Skip to content

RTCEngine.join() reopens an engine closed mid-join, so Room.disconnect() during connect/reconnect leaves an orphaned engine that keeps rejoining #2150

Open

Description

@jensellfors

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-client 2.22.4 (current latest)
  • React Native via @livekit/react-native 3.0 / @livekit/components-react 2.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 pending

There are two ways to hit it:

  1. Room.disconnect() during Connecting.
    • disconnect() aborts the controller, awaits engine.close(), then sets this.engine = undefined.
    • If the signal join is already past its last abort check, client.join still resolves. The engine reopens, and Room.attemptConnection continues on this.engine, which is now undefined. That throws TypeErrors such as Cannot read property 'client' of undefined.
    • The reopened engine is never closed again.
  2. engine.close() during an internal full reconnect. restartConnection() calls this.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

  1. const room = new Room(); room.connect(url, token);
  2. Call await room.disconnect() while the room is still Connecting. Around 0.5–1.5s works; it's easier on a slow network, or by delaying the join response.
  3. Watch the room on the server: after disconnect() resolves, the identity keeps rejoining with new participant SIDs. On the client there are no Room events (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(): after client.join resolves, bail out if the engine was closed or the attempt aborted while it was pending. For example, keep a close generation counter: increment it in close(), capture it at the start of join(), and compare after the await, alongside abortSignal?.aborted. If it changed, send a best-effort leave, close the signal client, and throw ConnectionError.cancelled(...) instead of reopening.
  • In Room.attemptConnection: capture const engine = this.engine once and use that local reference, rather than re-reading this.engine after awaits, so a concurrent disconnect() can't cause undefined dereferences. In catch paths, only recreateEngine() if this.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

  1. jensellfors commented on Oct 14, 2026

    @jensellfors
    Author

    More data on variant 2 (close() during an internal reconnect):

    • Setup: a connected call on an Android emulator whose JS thread stalled, so the engine fell into its reconnect loop. We then called room.disconnect() while the room was Reconnecting.
    • The leave was sent, and the server deleted the room shortly afterwards.
    • The engine kept going anyway: it kept running signal connecting and full-reconnected with the saved token. That recreated the deleted room on the server (new room SID, new participant SID).
    • Why it's worse than lingering: with server-side room auto-create (and any agent dispatch configured in the token's room config), an orphaned engine can respawn a whole session after the app has left.

    In the same run, every case of disconnect() while Connecting was clean once we deferred the disconnect until the connect settled (6/6). That points at the join() reopen in the restart path as what's left.

  2. lukasIO commented on Oct 16, 2026

    @lukasIO
    Contributor

    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

  3. jensellfors commented on Oct 16, 2026

    @jensellfors
    Author

    Thanks @lukasIO, that was quick! We read through both PRs. #2095's cancel checks after the signal join response and through restartConnection cover both ways we were hitting this, and #2151 takes care of the TypeError we 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 bump livekit-client and test once these ship. Could you ping this issue when a release includes them?

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions