Repository navigation
Conversation
Two failing tests against main: - ReconnectThenConnectTests.staleReconnectDoesNotTearDownNextSession: a reconnect held in its socket handshake (a local listener that never answers) is still unwinding when the app connects the Room to a new session, with or without disconnect() first. Once released, the stale reconnect cleans up over the new session: the room ends .disconnected. - LifecycleHandoffTests.connectFromDidDisconnectSurvivesAHeldDisconnect: disconnect() is held inside cleanUp() while the server's leave arrives; the app connects from didDisconnectWithError, and the old disconnect() then tears that session down. Both hold their ordering on barriers, not sleeps. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: omnigent <noreply@omnigent.ai>
…new session When the app moves a Room to a new session while an automatic reconnect of the old one is still running (socket died while suspended; on foreground the app calls connect(), with or without disconnect() first), the reconnect's failure path ran cleanUp() over the new session: connect() returned, then the room dropped to .disconnected(cancelled). - The `if !Task.isCancelled` guard read the outer, never-cancelled task. The whole reconnect (retry loop, success write, failure clean-up) is now one stored task that forwards cancellation into Task.retrying and checks its own cancellation before touching state. - Reconnect admission and registration happen in one StateSync mutation, so a connect()/disconnect() either finds it to cancel or refuses it. - connect(), disconnect() and the server's disconnect run one at a time on a SerialRunnerActor (as Track and LocalParticipant do). Each cancels the in-flight reconnect explicitly, waits for it to finish, then cleans up once; no reconnect starts while they hand off. - SignalClient drops a leave whose socket is no longer current at delivery, so the old session's leave cannot fail the next connect(). - fullConnectSequence is @nonobjc: as an implicitly @objc async method its calls dropped the caller's cancellation, so a cancelled reconnect waited out its socket connect. - AnyTaskCancellable gains wait(). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: omnigent <noreply@omnigent.ai>
…ate reconnect - reconnectIsRegisteredWhenAdmitted: no mutation admits a reconnect without registering it. - reconnectIsRefusedDuringHandoff: no reconnect starts while connect() waits for a stale one. - cancelledConnectEndsItsHandoff / connectQueuedBehindAHandoffRunsAfterIt. - connectFromDidDisconnectSurvivesTheDrain: a connect() started from didDisconnectWithError while disconnect() waits for a stale reconnect keeps its session. Each handoff test asserts the handoff marker is cleared. Ordering is held on barriers (a test-held reconnect, delegate-queue flush), not timeouts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: omnigent <noreply@omnigent.ai>
…dropped Two failing tests for the serialized lifecycle: - ReconnectThenConnectTests.disconnectInterruptsAHeldConnect: connect() is held in its socket handshake; disconnect(), without cancelling the connect task, must return promptly and the connect must throw .cancelled. Today disconnect() queues behind the connect, which throws .network once the handshake times out. - OfferWithJoinGateTests.leaveEnqueuedAfterItsSocketWasReplacedIsDropped: a leave that arrived on a socket since replaced is enqueued late (as the detached enqueue can be); it must not be delivered. Today the leave guard captures the socket current at processing time, so it is. The detached enqueue moves into enqueue(_:encoded:from:) so the test can run it late; no behaviour change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: omnigent <noreply@omnigent.ai>
…hind it Serializing the lifecycle queued disconnect() behind a connect() in progress, so a stalled join held disconnect() until it timed out; on main disconnect() aborted it at once. - connect() registers its runner task (SerialRunnerActor.enqueue, split out of run()) while it is queued or running; disconnect() cancels the ones registered before it, then queues its teardown as before. - SignalClient.connect() skips server validation once cancelled, so the interrupted connect throws .cancelled instead of a validation error. - A connect() failing after it joined (e.g. interrupted while publishing the microphone) sends a leave before cleaning up, as disconnect() would. - connectQueuedBehindAHandoffRunsAfterIt waits until the second connect is queued on the runner before releasing the first. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: omnigent <noreply@omnigent.ai>
…essing The leave guard captured the socket current when the response was processed. The detached enqueue can run after cleanUp() and a new connect replaced the socket; the old session's leave then captured the new socket, passed the guard and could end the new session. The response queue now carries the socket each message arrived on, and the leave is delivered only while that socket is still current. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: omnigent <noreply@omnigent.ai>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: omnigent <noreply@omnigent.ai>
…eave, and a drained leave oracle - connectIsRegisteredBeforeItRuns: _connect must already be in _connectTasks when it starts, or a disconnect() in between misses it. - RegionManagerTests.cancelledWaiterStopsWaitingForTheSharedFetch: a cancelled resolveBest() must stop waiting for a held settings fetch, which then still serves the next waiter. - disconnectDuringTheTransportWaitSendsTheLeave: interrupted after the JOIN while the transport cannot connect (relay-only, no TURN), the observer must see the participant leave with CLIENT_INITIATED. - leaveEnqueuedAfterItsSocketWasReplacedIsDropped flushes the delegate runner after each enqueue instead of racing a control leave. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: omnigent <noreply@omnigent.ai>
…arrived - connect() creates its task inside the _connectTasks mutation, so a disconnect() either finds it or was requested before it existed. Previously the runner could start _connect before the handle was inserted, and a disconnect() in that window queued behind a stalled join. - An interrupted or failed connect sends a leave when the signal connection has its JOIN, not only once the room reads .connected: between JOIN and transport-connected the room is still .connecting, where main's disconnect() did send the leave. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: omnigent <noreply@omnigent.ai>
A connect interrupted by disconnect() while resolving a Cloud region kept awaiting the shared fetch's value, which ignores the waiter's cancellation, so disconnect() stayed queued for up to URLSession's 60 s timeout. The waiter now waits on an AsyncCompleter fed by the fetch: it throws .cancelled at once, and the fetch carries on for other waiters. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: omnigent <noreply@omnigent.ai>
…rder - The leave handler captures the room's session before the region update can suspend and drops the leave if the session changed meanwhile; the queued server-disconnect clean-up checks that same session. Before, an old leave resuming after a replacement cleaned up the new connecting session, or captured it for the queued clean-up. - SignalClient queues the leave's delegate delivery in processing order (AsyncSerialDelegate.notifyQueued, on SerialRunnerActor.enqueue) instead of racing it in a detached task; the response queue awaits only the enqueue, never delegate code. This also makes the stale-leave test's delegate flush a real barrier. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: omnigent <noreply@omnigent.ai>
…d disconnect during microphone setup - LifecycleHandoffTests.leaveFromAReplacedSocketDoesNotFailTheNextConnect: the old session's leave reaches the room's handler only after a new connect replaced the socket (the delegate call can suspend after the signal client's check, SE-0338). The room must not clean up the new connect, which today fails it. - LifecycleHandoffTests.disconnectDoesNotWaitForTheMicrophone: with enableMicrophone and the microphone track held (an unanswered permission prompt), disconnect() must return while it is still held. Today it waits for it. Once connected, the interruption is reported to didDisconnectWithError as .cancelled. Seams, no behaviour change: the leave delegate call carries the socket it arrived on (the room ignores it for now); the microphone track comes from an overridable makeMicrophoneTrack(). HeldSocketServer moves to its own test file. disconnectDuringTheTransportWaitSendsTheLeave now chains the room's onDidMutate instead of replacing it (which silenced its delegates). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: omnigent <noreply@omnigent.ai>
… call SignalClient checked the leave's socket and then called into the room; that call can suspend (SE-0338), so a socket replaced in between let the old leave capture the new session. The delegate call now carries the socket the leave arrived on, and the room checks it and captures the session in one synchronous step on entry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: omnigent <noreply@omnigent.ai>
…t interrupts With enableMicrophone the microphone track can wait on a permission prompt, which ignores cancellation. The interrupted connect awaited it, either for publishing or to stop it in its catch, and disconnect() waited behind it. - The connect waits for the track via Task.valueUnlessCancelled(), which throws .cancelled once the connect is cancelled; the track task keeps running. - On failure the connect cancels the track task and stops its track when it finishes, without waiting for it. - AsyncCompleter accepts an infinite timeout (no timer scheduled), which the helper uses: a permission prompt may legitimately take minutes. - The region-settings waiter uses the same helper instead of a 120 s bound. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: omnigent <noreply@omnigent.ai>
…connect Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: omnigent <noreply@omnigent.ai>
…, and the borrowed pre-connect track - leaveForAReplacedSessionIsIgnoredOnTheSameSocket: the session is replaced while the leave's socket still reads current, as when a replacement lands between the room's separate socket and session reads. The old session's leave must not end the new one; today it does. - disconnectDoesNotWaitForTheMicrophone now also grants the held permission after the disconnect: the abandoned microphone task must stop before starting recording. Today it goes on to start it. - interruptedConnectDoesNotStopThePreConnectTrack: an interrupted connect must not stop the pre-connect buffer's track it only borrowed. Today its deferred clean-up stops it, which can land on the next session's publish. Seams, no behaviour change: SignalClient records the room session with the socket it installs and passes the leave's session to the delegate (the room ignores it for now); LocalAudioTrack's permission request is an overridable requestMicrophonePermission(). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: omnigent <noreply@omnigent.ai>
The room checked the leave's socket and read its session under separate locks, so a session replaced between the two reads let the old leave act on the new one. SignalClient now records the room session in the same mutation that installs the socket, and reads both together when the leave is delivered; the room only compares that session with its current one, in single reads, before acting and in the queued clean-up. The leave's delegate call no longer carries the socket. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: omnigent <noreply@omnigent.ai>
The permission prompt ignores cancellation, so a microphone task abandoned by an interrupted connect() went on, once permission was granted, to configure and start shared audio recording, possibly under the next session. LocalAudioTrack.startCapture() now checks cancellation after the permission request and before any recording side effect; the connect's catch already cancels the task, and Track.start() forwards that cancellation into startCapture(). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: omnigent <noreply@omnigent.ai>
The connect took the pre-connect buffer's track through the same task as a microphone track it makes itself, so its deferred clean-up stopped the borrowed track too, possibly after the next session had published it (resetting that publish's frame watcher). The pre-connect track is now published directly and never stopped by connect(); only a microphone track the connect made itself is stopped on failure. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: omnigent <noreply@omnigent.ai>
With the executor held, the start is granted permission, queues its recording step, and is cancelled before that step runs; it must end cancelled without starting recording. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: omnigent <noreply@omnigent.ai>
…fore recording The check ran before the hop to @rtc; a cancel landing while the start waited for that executor still configured and started shared recording. It now runs inside the RTC closure, immediately before startLocalRecording, which covers every cancel up to that point, so the pre-hop check is dropped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: omnigent <noreply@omnigent.ai>
LocalAudioTrack and Room are @objcMembers, so requestMicrophonePermission() and cleanUpForServerDisconnect(withError:session:) were exported as Objective-C selectors. Both are @nonobjc now; the generated header and the built object's Objective-C method names match main. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: omnigent <noreply@omnigent.ai>
…track Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: omnigent <noreply@omnigent.ai>
Comment only: `requested` proves the permission request was entered, not that the recording step is already queued on the executor. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: omnigent <noreply@omnigent.ai>
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
An app can move a
Roomto a new session while an automatic reconnect of the old session is still running, typically when it returns to the foreground after suspension. When that stale reconnect fails, its failure path cleans up the new session.connect()returns successfully, and then the room drops to.disconnected.Fix
connect(),disconnect()and the server's disconnect are serialized on the existingSerialRunnerActor, each running to completion.disconnect()still interrupts an in-flightconnect()instead of queuing behind it. That includes a Cloud region-settings fetch and a microphone start waiting on its permission prompt.connect()only stops tracks it created itself.Behaviour changes
disconnect()that interrupts aconnect()after it connected reports.cancelledtoroom(_:didDisconnectWithError:)instead ofnil.connect()no longer stops the pre-connect audio track. The pre-connect buffer owns that track (withPreConnectAudiostops it on failure), and a buffer started manually must be stopped by its owner.Known limitation
A server leave that arrives while
connect()is still joining still cleans up alongside the connect's own clean-up, as onmain. It is out of scope here.Testing
ReconnectThenConnectTests,LifecycleHandoffTests,OfferWithJoinGateTestsandRegionManagerTests.staleReconnectDoesNotTearDownNextSession) and the held-disconnect test (connectFromDidDisconnectSurvivesAHeldDisconnect) fail onmain.LiveKitCoreTestssuite passes locally againstlivekit-server --dev: 471 tests in 98 suites.LiveKit-Swift.his identical tomain.