diff --git a/mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift b/mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift index e3e1317d33..9f3f9b06b4 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)) }