Fix the flaky ElementCallServiceTests by replacing TestClock

The suite intermittently hung on CI, most recently for the full 2 minute time
limit on lifetimeIsCapped(). A spindump from a failed run shows the process
suspended rather than spinning (0.247s of CPU over 190s) with a background
priority worker thread sitting runnable for roughly 145 seconds without ever
being scheduled.

That band is exactly what swift-clocks' TestClock depends on to make progress:
both advance() and checkSuspension() await Task.megaYield(), which serially
awaits 20 detached tasks at background priority. On an oversubscribed runner
those never run, so the test waits forever instead of failing. The same
mechanism, less starved, is why two of the passing tests took 1.0s and 0.77s
for what is otherwise pure logic. checkSuspension() made it worse, since
asking whether a timer was armed cost a megaYield per poll.

ManualClock replaces it. It holds a single pending sleep, since that is all a
test needs to step through: the sleep registers a continuation, advance(by:)
resumes it synchronously once its deadline passes, and waitForScheduledSleep()
suspends until the timer is armed instead of polling for it. Nothing in the
path touches background priority and no loop's iteration count depends on the
scheduler. The suite now runs in 0.019s, and under a locally saturated CPU the
old version took 1.005s against the new one's 0.037s.

Also drops the now unused Clocks package, removes the 2 minute time limit that
was papering over the starvation, re-enables callIsTimingOut() which the same
flakiness had disabled, and has lifetimeIsCapped() assert the 90 second cap
directly rather than inferring it from when the timer fires.
This commit is contained in:
Stefan Ceriu
2026-08-14 10:55:27 +03:00
parent 1db7a9b07c
commit f7f55024ab
7 changed files with 254 additions and 81 deletions
+12 -21
View File
@@ -700,7 +700,6 @@
71643093F87153F633A1B025 /* ThreadDecorator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA4F09CB613C54FDC73AE6A /* ThreadDecorator.swift */; };
719E7AAD1F8E68F68F30FECD /* Task.swift in Sources */ = {isa = PBXBuildFile; fileRef = A40C19719687984FD9478FBE /* Task.swift */; };
71AC1CAAC23403FFE847F2C9 /* ComposerToolbarViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C90514BE9B8ACCBCF0AD2489 /* ComposerToolbarViewModel.swift */; };
71AF9345A1202DB9B5DCD9AE /* Macros in Frameworks */ = {isa = PBXBuildFile; productRef = AF574A0FC6F3A83D3BFFC9B3 /* Macros */; };
71B62C48B8079D49F3FBC845 /* ExpiringTaskRunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84B7A28A6606D58D1E38C55A /* ExpiringTaskRunnerTests.swift */; };
71C1347F23868324A4F43940 /* NavigationModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A22A05E472533ED3C5A31B3 /* NavigationModule.swift */; };
723382158BFD148984201266 /* HomeserverHistoryManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9292E3516E65DED7130684 /* HomeserverHistoryManagerTests.swift */; };
@@ -709,6 +708,7 @@
72D2298DE695A6797CDA1A2A /* SpaceScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18B223FA339BF53085328DEE /* SpaceScreenViewModelTests.swift */; };
733E2B19AB1FDA3B93293A28 /* AppLockSetupPINScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3F275432954C8C6B1B7D966 /* AppLockSetupPINScreen.swift */; };
738288EAEE235CAC0893AB9E /* ThreadTimelineScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C9ACDD96F36510C1FC0836B /* ThreadTimelineScreenViewModel.swift */; };
73D4437A6A410FA2B94CFE0F /* ManualClock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32435409710BF1A54C3BBD53 /* ManualClock.swift */; };
73F33E9776B7A50B65A031D2 /* AppLockSettingsScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0BA67B3E4EF9D29D14A78CE /* AppLockSettingsScreenViewModelTests.swift */; };
73F547BEB41D3DAFAAF6E0AF /* UserProfileScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71E2E5103702D13361D09100 /* UserProfileScreenViewModelTests.swift */; };
7405B4824D45BA7C3D943E76 /* Application.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D0CBC76C80E04345E11F2DB /* Application.swift */; };
@@ -1127,7 +1127,7 @@
B5899F18AD6C56CE08FE532B /* RoomSummaryProviderMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC83F47D2173B7538AA72E0E /* RoomSummaryProviderMock.swift */; };
B5B68C7511DE1E36A89D353A /* SpaceAddRoomsScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4338BB055A26094673CDC220 /* SpaceAddRoomsScreenViewModel.swift */; };
B5BCE012F9E7C45D1C76108E /* RoomMembersListScreenTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2520C4F33AA0C061D209C28 /* RoomMembersListScreenTests.swift */; };
B5C40DCFFDFBA0F86E228602 /* Clocks in Frameworks */ = {isa = PBXBuildFile; productRef = FFA423B0A7BBD8AA9BB91AB0 /* Clocks */; };
B5C40DCFFDFBA0F86E228602 /* Dynamic in Frameworks */ = {isa = PBXBuildFile; productRef = AFAD718C6621B94813B960B4 /* Dynamic */; };
B5E455C9689EA600EDB3E9E0 /* NavigationRootCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA28F29C9F93E93CC3C2C715 /* NavigationRootCoordinator.swift */; };
B6048166B4AA4CEFEA9B77A6 /* InfoPlistReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A580295A56B55A856CC4084 /* InfoPlistReader.swift */; };
B6064D82FCDCB829601C1F59 /* SecureBackupLogoutConfirmationScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37FEE10AB666891E6A675E5E /* SecureBackupLogoutConfirmationScreen.swift */; };
@@ -1325,6 +1325,7 @@
D4D7CCECC6C0AAFC42E165BB /* NotificationPermissionsScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE9BBB18FB27F09032AD8769 /* NotificationPermissionsScreenViewModel.swift */; };
D4F3C5656DF09037825E9965 /* ChatsSpaceFiltersScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEF2C15634499348A512A93A /* ChatsSpaceFiltersScreenModels.swift */; };
D55AF9B5B55FEED04771A461 /* RoomFlowCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A008E57D52B07B78DFAD1BB /* RoomFlowCoordinator.swift */; };
D5B02E816B85F91AA81FA877 /* ManualClockTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FCD606155B7B4AFC06B7635 /* ManualClockTests.swift */; };
D5B1531A72387D432939D4E0 /* RoomDirectorySearchProxyProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0516C69708D5CBDE1A8E77EC /* RoomDirectorySearchProxyProtocol.swift */; };
D5C2DA52162A978743A183F2 /* SessionVerificationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8AA084E10B80D64449C02A9 /* SessionVerificationTests.swift */; };
D5E771132BB36240DE38102F /* RoomMessageEventStringBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80E815FF3CC5E5A355E3A25E /* RoomMessageEventStringBuilder.swift */; };
@@ -1334,7 +1335,7 @@
D63974A88CF2BC721F109C77 /* Compound in Frameworks */ = {isa = PBXBuildFile; productRef = DCA3C4A997AD28E6918D4CE5 /* Compound */; };
D69CCEC785E6C819551B402F /* SearchScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5413213B56FB76C1071E2BFB /* SearchScreenViewModelTests.swift */; };
D6DE764B17FB4A9A12C33BF4 /* MessageComposer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F1DF3FFFE5ED2B8133F43A7 /* MessageComposer.swift */; };
D75C591C44C2510FDFA9C3D8 /* Dynamic in Frameworks */ = {isa = PBXBuildFile; productRef = AFAD718C6621B94813B960B4 /* Dynamic */; };
D75C591C44C2510FDFA9C3D8 /* Macros in Frameworks */ = {isa = PBXBuildFile; productRef = AF574A0FC6F3A83D3BFFC9B3 /* Macros */; };
D7CC123DC5138C3BF1F9999D /* MapLibreAnnotation.swift in Sources */ = {isa = PBXBuildFile; fileRef = B50642DAE43672897C0BBAE2 /* MapLibreAnnotation.swift */; };
D7CDBAE82782BD0529DECB5F /* AttributedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52BD6ED18E2EB61E28C340AD /* AttributedString.swift */; };
D820B3C223E4C2E77BB2A2BF /* ElementCallServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EA2AFF6EB59FE25234D29F3 /* ElementCallServiceTests.swift */; };
@@ -2008,6 +2009,7 @@
2F36C5D9B37E50915ECBD3EE /* RoomMemberProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomMemberProxy.swift; sourceTree = "<group>"; };
2F65178687F725E8924B723C /* TimelineFixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineFixtures.swift; sourceTree = "<group>"; };
2F926D08EB3D622A480BCA71 /* TimelineEventContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineEventContent.swift; sourceTree = "<group>"; };
2FCD606155B7B4AFC06B7635 /* ManualClockTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManualClockTests.swift; sourceTree = "<group>"; };
303D9438EFB481F57A366E82 /* EncryptionResetPasswordScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionResetPasswordScreenViewModel.swift; sourceTree = "<group>"; };
303FCADE77DF1F3670C086ED /* BugReportScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugReportScreenViewModel.swift; sourceTree = "<group>"; };
306AB507E1027D6C5C147EB6 /* EncryptionResetScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionResetScreenModels.swift; sourceTree = "<group>"; };
@@ -2021,6 +2023,7 @@
31A6314FDC51DA25712D9A81 /* PillContextTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PillContextTests.swift; sourceTree = "<group>"; };
31D6764D6976D235926FE5FC /* HomeScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreenViewModel.swift; sourceTree = "<group>"; };
3203C6566DC17B7AECC1B7FD /* RoomNotificationSettingsUserDefinedScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomNotificationSettingsUserDefinedScreen.swift; sourceTree = "<group>"; };
32435409710BF1A54C3BBD53 /* ManualClock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManualClock.swift; sourceTree = "<group>"; };
32B5E17028C02DFA7DDA3931 /* RoomMemberProxyProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomMemberProxyProtocol.swift; sourceTree = "<group>"; };
33035418BB35754232985871 /* TimelineReadReceiptsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineReadReceiptsView.swift; sourceTree = "<group>"; };
330AF4D121C3396F7A14B21D /* id */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = id; path = id.lproj/SAS.strings; sourceTree = "<group>"; };
@@ -3241,9 +3244,8 @@
1524AAAE9BCAA32D65E97866 /* MapLibreInterface.framework in Frameworks */,
7FF27DA70D833CFC5724EFC5 /* MatrixRustSDK in Frameworks */,
BCA5E2157CE27AB6F1D043D3 /* AsyncAlgorithms in Frameworks */,
B5C40DCFFDFBA0F86E228602 /* Clocks in Frameworks */,
D75C591C44C2510FDFA9C3D8 /* Dynamic in Frameworks */,
71AF9345A1202DB9B5DCD9AE /* Macros in Frameworks */,
B5C40DCFFDFBA0F86E228602 /* Dynamic in Frameworks */,
D75C591C44C2510FDFA9C3D8 /* Macros in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -4928,6 +4930,7 @@
isa = PBXGroup;
children = (
B91AD590B0B40718A0AA0C61 /* DeferredFulfillment.swift */,
32435409710BF1A54C3BBD53 /* ManualClock.swift */,
9E21DC54FCC2A7F78532923F /* WaitingConfirmation.swift */,
0345B849A6A802EB2F72C44A /* Extensions */,
);
@@ -5181,6 +5184,7 @@
3DC1943ADE6A62ED5129D7C8 /* LoggingTests.swift */,
5A43964330459965AF048A8C /* LoginScreenViewModelTests.swift */,
D80807B554CF9C524F98674F /* ManageRoomMemberSheetViewModelTests.swift */,
2FCD606155B7B4AFC06B7635 /* ManualClockTests.swift */,
51956E593E1AEEE6C6DA7DB9 /* MapLibreLoaderTests.swift */,
1D262A26713C18BB70C82CA5 /* MapTilerURLBuilderTests.swift */,
F31F59030205A6F65B057E1A /* MatrixEntityRegexTests.swift */,
@@ -7460,7 +7464,6 @@
packageProductDependencies = (
C07EA60CAB296D7726210F5B /* MatrixRustSDK */,
5A8EF1A5F9629FCA309D4B2A /* AsyncAlgorithms */,
FFA423B0A7BBD8AA9BB91AB0 /* Clocks */,
AFAD718C6621B94813B960B4 /* Dynamic */,
AF574A0FC6F3A83D3BFFC9B3 /* Macros */,
);
@@ -7787,7 +7790,6 @@
E025F19D013D9BA6C58B37F4 /* XCRemoteSwiftPackageReference "swift-algorithms" */,
AC3475112CA40C2C6E78D1EB /* XCRemoteSwiftPackageReference "matrix-analytics-events" */,
4A8D3ABF18EABB8066BBD46E /* XCRemoteSwiftPackageReference "swift-async-algorithms" */,
869B65C34E469FC879A9F116 /* XCRemoteSwiftPackageReference "swift-clocks" */,
F76A08D0EA29A07A54F4EB4D /* XCRemoteSwiftPackageReference "swift-collections" */,
4C34425923978C97409A3EF2 /* XCRemoteSwiftPackageReference "DSWaveformImage" */,
D5F7D47BBAAE0CF1DDEB3034 /* XCRemoteSwiftPackageReference "DeviceKit" */,
@@ -8255,6 +8257,8 @@
149D1942DC005D0485FB8D93 /* LoggingTests.swift in Sources */,
7434A7F02D587A920B376A9A /* LoginScreenViewModelTests.swift in Sources */,
1C1750C009F7214B967928BC /* ManageRoomMemberSheetViewModelTests.swift in Sources */,
73D4437A6A410FA2B94CFE0F /* ManualClock.swift in Sources */,
D5B02E816B85F91AA81FA877 /* ManualClockTests.swift in Sources */,
EB069CC36087F103C517C6A0 /* MapLibreLoaderTests.swift in Sources */,
3BEBDCB42BABFA3B456FECA7 /* MapTilerURLBuilderTests.swift in Sources */,
2E43A3D221BE9587BC19C3F1 /* MatrixEntityRegexTests.swift in Sources */,
@@ -10789,14 +10793,6 @@
revision = 60bc01f2e3b31445dc723f5af1a777b63d18e6c2;
};
};
869B65C34E469FC879A9F116 /* XCRemoteSwiftPackageReference "swift-clocks" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/pointfreeco/swift-clocks";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 1.1.0;
};
};
96495DD8554E2F39D3954354 /* XCRemoteSwiftPackageReference "posthog-ios" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/PostHog/posthog-ios";
@@ -11265,11 +11261,6 @@
package = AB8E808A59756170682BEC20 /* XCRemoteSwiftPackageReference "SwiftSoup" */;
productName = SwiftSoup;
};
FFA423B0A7BBD8AA9BB91AB0 /* Clocks */ = {
isa = XCSwiftPackageProductDependency;
package = 869B65C34E469FC879A9F116 /* XCRemoteSwiftPackageReference "swift-clocks" */;
productName = Clocks;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = AC22997D58D612146053154D /* Project object */;
@@ -1,5 +1,5 @@
{
"originHash" : "8a0c9555b8a89581c69dc3e2b91f47adf41791f04765972870fe510b261f83b3",
"originHash" : "fbe2663429af94e60cfa70345bffe72998cbc19f6f659bd6f3c1a0ddc50b941d",
"pins" : [
{
"identity" : "compound-design-tokens",
@@ -234,15 +234,6 @@
"version" : "1.1.5"
}
},
{
"identity" : "swift-clocks",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-clocks",
"state" : {
"revision" : "72d749bf341b78851203066ab421869b783ec42a",
"version" : "1.1.0"
}
},
{
"identity" : "swift-collections",
"kind" : "remoteSourceControl",
@@ -252,15 +243,6 @@
"version" : "1.6.0"
}
},
{
"identity" : "swift-concurrency-extras",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-concurrency-extras",
"state" : {
"revision" : "5a3825302b1a0d744183200915a47b508c828e6f",
"version" : "1.3.2"
}
},
{
"identity" : "swift-custom-dump",
"kind" : "remoteSourceControl",
+27 -37
View File
@@ -6,18 +6,15 @@
//
import CallKit
import Clocks
@testable import ElementX
import PushKit
import Testing
// The TestClock advances by yielding to background tasks which can be starved on a busy CI runner.
@Suite(.timeLimit(.minutes(2)))
@MainActor
final class ElementCallServiceTests {
private var callProvider: CXProviderMock!
private var currentDate: Date!
private var testClock: TestClock<Duration>!
private var clock: ManualClock!
private var pushRegistry: PKPushRegistry!
private var service: ElementCallService!
@@ -25,17 +22,17 @@ final class ElementCallServiceTests {
pushRegistry = PKPushRegistry(queue: nil)
callProvider = CXProviderMock(.init())
currentDate = Date()
testClock = TestClock()
clock = ManualClock()
let dateProvider: () -> Date = {
self.currentDate
}
service = ElementCallService(callProvider: callProvider, timeProvider: TimeProvider(clock: testClock, now: dateProvider))
service = ElementCallService(callProvider: callProvider, timeProvider: TimeProvider(clock: clock, now: dateProvider))
}
isolated deinit {
callProvider = nil
currentDate = nil
testClock = nil
clock = nil
pushRegistry = nil
}
@@ -84,11 +81,17 @@ final class ElementCallServiceTests {
}
}
@Test(.disabled())
func callIsTimingOut() async {
@Test
func callIsTimingOut() async throws {
#expect(!callProvider.reportNewIncomingCallWithUpdateCompletionCalled)
await confirmation { confirmation in
let (endedCalls, endedCallsContinuation) = AsyncStream<CXCallEndedReason>.makeStream()
callProvider.reportCallWithEndedAtReasonClosure = { _, _, reason in
endedCallsContinuation.yield(reason)
}
let deferredEndedCall = deferFulfillment(endedCalls) { _ in true }
await waitForConfirmation { confirmation in
let pushPayload = PKPushPayloadMock().updatingExpiration(currentDate, lifetime: 20)
service.pushRegistry(pushRegistry,
@@ -98,18 +101,13 @@ final class ElementCallServiceTests {
}
}
await confirmation { confirmation in
callProvider.reportCallWithEndedAtReasonClosure = { _, _, reason in
if reason == .unanswered {
confirmation()
} else {
Issue.record("Call should have ended as unanswered")
}
}
// advance past the timeout
await testClock.advance(by: .seconds(30))
}
await clock.waitForScheduledSleep()
// advance past the timeout
clock.advance(by: .seconds(30))
let reason = try await deferredEndedCall.fulfill()
#expect(reason == .unanswered, "Call should have ended as unanswered")
}
@Test
@@ -128,10 +126,11 @@ final class ElementCallServiceTests {
didReceiveIncomingPushWith: pushPayload,
for: .voIP) { }
await waitForScheduledSleep()
// The ring duration is capped, no matter how far away the push's expiration is.
#expect(await clock.waitForScheduledSleep() == .seconds(90))
// Advance past the max timeout but below the 300
await testClock.advance(by: .seconds(100))
clock.advance(by: .seconds(100))
let reason = try await deferredEndedCall.fulfill()
#expect(reason == .unanswered, "Call should have ended as unanswered")
@@ -156,8 +155,8 @@ final class ElementCallServiceTests {
let firstPayload = PKPushPayloadMock().updatingExpiration(currentDate, lifetime: 60)
service.pushRegistry(pushRegistry, didReceiveIncomingPushWith: firstPayload, for: .voIP) { }
await waitForScheduledSleep()
await testClock.advance(by: .seconds(70))
await clock.waitForScheduledSleep()
clock.advance(by: .seconds(70))
try await deferredEndedCall.fulfill()
callProvider.reportCallWithEndedAtReasonClosure = nil
@@ -193,7 +192,7 @@ final class ElementCallServiceTests {
}
// Make sure the cancellation below exercises a scheduled timer rather than one that never started.
await waitForScheduledSleep()
await clock.waitForScheduledSleep()
// Simulate the answer flow handing off to setupCallSession, which must cancel
// the pending endUnansweredCallTask as part of clearing the incoming state.
@@ -207,7 +206,7 @@ final class ElementCallServiceTests {
}
// Advance past what would have been the 60s unanswered timeout
await testClock.advance(by: .seconds(120))
clock.advance(by: .seconds(120))
for _ in 0..<3 {
await Task.yield()
}
@@ -241,15 +240,6 @@ final class ElementCallServiceTests {
#expect(service.ongoingCallRoomIDPublisher.value == "!room:example.com")
}
/// Waits until the unanswered-call timer is sleeping on the test clock — it runs in an
/// unstructured task, so advancing the clock before that would never wake it.
/// Note: `checkSuspension()` throws when a sleep is scheduled, which is the state we want.
private func waitForScheduledSleep() async {
while await (try? testClock.checkSuspension()) != nil {
await Task.yield()
}
}
private func expectImmediatelyEndedCallReported(forPayload payload: PKPushPayloadMock,
expectedReason: CXCallEndedReason) async {
let baselineNewIncomingCount = callProvider.reportNewIncomingCallWithUpdateCompletionCallsCount
+63
View File
@@ -0,0 +1,63 @@
//
// Copyright 2026 Element Creations Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
// Please see LICENSE files in the repository root for full details.
//
import Testing
@MainActor
final class ManualClockTests {
private let clock = ManualClock()
@Test
func sleepFinishesOnlyOnceItsDeadlineHasPassed() async throws {
let (sleepFinished, sleepFinishedContinuation) = AsyncStream<Void>.makeStream()
let sleeper = Task {
try await clock.sleep(for: .seconds(10))
sleepFinishedContinuation.yield()
}
await clock.waitForScheduledSleep()
let deferredFailure = deferFailure(sleepFinished, timeout: .milliseconds(200)) { _ in true }
clock.advance(by: .seconds(9))
try await deferredFailure.fulfill()
clock.advance(by: .seconds(1))
try await sleeper.value
}
@Test
func waitForScheduledSleepReportsTheRequestedDurations() async {
let firstSleeper = Task { try await clock.sleep(for: .seconds(30)) }
#expect(await clock.waitForScheduledSleep() == .seconds(30))
firstSleeper.cancel()
// Cancelling the first sleep frees the clock up to schedule another one.
let secondSleeper = Task { try await clock.sleep(for: .seconds(90)) }
#expect(await clock.waitForScheduledSleep() == .seconds(90))
secondSleeper.cancel()
}
@Test
func cancellingASleepingTaskThrows() async {
let sleeper = Task { try await clock.sleep(for: .seconds(10)) }
await clock.waitForScheduledSleep()
sleeper.cancel()
await #expect(throws: CancellationError.self) {
try await sleeper.value
}
}
@Test
func sleepingUntilAPastDeadlineFinishesImmediately() async throws {
clock.advance(by: .seconds(10))
#expect(clock.now == ManualClock.Instant(offset: .seconds(10)))
try await clock.sleep(until: ManualClock.Instant(offset: .seconds(5)))
}
}
@@ -0,0 +1,151 @@
//
// Copyright 2026 Element Creations Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
// Please see LICENSE files in the repository root for full details.
//
import Synchronization
/// A clock whose sleeps only finish when the test advances it.
final nonisolated class ManualClock: Clock {
struct Instant: InstantProtocol {
let offset: Duration
func advanced(by duration: Duration) -> Self {
Self(offset: offset + duration)
}
func duration(to other: Self) -> Duration {
other.offset - offset
}
static func < (lhs: Self, rhs: Self) -> Bool {
lhs.offset < rhs.offset
}
}
private struct Sleep {
let deadline: Instant
let continuation: CheckedContinuation<Void, any Error>
}
private struct State {
var now = Instant(offset: .zero)
/// A test steps through one timer at a time, so a single sleep can be pending.
var sleep: Sleep?
var sleepObserver: CheckedContinuation<Duration, Never>?
}
private let state = Mutex(State())
let minimumResolution: Duration = .zero
var now: Instant {
state.withLock { $0.now }
}
/// Suspends until ``advance(by:)`` moves the clock to `deadline`, or the calling task is
/// cancelled, in which case this throws a `CancellationError`. Deadlines that have already
/// passed return without suspending. `tolerance` is ignored.
func sleep(until deadline: Instant, tolerance: Duration? = nil) async throws {
try await withTaskCancellationHandler {
try Task.checkCancellation()
try await withCheckedThrowingContinuation { continuation in
switch schedule(deadline: deadline, continuation: continuation) {
case .elapsed:
continuation.resume()
case .scheduled(let duration, let observer):
observer?.resume(returning: duration)
// Cancellation that landed while scheduling found nothing to cancel.
if Task.isCancelled {
cancelSleep()
}
}
}
} onCancel: {
cancelSleep()
}
}
/// Suspends until a sleep is pending and returns how long it has left to run. A sleep that is
/// already pending returns straight away, so a scheduled sleep can't be missed.
///
/// Await this before advancing a timer that runs in an unstructured task — advancing the clock
/// before the sleep has been scheduled would leave it sleeping past the new deadline.
@discardableResult
func waitForScheduledSleep() async -> Duration {
await withCheckedContinuation { continuation in
let duration = state.withLock { state -> Duration? in
guard let sleep = state.sleep else {
state.sleepObserver = continuation
return nil
}
return state.now.duration(to: sleep.deadline)
}
if let duration {
continuation.resume(returning: duration)
}
}
}
/// Moves ``now`` forward, resuming the pending sleep if it has come due. Resuming is all this
/// does — wait on the work the sleep unblocks rather than assuming it has run by the time this
/// returns.
func advance(by duration: Duration) {
let dueSleep = state.withLock { state -> Sleep? in
state.now = state.now.advanced(by: duration)
guard let sleep = state.sleep, sleep.deadline <= state.now else {
return nil
}
state.sleep = nil
return sleep
}
dueSleep?.continuation.resume()
}
// MARK: - Private
private enum Scheduling {
/// The deadline had already passed, the sleep doesn't suspend.
case elapsed
/// The sleep is now pending, along with the ``waitForScheduledSleep()`` caller to notify.
case scheduled(Duration, observer: CheckedContinuation<Duration, Never>?)
}
/// Stores the sleep's continuation, handing the caller whatever needs resuming so that it
/// happens outside of the lock.
private func schedule(deadline: Instant, continuation: CheckedContinuation<Void, any Error>) -> Scheduling {
state.withLock { state in
guard deadline > state.now else {
return .elapsed
}
precondition(state.sleep == nil, "The clock only supports a single pending sleep.")
state.sleep = Sleep(deadline: deadline, continuation: continuation)
let observer = state.sleepObserver
state.sleepObserver = nil
return .scheduled(state.now.duration(to: deadline), observer: observer)
}
}
private func cancelSleep() {
let sleep = state.withLock { state -> Sleep? in
let sleep = state.sleep
state.sleep = nil
return sleep
}
sleep?.continuation.resume(throwing: CancellationError())
}
}
-1
View File
@@ -34,7 +34,6 @@ targets:
- target: MapLibreInterface
- package: MatrixRustSDK
- package: AsyncAlgorithms
- package: Clocks
- package: Dynamic
- package: BuildExtensions
product: Macros
-3
View File
@@ -108,9 +108,6 @@ packages:
AsyncAlgorithms:
url: https://github.com/apple/swift-async-algorithms
minorVersion: 1.1.5
Clocks:
url: https://github.com/pointfreeco/swift-clocks
from: 1.1.0
Collections:
url: https://github.com/apple/swift-collections
minorVersion: 1.6.0