Skip to content

Announce engine closure on disposal and terminal reconnect failure - #1268

Open
hiroshihorie wants to merge 5 commits into
mainfrom
hiroshi/close-engine-on-terminal-paths
Open

hiroshihorie wants to merge 5 commits into
mainfrom
hiroshi/close-engine-on-terminal-paths

Conversation

@hiroshihorie

@hiroshihorie hiroshihorie commented Oct 29, 2026 •

Copy link
Copy Markdown
Member

Disposing a Room or giving up on reconnection tore down the engine without marking it closed or emitting EngineClosingEvent. Subscribers waiting for closure never received it, and an unrecoverable reconnect left isClosed false.

This change centralizes terminal closure in an idempotent internal Engine.markClosed(). It marks the engine closed synchronously and queues EngineClosingEvent while the event emitter is still alive.

Disposal sequence

Room.dispose() calls engine.markClosed() before super.dispose() and before any disposal callbacks. This placement matters because Room's cleanup itself calls engine.cleanUp(), before it eventually calls engine.dispose(). Announcing closure only in Engine's constructor-registered disposal callback is too late for that first transport teardown.

Engine.dispose() also calls markClosed() before super.dispose(). Disposal callbacks run in reverse registration order, so callbacks registered later can safely await engine work that is already being cancelled. Engine's constructor-registered callback retains its cleanup sequence: clean up transports, dispose the emitter, dispose signal listeners, and release reliable receive state.

Explicit disconnect, retry exhaustion, and unrecoverable reconnect failure use the same closure helper before cleanup. Recoverable retries and full-reconnect transport replacement do not close the engine. Buffer waits started after closure reject immediately, even if the retained channel reports a low buffer. Closing-event delivery remains asynchronous. Disposal does not await completion of arbitrary event handlers.

What it fixes for a user

A room disposed while a full reconnect was tearing down the old connection used to join again with the old token about a second later, as a new participant sid. restartConnection now checks closure after the teardown and stops. A dispose or disconnect that lands later, while the reconnect is opening its new signal connection, still rejoins. That is pre-existing, the same on main, and left for the session follow-up.

Buffer wait error type

waitForBufferStatusLow() used to throw, or fail its wait with, the plain string 'Engine disconnected'. Data stream writes surface that error, so an app catching LiveKitException missed it. Both paths now use UnexpectedConnectionState, which client-sdk-js also throws when its engine is closed.

Relationship to other fixes

Validation

  • Nine lifecycle regression tests in engine_closing_test.dart cover closure events, recoverable versus terminal reconnect failures, waits started after closure, duplicate closure, and disposal ordering. One verifies that the closing event is delivered with Engine marked disposed but its separate emitter still alive.
  • disconnect_during_reconnect_test.dart disposes the room while a full reconnect is still tearing down, the case where this PR meets Stay closed when disconnect() lands during a full reconnect #1264. It fails on main, where the reconnect opens a new signal socket, and passes here.
  • Against main's lib, 10 of the 13 tests in those two files fail. Removing engine.markClosed() from Room.dispose() fails 3 of them, removing the post-teardown _isClosed check in restartConnection fails 2.
  • Full unit suite at 8d4c725: 521 passed, 1 skipped. flutter analyze, dart analyze web/, repository format, import order, version consistency, and git diff --check pass.
  • e2e-flutter on macOS against a local livekit-server --dev passes Connection basics, PingPong, duplicate identity, Delete Room, both data channel scenarios, full reconnect, disconnect during a full reconnect, migration and node failure.
  • livekit/e2e-flutter#23 adds the matching scenario, "Dispose during a full reconnect stays gone". It fails in 3 of 3 runs on main (88c72c6), where the identity joins again with a new participant sid about 1 s after dispose(), and passes in 3 of 3 here. On this head, the scenarios tagged reconnect, data and signaling pass, apart from expected failures and "Reliable data across a resume", the known gap until Send reliable data after a full reconnect instead of dropping it #1277.
  • A blocked data stream write with disconnect() or dispose() was checked with a throwaway test on both branches, with the results described above.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

No active review issues remain for the terminal engine-closure changes.

No open issues 🎉

What changed in this PR

This PR centralizes terminal engine closure so subscribers receive EngineClosingEvent before disposal or terminal reconnect cleanup begins. It complements the separate fixes for already-blocked buffer waits and disconnect-during-reconnect races.

Changes:

  • Adds idempotent closure handling before Room and Engine disposal callbacks and after terminal reconnect failures.
  • Rejects buffer waits started after closure while leaving recoverable reconnects open.
  • Adds ten lifecycle regression tests and a patch changeset.
File Description
test/​core/​engine_closing_test.dart Tests closure events, disposal ordering, reconnect outcomes, and post-closure waits.
lib/​src/​core/​room.dart Marks the engine closed before Room disposal callbacks run.
lib/​src/​core/​engine.dart Centralizes closure signaling and guards buffer waits after closure.
.changes/​engine-terminal-closure Records the lifecycle and buffer-wait fixes for release.

Review effort: Balanced

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no bugs or issues to report.

Devin Review

waitForBufferStatusLow() threw, or completed its wait with, the plain string
'Engine disconnected' once the engine closed. Data stream writes surface that
error, so an app catching LiveKitException missed it. Both paths now use
UnexpectedConnectionState, which client-sdk-js also throws when its engine is
closed.
The room tears down its transports before it closes the engine, so a full
reconnect that resumes during that teardown used to open a new signal socket.
The test pauses the reconnect and the dispose against each other and fails on
main without the disposal ordering in this PR.
@hiroshihorie
hiroshihorie force-pushed the hiroshi/close-engine-on-terminal-paths branch from ec7a1a3 to 8d4c725 Compare November 3, 2026 11:55

This branch has not been deployed

No deployments
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.

3 participants