From 103f6b6c9e9e33741a005df56a48169a89122dad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Mon, 14 Sep 2026 10:21:43 +0200 Subject: [PATCH 1/7] test(data-channel): make sendAsync a barrier in drop-oldest tests flushEvents() only orders events already yielded to the drain's stream; the unstructured Task in sendAsync could submit after the evict/fail event it was meant to settle, parking the write behind a fake channel that never drains and hanging the test process. Signal after submit() returns so the barrier holds. CI: 15 of 37 idle-until-timeout Build & Test jobs (Sep 10-13) ended on evictionResolvesTheDisplacedWaiter / teardownFailsQueuedWaiters. Reproduced by delaying the task 20 ms; the same delay passes with the barrier. The file crosses swiftlint's 400-line limit; disabled as in PeerConnectionSignalingTests. Co-Authored-By: Claude Fable 5.1 --- .../DataChannel/DataChannelDrainTests.swift | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/Tests/LiveKitCoreTests/DataChannel/DataChannelDrainTests.swift b/Tests/LiveKitCoreTests/DataChannel/DataChannelDrainTests.swift index e20ff8c6b..e06ba9ba4 100644 --- a/Tests/LiveKitCoreTests/DataChannel/DataChannelDrainTests.swift +++ b/Tests/LiveKitCoreTests/DataChannel/DataChannelDrainTests.swift @@ -14,6 +14,8 @@ * limitations under the License. */ +// swiftlint:disable file_length + import Foundation @testable import LiveKit import Testing @@ -274,15 +276,25 @@ struct DropOldestContinuationTests { drain.attach(sendTarget: channel) } - private func sendAsync(_ tag: UInt8) -> Task { - Task { try await drain.send(DrainFixture.frame(tag)) } + /// Starts a send and returns once its submit has reached the drain's event stream, so a + /// `flushEvents()` that follows is a barrier behind it: `Task {}` alone may not have run yet. + private func sendAsync(_ tag: UInt8) async -> Task { + let (submitted, mark) = AsyncStream.makeStream(of: Void.self) + let task = Task { + try await withCheckedThrowingContinuation { continuation in + drain.submit(DrainFixture.frame(tag), continuation: continuation) + mark.finish() + } + } + for await _ in submitted {} + return task } @Test(.spec("https://github.com/livekit/client-sdk-js/blob/499c8420/src/room/RTCEngine.ts#L1458")) func evictionResolvesTheDisplacedWaiter() async throws { try await drain.fillBuffer(of: channel) - let displaced = sendAsync(1) + let displaced = await sendAsync(1) try await drain.flushEvents() // A newer group evicts the queued one; its waiter must not be left suspended. @@ -294,7 +306,7 @@ struct DropOldestContinuationTests { @Test func channelSwapResolvesQueuedWaiters() async throws { try await drain.fillBuffer(of: channel) - let queued = sendAsync(1) + let queued = await sendAsync(1) try await drain.flushEvents() drain.attach(sendTarget: FakeSendChannel()) @@ -309,7 +321,7 @@ struct DropOldestContinuationTests { @Test func rejectedSendFailsItsWaiterExactlyOnce() async throws { channel.acceptsSends = false - let waiter = sendAsync(1) + let waiter = await sendAsync(1) await #expect { try await waiter.value @@ -324,7 +336,7 @@ struct DropOldestContinuationTests { @Test func teardownFailsQueuedWaiters() async throws { try await drain.fillBuffer(of: channel) - let queued = sendAsync(1) + let queued = await sendAsync(1) try await drain.flushEvents() drain.reset(throwing: LiveKitError(.invalidState, message: "torn down")) From f2c202046de076b73f9e894dc818c2a72e72516d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Mon, 14 Sep 2026 10:21:43 +0200 Subject: [PATCH 2/7] test(support): bound next(within:) without cancelling the UniFFI read The UniFFI future under DataTrackStream.next() cannot be cancelled, so the task group in next(within:) waited on it past the deadline and a single lost frame hung the job. Race the read against an AsyncCompleter timeout and let a timed-out read finish when the stream ends. CI: 11 of 37 idle-until-timeout jobs (publishAndReceive largeFrames, setPipelineOptionsReassemblesMultiPacketFrames, publishWithFrameMetadata). Expecting one more frame than sent now fails in 15 s instead of hanging. Co-Authored-By: Claude Fable 5.1 --- Tests/LiveKitTestSupport/Room+DataTrack.swift | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/Tests/LiveKitTestSupport/Room+DataTrack.swift b/Tests/LiveKitTestSupport/Room+DataTrack.swift index c5d9c2d04..10ed31537 100644 --- a/Tests/LiveKitTestSupport/Room+DataTrack.swift +++ b/Tests/LiveKitTestSupport/Room+DataTrack.swift @@ -133,17 +133,12 @@ public final class DataTrackDelegateRecorder: NSObject, RoomDelegate, Participan /// waits forever if a frame is lost — on an unreliable channel that turns a failed assertion into /// a hung job. public extension DataTrackStream { - /// The next frame, or `nil` if none arrives in time. + /// The next frame, or `nil` if none arrives in time. The read is not cancelled on timeout — the + /// UniFFI future under `next()` cannot be — so it is left to finish when the stream ends. func next(within timeout: TimeInterval = 15) async -> DataTrackFrame? { - await withTaskGroup(of: DataTrackFrame?.self) { group in - group.addTask { await self.next() } - group.addTask { - try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) - return nil - } - defer { group.cancelAll() } - return await group.next() ?? nil - } + let frame = AsyncCompleter(label: "data track frame", defaultTimeout: timeout) + Task { await frame.resume(returning: self.next()) } + return try? await frame.wait() } /// Up to `count` frames matching `predicate`, or fewer if the deadline passes first. From 570c59a77c8d3b9d116448561734ccded60d4157 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Mon, 14 Sep 2026 10:21:44 +0200 Subject: [PATCH 3/7] test(participant): time out via completer reset, not wiped info The `...Timeout` tests faked an inactive participant with set(info: .init(), connectionState:), which ignores the connection state and instead blanks sid/identity and flips state to JOINING - failing a completer keyed "" with participantRemoved, so the tests passed on an existing error rather than a timeout, and leaving Room with participants it can no longer address. Reset the room's active completer for the identity instead, so waitUntilActive genuinely times out, and assert .timedOut. CI: 4 of 37 idle-until-timeout jobs ended in waitUntillAnyActiveTimeout - the only test that blanked both remote participants - after the expected throws and before the leave reached the server. Does not reproduce on macOS (0/8 locally, 0/4 in CI), so this removes what was unique to the test rather than a verified wedge. Co-Authored-By: Claude Fable 5.1 --- .../Participant/RemoteParticipantTests.swift | 30 ++++++++++++------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/Tests/LiveKitCoreTests/Participant/RemoteParticipantTests.swift b/Tests/LiveKitCoreTests/Participant/RemoteParticipantTests.swift index 8940840d2..64e121694 100644 --- a/Tests/LiveKitCoreTests/Participant/RemoteParticipantTests.swift +++ b/Tests/LiveKitCoreTests/Participant/RemoteParticipantTests.swift @@ -26,6 +26,13 @@ import LiveKitTestSupport struct RemoteParticipantTests { let timeout: TimeInterval = 0.1 + /// Makes `room` forget that `participant` became active, so `waitUntilActive` has to wait for a + /// transition that never comes rather than return the cached outcome. + private func forgetActive(_ participant: RemoteParticipant, in room: Room) async throws { + let identity = try #require(participant.identity) + await room.activeParticipantCompleters.completer(for: identity.stringValue).reset() + } + @Test func waitUntilActiveSuccess() async throws { try await TestEnvironment.withRooms(Array(repeating: RoomTestingOptions(), count: 2)) { rooms in let active = try #require(rooms[0].remoteParticipants.values.first) @@ -36,10 +43,10 @@ struct RemoteParticipantTests { @Test func waitUntilActiveTimeout() async throws { try await TestEnvironment.withRooms(Array(repeating: RoomTestingOptions(), count: 2)) { rooms in - let disconnected = try #require(rooms[0].remoteParticipants.values.first) - disconnected.set(info: .init(), connectionState: .disconnected) + let inactive = try #require(rooms[0].remoteParticipants.values.first) + try await forgetActive(inactive, in: rooms[0]) - await #expect(throws: (any Error).self) { try await disconnected.waitUntilActive(timeout: self.timeout) } + await #expect { try await inactive.waitUntilActive(timeout: self.timeout) } throws: { ($0 as? LiveKitError)?.type == .timedOut } } } @@ -53,10 +60,10 @@ struct RemoteParticipantTests { @Test func waitUntillAllActiveTimeout() async throws { try await TestEnvironment.withRooms(Array(repeating: RoomTestingOptions(), count: 3)) { rooms in - let oneDisconnected = try #require(rooms[0].remoteParticipants.values.first) - oneDisconnected.set(info: .init(), connectionState: .disconnected) + let oneInactive = try #require(rooms[0].remoteParticipants.values.first) + try await forgetActive(oneInactive, in: rooms[0]) - await #expect(throws: (any Error).self) { try await rooms[0].remoteParticipants.values.waitUntilAllActive(timeout: self.timeout) } + await #expect { try await rooms[0].remoteParticipants.values.waitUntilAllActive(timeout: self.timeout) } throws: { ($0 as? LiveKitError)?.type == .timedOut } try await rooms[1].remoteParticipants.values.waitUntilAllActive(timeout: timeout) try await rooms[2].remoteParticipants.values.waitUntilAllActive(timeout: timeout) } @@ -72,8 +79,8 @@ struct RemoteParticipantTests { @Test func waitUntillAnyActiveNoTimeout() async throws { try await TestEnvironment.withRooms(Array(repeating: RoomTestingOptions(), count: 3)) { rooms in - let oneDisconnected = try #require(rooms[0].remoteParticipants.values.first) - oneDisconnected.set(info: .init(), connectionState: .disconnected) + let oneInactive = try #require(rooms[0].remoteParticipants.values.first) + try await forgetActive(oneInactive, in: rooms[0]) try await rooms[0].remoteParticipants.values.waitUntilAnyActive(timeout: timeout) try await rooms[1].remoteParticipants.values.waitUntilAnyActive(timeout: timeout) @@ -83,10 +90,11 @@ struct RemoteParticipantTests { @Test func waitUntillAnyActiveTimeout() async throws { try await TestEnvironment.withRooms(Array(repeating: RoomTestingOptions(), count: 3)) { rooms in - let allDisconnected = rooms[0].remoteParticipants.values - allDisconnected.forEach { $0.set(info: .init(), connectionState: .disconnected) } + for participant in rooms[0].remoteParticipants.values { + try await forgetActive(participant, in: rooms[0]) + } - await #expect(throws: (any Error).self) { try await rooms[0].remoteParticipants.values.waitUntilAnyActive(timeout: self.timeout) } + await #expect { try await rooms[0].remoteParticipants.values.waitUntilAnyActive(timeout: self.timeout) } throws: { ($0 as? LiveKitError)?.type == .timedOut } try await rooms[1].remoteParticipants.values.waitUntilAnyActive(timeout: timeout) try await rooms[2].remoteParticipants.values.waitUntilAnyActive(timeout: timeout) } From 8225df962498ec4c379862a1b7745f158389ef62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Mon, 14 Sep 2026 11:32:46 +0200 Subject: [PATCH 4/7] ci: dump test-host threads and tasks when the log stalls A hung Build & Test job leaves only silence: the client log stops, the server sees pings and no leave, and the 30-minute timeout kills the host before anything records where it was. Start a watchdog next to xcodebuild that, after five minutes without log growth, writes for every xctest a `sample` (thread stacks) and `swift-inspect dump-concurrency` (the runtime's task tree with async backtraces), up to three times, and ship them with the test-log artifact. Both are needed: a suspended task has no thread, so `sample` cannot show it, while the task dump names it (validated locally on a leaked continuation and on a blocked semaphore under xcodebuild; lldb's tasks plugin only unwinds tasks that are on a thread). swift-inspect attaches to a test host without sudo. Co-Authored-By: Claude Fable 5.1 --- .github/stall-dump/stall-dump.sh | 22 ++++++++++++++++++++++ .github/workflows/ci.yaml | 6 +++++- 2 files changed, 27 insertions(+), 1 deletion(-) create mode 100755 .github/stall-dump/stall-dump.sh diff --git a/.github/stall-dump/stall-dump.sh b/.github/stall-dump/stall-dump.sh new file mode 100755 index 000000000..d87662578 --- /dev/null +++ b/.github/stall-dump/stall-dump.sh @@ -0,0 +1,22 @@ +#!/bin/bash +# Usage: stall-dump.sh [stall-seconds] +# +# Watches ; each time it stops growing for , dumps every test host into +# : a thread sample (a blocked thread) and the Swift concurrency runtime's task tree with +# async backtraces (a suspended task, which no thread sample can show). Up to three dumps, then exits. +log=$1; out=$2; stall=${3:-300} +last=-1; quiet=0; dumps=0 +while sleep 10; do + size=$(stat -f%z "$log" 2>/dev/null || echo 0) + if [ "$size" != "$last" ]; then last=$size; quiet=0; continue; fi + quiet=$((quiet + 10)) + [ "$quiet" -lt "$stall" ] && continue + quiet=0; dumps=$((dumps + 1)); mkdir -p "$out" + pids=$(pgrep -x xctest); [ -z "$pids" ] && pids=$(pgrep -x xcodebuild) + for pid in $pids; do + ps -o pid=,ppid=,etime=,command= -p "$pid" >> "$out/$dumps-processes.txt" + sample "$pid" 5 -file "$out/$dumps-sample-$pid.txt" >/dev/null 2>&1 + swift-inspect dump-concurrency "$pid" > "$out/$dumps-tasks-$pid.txt" 2>&1 + done + [ "$dumps" -ge 3 ] && exit 0 +done diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 1d3359b3c..6ad3cb0b3 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -124,6 +124,8 @@ jobs: - name: Build & Test timeout-minutes: 30 run: | + # A hung job's log goes quiet; capture where the test host is stuck before the timeout kills it. + .github/stall-dump/stall-dump.sh "$RUNNER_TEMP/xcodebuild.log" "$RUNNER_TEMP/stall-dump" & set -o pipefail && xcodebuild test \ -scheme LiveKit \ -destination 'platform=${{ matrix.platform }}' \ @@ -146,7 +148,9 @@ jobs: uses: actions/upload-artifact@v7 with: name: test-log-${{ strategy.job-index }} - path: ${{ runner.temp }}/xcodebuild.log + path: | + ${{ runner.temp }}/xcodebuild.log + ${{ runner.temp }}/stall-dump retention-days: 5 # Client-side logs can't distinguish "we gave up" from "the SFU hung up on From 6eadda387343f1a9dd7a042df5009d4f08d2b302 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Mon, 14 Sep 2026 11:50:22 +0200 Subject: [PATCH 5/7] ci: stop retrying failed tests With Swift Testing, every retry iteration re-runs the whole selection, not the failed test: one flake in iteration 1 (798 s) triggered a full second pass (781 s) and the job ran into its 30-minute limit before the ObjC bundle could finish. A flake now fails the job with its log attached instead of being hidden behind a retry or turned into a timeout. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/ci.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6ad3cb0b3..057327650 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -136,7 +136,6 @@ jobs: -only-testing:LiveKitNanopbTests \ -only-testing:LiveKitObjCTests \ -parallel-testing-enabled NO \ - -retry-tests-on-failure -test-iterations 2 \ | tee "$RUNNER_TEMP/xcodebuild.log" \ | xcbeautify --renderer github-actions From 13058e3249d06dddfd66ddb2e3729175c6192214 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Mon, 14 Sep 2026 12:41:42 +0200 Subject: [PATCH 6/7] fix(async): resume AsyncCompleter waiters outside its lock The timeout block and the cancellation handler both took `_lock` and resumed the continuation inside it. Resuming needs the task's status-record lock, and `swift_task_cancel` runs the cancellation handler while holding that lock - so a waiter cancelled at the instant its own timer fired deadlocked two threads: the timer queue holding `_lock`, the cancelling task holding the status record. `waitUntilAnyActive` triggers exactly that when every child times out together; the first throw cancels the siblings as their timers go off. Caught by the stall-dump watchdog on CI (macos-15: three identical dumps five minutes apart, `WaitEntry.timeout -> swift_continuation_throwingResume -> waitForStatusRecordUnlock` against `swift_task_cancel -> onCancel -> _lock`) and reproduced locally 3/3 with two waiters timing out under `prefix(1)`. Take the entries out under the lock and settle them after it. The regression test races 2,000 such groups and is bounded by a completer of its own, so a recurrence fails the test instead of wedging the job. Co-Authored-By: Claude Fable 5.1 --- .changes/async-completer-cancel-deadlock | 1 + .../Support/Async/AsyncCompleter.swift | 36 +++++++++---------- .../AsyncCompleter+Test.swift | 25 +++++++++++++ 3 files changed, 42 insertions(+), 20 deletions(-) create mode 100644 .changes/async-completer-cancel-deadlock diff --git a/.changes/async-completer-cancel-deadlock b/.changes/async-completer-cancel-deadlock new file mode 100644 index 000000000..767cc2ae2 --- /dev/null +++ b/.changes/async-completer-cancel-deadlock @@ -0,0 +1 @@ +patch type="fixed" "Fix a deadlock when a `waitUntilActive` / `waitUntilAnyActive` timeout fired at the moment the wait was cancelled, which could hang the app's task and a timer thread" diff --git a/Sources/LiveKit/Support/Async/AsyncCompleter.swift b/Sources/LiveKit/Support/Async/AsyncCompleter.swift index c2c5b493b..5c2770d45 100644 --- a/Sources/LiveKit/Support/Async/AsyncCompleter.swift +++ b/Sources/LiveKit/Support/Async/AsyncCompleter.swift @@ -63,6 +63,8 @@ actor CompleterMapActor { } } +/// Waiters are always resumed outside `_lock`: the runtime resumes a continuation under the task's +/// status-record lock, and a cancellation handler runs under that same lock while taking `_lock`. final class AsyncCompleter: @unchecked Sendable, Loggable { // struct WaitEntry { @@ -116,12 +118,14 @@ final class AsyncCompleter: @unchecked Sendable, Loggable { } func reset(throwing error: Error? = nil) { - _lock.sync { - for entry in _entries.values { - entry.cancel(throwing: LiveKitError.from(error: error)) - } + let entries = _lock.sync { + let entries = Array(_entries.values) _entries.removeAll() _result = nil + return entries + } + for entry in entries { + entry.cancel(throwing: LiveKitError.from(error: error)) } } @@ -134,16 +138,18 @@ final class AsyncCompleter: @unchecked Sendable, Loggable { } func resume(with result: Result) { - _lock.sync { + let entries = _lock.sync { if let _result { log("\(label) already resolved \(_entries) with \(_result)", .debug) } - for entry in _entries.values { - entry.resume(with: result) - } + let entries = Array(_entries.values) _entries.removeAll() _result = result + return entries + } + for entry in entries { + entry.resume(with: result) } } @@ -180,12 +186,7 @@ final class AsyncCompleter: @unchecked Sendable, Loggable { let timeoutBlock = DispatchWorkItem { [weak self] in guard let self else { return } log("\(label) id: \(entryId) timed out") - _lock.sync { - if let entry = self._entries[entryId] { - entry.timeout() - } - self._entries.removeValue(forKey: entryId) - } + _lock.sync { _entries.removeValue(forKey: entryId) }?.timeout() } _lock.sync { @@ -200,12 +201,7 @@ final class AsyncCompleter: @unchecked Sendable, Loggable { } } onCancel: { // Cancel only this completer when Task gets cancelled - _lock.sync { - if let entry = self._entries[entryId] { - entry.cancel() - } - self._entries.removeValue(forKey: entryId) - } + _lock.sync { _entries.removeValue(forKey: entryId) }?.cancel() } } } diff --git a/Tests/LiveKitCoreTests/AsyncCompleter+Test.swift b/Tests/LiveKitCoreTests/AsyncCompleter+Test.swift index 3f7a0e683..b5951937f 100644 --- a/Tests/LiveKitCoreTests/AsyncCompleter+Test.swift +++ b/Tests/LiveKitCoreTests/AsyncCompleter+Test.swift @@ -15,6 +15,7 @@ */ @testable import LiveKit +import Testing extension AsyncCompleter { /// Yields until at least one waiter has parked on this completer — used @@ -26,3 +27,27 @@ extension AsyncCompleter { } } } + +struct AsyncCompleterCancellationTests { + /// A waiter cancelled while its own timeout is firing must settle, not deadlock: the first child + /// to time out cancels the rest at the very moment their timers go off. + @Test func cancelRacingTimeoutSettles() async throws { + let races = Task.detached { + for _ in 0 ..< 2000 { + let first = AsyncCompleter(label: "first", defaultTimeout: 1) + let second = AsyncCompleter(label: "second", defaultTimeout: 1) + _ = try? await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { try await first.wait(timeout: 0.001) } + group.addTask { try await second.wait(timeout: 0.001) } + for try await _ in group.prefix(1) { + group.cancelAll() + } + } + } + } + // Bounded by a completer of its own, so a regression fails the test instead of the job. + let finished = AsyncCompleter(label: "races", defaultTimeout: 30) + Task.detached { await races.value; finished.resume(returning: ()) } + try await finished.wait() + } +} From fcfea97c6304c896cc68f9be6943626cf76c2187 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Mon, 14 Sep 2026 13:11:06 +0200 Subject: [PATCH 7/7] test(async): box the completer deadlock test by wall clock Moves cancelRacingTimeoutSettles next to the other completer tests and races for a three-second budget instead of a fixed 2,000 iterations. On loaded simulators the utility-QoS timers behind 4,000 one-millisecond waits fired tens of seconds late (the 30 s bound's own timer fired ~25 s late on visionOS), so slowness was indistinguishable from the deadlock the test guards against. The pre-fix deadlock hits within the first 75 races, so a time box loses no sensitivity, and only a deadlock can now leave the loop unfinished; the bound is widened accordingly. Co-Authored-By: Claude Fable 5.1 --- .../AsyncCompleter+Test.swift | 25 ------------------ Tests/LiveKitCoreTests/CompleterTests.swift | 26 +++++++++++++++++++ 2 files changed, 26 insertions(+), 25 deletions(-) diff --git a/Tests/LiveKitCoreTests/AsyncCompleter+Test.swift b/Tests/LiveKitCoreTests/AsyncCompleter+Test.swift index b5951937f..3f7a0e683 100644 --- a/Tests/LiveKitCoreTests/AsyncCompleter+Test.swift +++ b/Tests/LiveKitCoreTests/AsyncCompleter+Test.swift @@ -15,7 +15,6 @@ */ @testable import LiveKit -import Testing extension AsyncCompleter { /// Yields until at least one waiter has parked on this completer — used @@ -27,27 +26,3 @@ extension AsyncCompleter { } } } - -struct AsyncCompleterCancellationTests { - /// A waiter cancelled while its own timeout is firing must settle, not deadlock: the first child - /// to time out cancels the rest at the very moment their timers go off. - @Test func cancelRacingTimeoutSettles() async throws { - let races = Task.detached { - for _ in 0 ..< 2000 { - let first = AsyncCompleter(label: "first", defaultTimeout: 1) - let second = AsyncCompleter(label: "second", defaultTimeout: 1) - _ = try? await withThrowingTaskGroup(of: Void.self) { group in - group.addTask { try await first.wait(timeout: 0.001) } - group.addTask { try await second.wait(timeout: 0.001) } - for try await _ in group.prefix(1) { - group.cancelAll() - } - } - } - } - // Bounded by a completer of its own, so a regression fails the test instead of the job. - let finished = AsyncCompleter(label: "races", defaultTimeout: 30) - Task.detached { await races.value; finished.resume(returning: ()) } - try await finished.wait() - } -} diff --git a/Tests/LiveKitCoreTests/CompleterTests.swift b/Tests/LiveKitCoreTests/CompleterTests.swift index 1fabd9e72..5eb5c749b 100644 --- a/Tests/LiveKitCoreTests/CompleterTests.swift +++ b/Tests/LiveKitCoreTests/CompleterTests.swift @@ -174,6 +174,32 @@ struct CompleterTests { completer.resume(returning: ()) try await secondTask.value } + + /// A waiter cancelled while its own timeout is firing must settle, not deadlock: the first child + /// to time out cancels the rest at the very moment their timers go off. Races for a fixed wall-clock + /// budget rather than a count, so a slow host cannot turn slowness into a timeout: only a deadlock + /// leaves the loop unfinished. + @Test func cancelRacingTimeoutSettles() async throws { + let races = Task.detached { + let deadline = Date().addingTimeInterval(3) + while Date() < deadline { + let first = AsyncCompleter(label: "first", defaultTimeout: 1) + let second = AsyncCompleter(label: "second", defaultTimeout: 1) + _ = try? await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { try await first.wait(timeout: 0.001) } + group.addTask { try await second.wait(timeout: 0.001) } + for try await _ in group.prefix(1) { + group.cancelAll() + } + } + } + } + // Bounded by a completer of its own, so a regression fails the test instead of the job. Generous, + // because utility-QoS timers have been seen to fire tens of seconds late on loaded simulators. + let finished = AsyncCompleter(label: "races", defaultTimeout: 120) + Task.detached { await races.value; finished.resume(returning: ()) } + try await finished.wait() + } } @Suite(.tags(.concurrency))