From 4278e994410899cd3528d1f3b03445ceea49805f Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Mon, 2 Nov 2026 11:41:35 +1100 Subject: [PATCH 1/4] fix(mobile): answer push snapshot bridge calls at user-initiated QoS `BuzzPushSnapshotEnrichmentTests` failed on two unrelated branches on 2026-11-01 in "Mobile Swift (Native)", step "Test native controls and Runner unit tests", and passed on re-run both times: - Run 41327410782 (PR #8896, job 124910378184): `testRestrictionCleanupFailurePreservesSnapshotAndRestoreReleasesAuthority` timed out its 10 s "allowed without restoring snapshot" wait (19.3 s total). The preceding `testNewBridgeRetriesRecordedCleanupWithoutRestrictingAccess` passed but took 4.3 s. - Run 41325606910 (PR #8897, job 124905175903): `testNewBridgeRetriesRecordedCleanupWithoutRestrictingAccess` timed out its "retry failure stays allowed" wait (nil instead of `interaction_cleanup_retry_failed`), then the restriction test timed out both waits (nil result, still restricted, 0 deletion calls). Cause: scheduling, not test logic. Every reply from `BuzzPushSnapshotBridge` hops through a private serial queue created at `.utility` QoS and then `DispatchQueue.main`. In both failing runs the test host (the full Flutter app, loaded as `TEST_HOST`) saturated the 3-vCPU `macos-latest` runner for roughly 40 s after launch: `BrowserAuthBridgeTests.testAllowsOnlyBuilderlabLoginPages`, a synchronous main-thread URL-parsing test, took 2.65 s instead of 0.013 s, and the since-deleted `testStrictAgeGateWriteFailsWhenAppGroupStoreIsUnavailable` took 4.9 s. In that window the utility queue was the lowest-priority runnable work in the process, so it got no CPU for 10-20 s while main-thread work kept moving slowly; in both passing re-runs every test in this class took under 0.3 s. The tests themselves have nothing to fix: the deletion mocks are synchronous, the deadline scheduler is a no-op, each test uses its own temp directory and bridge, and no state is shared across tests, so neither a state leak nor an expectation ordering race explains a callback that never arrives. Utility is also the wrong class in production: Dart awaits every one of these calls, and the age-gate restore runs during app launch, exactly when the process is busiest. Change: the bridge queue and the default interaction-deletion deadline timer run at `.userInitiated`. The restriction test now records `qos_class_self()` inside the injected deletion closure, which the bridge invokes synchronously on its queue, and asserts both calls ran at `QOS_CLASS_USER_INITIATED`. The 10 s waits are unchanged. Verification (Xcode 27.2, iOS 27.2 simulator, 16-core host; the CI starvation did not reproduce locally): - Class in isolation: 10/10 runs green, 0.1-0.4 s each. - Full RunnerTests bundle with CI's `-parallel-testing-enabled NO`: 2/2 runs green (101 tests, about 22 s). - Falsification: with the queue temporarily back at `.utility` the new assertion fails, observing `[17, 17]` (utility) instead of `[25, 25]`. - A throwaway probe confirmed that async blocks on a queue with an assigned QoS run at that QoS (17 for utility, 25 for user-initiated), so the change alters real scheduling. Saturating the host process's default-QoS pool with spinning blocks did not delay a utility hop (5 ms), so the CI stall is OS-level CPU priority starvation on a saturated 3-vCPU VM, which this machine cannot reproduce; the diagnosis rests on the CI timings above. - The repo has no swiftformat or swiftlint configuration; `git diff --check` is clean. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Matt Toohey --- mobile/ios/Runner/PushSnapshotBridge.swift | 9 +++++++-- .../RunnerTests/BuzzCommunicationNotificationTests.swift | 4 ++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/mobile/ios/Runner/PushSnapshotBridge.swift b/mobile/ios/Runner/PushSnapshotBridge.swift index b735539c8eb..09f599cf0e5 100644 --- a/mobile/ios/Runner/PushSnapshotBridge.swift +++ b/mobile/ios/Runner/PushSnapshotBridge.swift @@ -9,9 +9,14 @@ final class BuzzPushSnapshotBridge { private let endpointGrantStore: BuzzPushEndpointGrantKeychainStore private let interactionDeletionDeadline: BuzzInteractionDeletionDeadline private let keychainAccessGroup: String? + // Flutter awaits every call here, and the age-gate restore runs during app + // launch, when this process is busiest. At utility QoS these replies were the + // lowest-priority work in the process and stalled for over 10 s on saturated + // CI hosts while main-thread work kept moving; user-initiated is the class + // for work a caller is waiting on. private let queue = DispatchQueue( label: "xyz.block.buzz.push-snapshot", - qos: .utility + qos: .userInitiated ) private lazy var store: BuzzPushPresentationCacheStore? = { guard let container = containerURL() else { return nil } @@ -31,7 +36,7 @@ final class BuzzPushSnapshotBridge { INInteraction.deleteAll(completion: completion) }, scheduleTimeout: { delay, action in - DispatchQueue.global(qos: .utility).asyncAfter( + DispatchQueue.global(qos: .userInitiated).asyncAfter( deadline: .now() + delay, execute: action ) diff --git a/mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift b/mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift index a9960a70b5b..8ade99d81f6 100644 --- a/mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift +++ b/mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift @@ -331,6 +331,7 @@ final class BuzzPushSnapshotEnrichmentTests: XCTestCase { let snapshotURL = directory.appendingPathComponent(BuzzPushPresentationCacheStore.fileName) let original = try Data(contentsOf: snapshotURL) var deletionCalls = 0 + var deletionQoS: [qos_class_t] = [] let bridge = BuzzPushSnapshotBridge( appGroupIdentifier: nil, endpointGrantStore: BuzzPushEndpointGrantKeychainStore(accessGroup: nil), @@ -340,6 +341,7 @@ final class BuzzPushSnapshotEnrichmentTests: XCTestCase { timeout: 5, deleteAllInteractions: { completion in deletionCalls += 1 + deletionQoS.append(qos_class_self()) completion(deletionCalls == 1 ? NSError(domain: "InjectedCleanupFailure", code: 1) : nil) }, scheduleTimeout: { _, _ in } @@ -370,6 +372,8 @@ final class BuzzPushSnapshotEnrichmentTests: XCTestCase { XCTAssertNil(restoreResult) XCTAssertFalse(BuzzAgeRestrictionSession.isRestricted(containerURL: directory)) XCTAssertEqual(deletionCalls, 2) + // Flutter awaits these replies; a lower QoS left them starved on busy hosts. + XCTAssertEqual(deletionQoS, [QOS_CLASS_USER_INITIATED, QOS_CLASS_USER_INITIATED]) XCTAssertEqual(try Data(contentsOf: snapshotURL), original) } From ff19edf0def13fa53d9f7842d6018c8867be8512 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Mon, 2 Nov 2026 12:24:43 +1100 Subject: [PATCH 2/4] test(mobile): gate push snapshot tests on a settled host, name the observed QoS Follow-up to f0bf2c0c8 from its review (no blockers; one suggestion and one warning, both addressed here). Suggestion: the QoS assertion in `testRestrictionCleanupFailurePreservesSnapshotAndRestoreReleasesAuthority` compared two raw `qos_class_t` arrays, which fails as `[__C.qos_class_t(rawValue: 17), ...]`. It now asserts each recorded call individually with a message naming the observed and expected raw classes. `deletionCalls == 2` already covers the count. Warning: on a saturated 3-vCPU runner `.userInitiated` still sits below the Flutter UI and raster threads of the full-app TEST_HOST, so the QoS change may narrow the window rather than close it. Accepted. The complement added here is test-side only and touches no production code or timeout: `BuzzPushSnapshotEnrichmentTests` gains a class `setUp` that runs a once-per-process `BuzzTestHostSettleBarrier`. The barrier dispatches the same hop the bridge's replies take (a user-initiated serial queue, then `DispatchQueue.main`), times it, and proceeds once three consecutive hops complete in under 0.5 s, capped at 60 s. It is a readiness gate on the measured symptom, not a sleep: on an idle host it costs about 10-60 ms (measured 0.04-0.38 s here under a load average of about 25), and in the CI window it would wait only while hops are actually slow. The thresholds come from the failing runs: the trivial hop measured 1.3 s and then over 10 s during the ~40 s after host launch; RunnerTests began about 9 s after launch and this class about 5 s later, so the cap covers the whole window with margin. 0.5 s leaves 20x headroom under the 10 s waits. If the host never settles, the tests run anyway and fail with the real symptom, and the barrier prints one line ("gave up after N s (M slow hops)") so the next reader can tell starvation from a missed callback. Residual risk: a burst that recurs mid-test or outlasts the cap is not covered; the remaining levers are then the test host itself or the wait length. Considered and declined: deriving `callbackTimeout` from a measured host-load factor. That still waits inside each test and only scales the same timeout, so it is a timeout increase in disguise and gives no signal about why the host was slow. Verification (Xcode 27.2, iOS 26.2 simulator runtime, 16-core host; the earlier commit's "iOS 27.2 simulator" meant the 27.2 SDK): - Barrier behaviour, with temporary variants not committed: forcing the first four hops to sleep 0.6 s gave "settled after 2.43 s (4 slow hops)"; the same with a 2 s cap gave "gave up after 2.00 s (3 slow hops)" and the class still ran and passed. - Class in isolation with the final code: 10/10 green, barrier settled in 0.04-0.38 s with 0 slow hops. - Full RunnerTests bundle with CI's `-parallel-testing-enabled NO`: 2/2 green (101 tests, about 22 s), barrier settled in about 0.06 s. - No swiftformat or swiftlint configuration in the repo; `git diff --check` is clean. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Matt Toohey --- .../BuzzCommunicationNotificationTests.swift | 58 ++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift b/mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift index 8ade99d81f6..e3e1317d33c 100644 --- a/mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift +++ b/mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift @@ -242,12 +242,63 @@ final class BuzzCommunicationNotificationTests: XCTestCase { } } +/// RunnerTests load the full Flutter app as their host. On CI's 3-vCPU runners +/// its launch saturated the machine for about 40 s (both 2026-11-01 failures): +/// a trivial queue-to-main hop took 1.3 s, then over 10 s, while a synchronous +/// main-thread test slowed from 0.013 s to 2.65 s, and the bridge's replies +/// missed their 10 s waits. This barrier measures that same hop, at the QoS +/// the bridge uses, and lets the class begin only once three consecutive hops +/// are fast again. On an idle host it costs a few milliseconds. The cap keeps a +/// host that never settles from hanging the run; its tests then fail with the +/// real symptom, and the printed line says the barrier gave up. +enum BuzzTestHostSettleBarrier { + /// A hop this slow leaves too little margin under the class's 10 s waits. + static let slowHop: TimeInterval = 0.5 + static let consecutiveFastHops = 3 + static let cap: TimeInterval = 60 + + /// Waits once per process; later calls return immediately. + static func waitUntilSettled() { + _ = settled + } + + private static let settled: Void = { + let queue = DispatchQueue(label: "xyz.block.buzz.test-host-settle", qos: .userInitiated) + let started = Date() + let deadline = started.addingTimeInterval(cap) + var fastHops = 0 + var slowHops = 0 + while fastHops < consecutiveFastHops, Date() < deadline { + let hopStarted = Date() + let hop = XCTestExpectation(description: "queue to main hop") + queue.async { DispatchQueue.main.async { hop.fulfill() } } + _ = XCTWaiter().wait(for: [hop], timeout: max(deadline.timeIntervalSinceNow, 0)) + if Date().timeIntervalSince(hopStarted) < slowHop { + fastHops += 1 + } else { + fastHops = 0 + slowHops += 1 + } + } + let outcome = fastHops < consecutiveFastHops ? "gave up" : "settled" + print( + "BuzzTestHostSettleBarrier \(outcome) after \(Date().timeIntervalSince(started)) s" + + " (\(slowHops) slow hops)" + ) + }() +} + /// Callbacks only record what they receive and assertions run after the wait, /// so a callback that lands after its test returned cannot fail a later test. /// The timeout is a cap; a passing test returns as soon as its callback runs. final class BuzzPushSnapshotEnrichmentTests: XCTestCase { private let callbackTimeout: TimeInterval = 10 + override class func setUp() { + super.setUp() + BuzzTestHostSettleBarrier.waitUntilSettled() + } + func testAgeSignalRequestDoesNotDependOnNotificationStorage() { let delegate = MissingAppGroupDelegate() var completed = false @@ -373,7 +424,12 @@ final class BuzzPushSnapshotEnrichmentTests: XCTestCase { XCTAssertFalse(BuzzAgeRestrictionSession.isRestricted(containerURL: directory)) XCTAssertEqual(deletionCalls, 2) // Flutter awaits these replies; a lower QoS left them starved on busy hosts. - XCTAssertEqual(deletionQoS, [QOS_CLASS_USER_INITIATED, QOS_CLASS_USER_INITIATED]) + for qos in deletionQoS { + XCTAssertEqual( + qos, QOS_CLASS_USER_INITIATED, + "deletion ran at QoS class \(qos.rawValue), expected user-initiated (\(QOS_CLASS_USER_INITIATED.rawValue))" + ) + } XCTAssertEqual(try Data(contentsOf: snapshotURL), original) } From 6cc43c9fd4abea112f222c8ef8d7265740c49c24 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Mon, 2 Nov 2026 13:17:03 +1100 Subject: [PATCH 3/4] test(mobile): report host hop latency when a push snapshot callback times out Follow-up to 885804282 from its review. That commit added a class-level `BuzzTestHostSettleBarrier` that measured a user-initiated-queue-then-main hop in `setUp` and let the class start once three consecutive hops were fast. Re-reading both 2026-11-01 CI logs showed the barrier would have opened immediately in both failing runs: in job 124905175903 the one-hop test `testAllowedNotificationRestoreNeedsNoAppGroupStore` passed in 0.070 s at the start of the class, and in job 124910378184 the gate would have opened in the 1-2 s lull after its 1.287 s hop. Both stalls began mid-class and recurred mid-test, which a one-instant gate cannot cover, and a sustained-hop version would be a CI-only wait. The barrier, its doc comment with the measurement figures, and its "settled/gave up" print are removed. The per-element QoS assertion from 885804282 and everything from f0bf2c0c8 stay. In its place, a `waitForCallback` helper in `BuzzPushSnapshotEnrichmentTests` wraps the three callback waits in the two affected tests. It waits with `XCTWaiter` for the unchanged 10 s `callbackTimeout` and returns at once on success, costing nothing on a passing run. Only when the wait does not complete does it time one fresh user-initiated-queue-then-main-queue hop, the path every bridge reply follows, bounded at 2 s, and fail with the expectation's description plus the measured hop ("a fresh queue->main hop took 705.3 ms", "took 0.4 ms", or "did not finish within 2 s"). That lets a starved host be told apart from a callback that never fired the next time this appears in CI. The original assertions still run after the helper, so the nil-versus-reason evidence is unchanged. If this recurs, the remaining levers are the test host (RunnerTests boot the full Flutter app as TEST_HOST, which is what saturated the 3-vCPU runner) or the 10 s `callbackTimeout`. Verification (Xcode 27.2, iOS 26.2 simulator runtime, 16-core host): - Forced timeout, with temporary variants not committed: making the bridge's retry deletion never call back produced "'retry failure stays allowed' not fulfilled within 10 s; a fresh queue->main hop took 0.4 ms" followed by the existing nil-versus-reason assertion failure; adding a 0.7 s sleep inside the hop produced "took 705.3 ms". - Class in isolation with the final code: 10/10 green, 0.1-0.5 s each. - Full RunnerTests bundle with CI's `-parallel-testing-enabled NO`: 2/2 green (101 tests, 24-29 s). - The commit touches only this test file; the pinned Dart formatter was first on PATH so the pre-commit hook reformatted nothing else. No swiftformat or swiftlint configuration in the repo; `git diff --check` is clean. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Matt Toohey --- .../BuzzCommunicationNotificationTests.swift | 79 +++++++------------ 1 file changed, 27 insertions(+), 52 deletions(-) diff --git a/mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift b/mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift index e3e1317d33c..9f3f9b06b42 100644 --- a/mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift +++ b/mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift @@ -242,61 +242,36 @@ final class BuzzCommunicationNotificationTests: XCTestCase { } } -/// RunnerTests load the full Flutter app as their host. On CI's 3-vCPU runners -/// its launch saturated the machine for about 40 s (both 2026-11-01 failures): -/// a trivial queue-to-main hop took 1.3 s, then over 10 s, while a synchronous -/// main-thread test slowed from 0.013 s to 2.65 s, and the bridge's replies -/// missed their 10 s waits. This barrier measures that same hop, at the QoS -/// the bridge uses, and lets the class begin only once three consecutive hops -/// are fast again. On an idle host it costs a few milliseconds. The cap keeps a -/// host that never settles from hanging the run; its tests then fail with the -/// real symptom, and the printed line says the barrier gave up. -enum BuzzTestHostSettleBarrier { - /// A hop this slow leaves too little margin under the class's 10 s waits. - static let slowHop: TimeInterval = 0.5 - static let consecutiveFastHops = 3 - static let cap: TimeInterval = 60 - - /// Waits once per process; later calls return immediately. - static func waitUntilSettled() { - _ = settled - } - - private static let settled: Void = { - let queue = DispatchQueue(label: "xyz.block.buzz.test-host-settle", qos: .userInitiated) - let started = Date() - let deadline = started.addingTimeInterval(cap) - var fastHops = 0 - var slowHops = 0 - while fastHops < consecutiveFastHops, Date() < deadline { - let hopStarted = Date() - let hop = XCTestExpectation(description: "queue to main hop") - queue.async { DispatchQueue.main.async { hop.fulfill() } } - _ = XCTWaiter().wait(for: [hop], timeout: max(deadline.timeIntervalSinceNow, 0)) - if Date().timeIntervalSince(hopStarted) < slowHop { - fastHops += 1 - } else { - fastHops = 0 - slowHops += 1 - } - } - let outcome = fastHops < consecutiveFastHops ? "gave up" : "settled" - print( - "BuzzTestHostSettleBarrier \(outcome) after \(Date().timeIntervalSince(started)) s" - + " (\(slowHops) slow hops)" - ) - }() -} - /// Callbacks only record what they receive and assertions run after the wait, /// so a callback that lands after its test returned cannot fail a later test. /// The timeout is a cap; a passing test returns as soon as its callback runs. final class BuzzPushSnapshotEnrichmentTests: XCTestCase { private let callbackTimeout: TimeInterval = 10 - override class func setUp() { - super.setUp() - BuzzTestHostSettleBarrier.waitUntilSettled() + /// Waits for a bridge callback. On timeout the failure also reports how long + /// one fresh user-initiated-queue-then-main-queue hop takes right then (the + /// path every bridge reply follows), so a starved test host ("hop took 7.2 s") + /// reads differently from a callback that never fired ("hop took 12 ms"). + /// Both 2026-11-01 CI failures were the former: the RunnerTests host is the + /// full Flutter app, and its launch saturated the 3-vCPU runner mid-class. + /// A passing wait returns as soon as the callback runs and measures nothing. + private func waitForCallback( + _ expectation: XCTestExpectation, file: StaticString = #filePath, line: UInt = #line + ) { + guard XCTWaiter().wait(for: [expectation], timeout: callbackTimeout) != .completed else { return } + let hop = XCTestExpectation(description: "queue to main hop") + let started = Date() + DispatchQueue(label: "xyz.block.buzz.test-hop", qos: .userInitiated).async { + DispatchQueue.main.async { hop.fulfill() } + } + let hopResult = XCTWaiter().wait(for: [hop], timeout: 2) + let elapsed = String(format: "%.1f ms", Date().timeIntervalSince(started) * 1000) + let hopReport = hopResult == .completed ? "took \(elapsed)" : "did not finish within 2 s" + XCTFail( + "'\(expectation.expectationDescription)' not fulfilled within \(Int(callbackTimeout)) s;" + + " a fresh queue->main hop \(hopReport)", + file: file, line: line + ) } func testAgeSignalRequestDoesNotDependOnNotificationStorage() { @@ -406,7 +381,7 @@ final class BuzzPushSnapshotEnrichmentTests: XCTestCase { purgeResult = value restricted.fulfill() }) - wait(for: [restricted], timeout: callbackTimeout) + waitForCallback(restricted) XCTAssertEqual((purgeResult as? FlutterError)?.code, "age_restriction_purge_failed") XCTAssertTrue(BuzzAgeRestrictionSession.isRestricted(containerURL: directory)) XCTAssertEqual(try Data(contentsOf: snapshotURL), original) @@ -419,7 +394,7 @@ final class BuzzPushSnapshotEnrichmentTests: XCTestCase { restoreResult = value restored.fulfill() }) - wait(for: [restored], timeout: callbackTimeout) + waitForCallback(restored) XCTAssertNil(restoreResult) XCTAssertFalse(BuzzAgeRestrictionSession.isRestricted(containerURL: directory)) XCTAssertEqual(deletionCalls, 2) @@ -459,7 +434,7 @@ final class BuzzPushSnapshotEnrichmentTests: XCTestCase { received = value completed.fulfill() }) - wait(for: [completed], timeout: callbackTimeout) + waitForCallback(completed) XCTAssertEqual((received as? FlutterError)?.code, "interaction_cleanup_retry_failed") XCTAssertFalse(BuzzAgeRestrictionSession.isRestricted(containerURL: directory)) } From e5515f765767640c2835b46bf166d742f91183f8 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Mon, 2 Nov 2026 13:35:45 +1100 Subject: [PATCH 4/4] test(mobile): match the hop diagnostic's doc to its output, use it for every reply Two optional suggestions from the final review of 93d426da1. The `waitForCallback` doc comment quoted example failures the helper cannot print: the hop is capped at 2 s, so "took 7.2 s" never occurs, and durations print as "%.1f ms". It now quotes the real forms ("did not finish within 2 s", hundreds of ms, "took 0.4 ms"), states that the hop is measured after the 10 s window so a fast hop does not rule out starvation that ended at the deadline, and presents the 2026-11-01 diagnosis as an inference, since the measurement did not exist in those runs. `testAllowedNotificationRestoreNeedsNoAppGroupStore` used a plain `wait(for:)`; it now goes through `waitForCallback`, so every bridge-reply wait in the class carries the hop report. Verification: class in isolation green once (6 tests, 0.05 s); `git diff --check` clean; the commit touches only this file, with no Podfile.lock, xcshareddata, or Dart changes. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Matt Toohey --- .../BuzzCommunicationNotificationTests.swift | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift b/mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift index 9f3f9b06b42..8d5364c1eee 100644 --- a/mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift +++ b/mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift @@ -250,11 +250,14 @@ final class BuzzPushSnapshotEnrichmentTests: XCTestCase { /// Waits for a bridge callback. On timeout the failure also reports how long /// one fresh user-initiated-queue-then-main-queue hop takes right then (the - /// path every bridge reply follows), so a starved test host ("hop took 7.2 s") - /// reads differently from a callback that never fired ("hop took 12 ms"). - /// Both 2026-11-01 CI failures were the former: the RunnerTests host is the - /// full Flutter app, and its launch saturated the 3-vCPU runner mid-class. - /// A passing wait returns as soon as the callback runs and measures nothing. + /// path every bridge reply follows), capped at 2 s. A starved test host reads + /// "a fresh queue->main hop did not finish within 2 s" or hundreds of ms; a + /// callback that never fired reads "took 0.4 ms". The hop is measured after + /// the 10 s window, so a fast hop does not rule out starvation that ended at + /// the deadline. The 2026-11-01 CI failures predate this measurement; their + /// timings fit starvation, since the RunnerTests host is the full Flutter app + /// and its launch saturated the 3-vCPU runner mid-class. A passing wait + /// returns as soon as the callback runs and measures nothing. private func waitForCallback( _ expectation: XCTestExpectation, file: StaticString = #filePath, line: UInt = #line ) { @@ -341,7 +344,7 @@ final class BuzzPushSnapshotEnrichmentTests: XCTestCase { received = value completed.fulfill() }) - wait(for: [completed], timeout: callbackTimeout) + waitForCallback(completed) XCTAssertNil(received) }