mirror of
https://github.com/block/buzz.git
synced 2026-11-06 09:21:45 +00:00
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 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
This commit is contained in:
@@ -9,9 +9,14 @@ final class BuzzPushSnapshotBridge {
|
|||||||
private let endpointGrantStore: BuzzPushEndpointGrantKeychainStore
|
private let endpointGrantStore: BuzzPushEndpointGrantKeychainStore
|
||||||
private let interactionDeletionDeadline: BuzzInteractionDeletionDeadline
|
private let interactionDeletionDeadline: BuzzInteractionDeletionDeadline
|
||||||
private let keychainAccessGroup: String?
|
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(
|
private let queue = DispatchQueue(
|
||||||
label: "xyz.block.buzz.push-snapshot",
|
label: "xyz.block.buzz.push-snapshot",
|
||||||
qos: .utility
|
qos: .userInitiated
|
||||||
)
|
)
|
||||||
private lazy var store: BuzzPushPresentationCacheStore? = {
|
private lazy var store: BuzzPushPresentationCacheStore? = {
|
||||||
guard let container = containerURL() else { return nil }
|
guard let container = containerURL() else { return nil }
|
||||||
@@ -31,7 +36,7 @@ final class BuzzPushSnapshotBridge {
|
|||||||
INInteraction.deleteAll(completion: completion)
|
INInteraction.deleteAll(completion: completion)
|
||||||
},
|
},
|
||||||
scheduleTimeout: { delay, action in
|
scheduleTimeout: { delay, action in
|
||||||
DispatchQueue.global(qos: .utility).asyncAfter(
|
DispatchQueue.global(qos: .userInitiated).asyncAfter(
|
||||||
deadline: .now() + delay,
|
deadline: .now() + delay,
|
||||||
execute: action
|
execute: action
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -331,6 +331,7 @@ final class BuzzPushSnapshotEnrichmentTests: XCTestCase {
|
|||||||
let snapshotURL = directory.appendingPathComponent(BuzzPushPresentationCacheStore.fileName)
|
let snapshotURL = directory.appendingPathComponent(BuzzPushPresentationCacheStore.fileName)
|
||||||
let original = try Data(contentsOf: snapshotURL)
|
let original = try Data(contentsOf: snapshotURL)
|
||||||
var deletionCalls = 0
|
var deletionCalls = 0
|
||||||
|
var deletionQoS: [qos_class_t] = []
|
||||||
let bridge = BuzzPushSnapshotBridge(
|
let bridge = BuzzPushSnapshotBridge(
|
||||||
appGroupIdentifier: nil,
|
appGroupIdentifier: nil,
|
||||||
endpointGrantStore: BuzzPushEndpointGrantKeychainStore(accessGroup: nil),
|
endpointGrantStore: BuzzPushEndpointGrantKeychainStore(accessGroup: nil),
|
||||||
@@ -340,6 +341,7 @@ final class BuzzPushSnapshotEnrichmentTests: XCTestCase {
|
|||||||
timeout: 5,
|
timeout: 5,
|
||||||
deleteAllInteractions: { completion in
|
deleteAllInteractions: { completion in
|
||||||
deletionCalls += 1
|
deletionCalls += 1
|
||||||
|
deletionQoS.append(qos_class_self())
|
||||||
completion(deletionCalls == 1 ? NSError(domain: "InjectedCleanupFailure", code: 1) : nil)
|
completion(deletionCalls == 1 ? NSError(domain: "InjectedCleanupFailure", code: 1) : nil)
|
||||||
},
|
},
|
||||||
scheduleTimeout: { _, _ in }
|
scheduleTimeout: { _, _ in }
|
||||||
@@ -370,6 +372,8 @@ final class BuzzPushSnapshotEnrichmentTests: XCTestCase {
|
|||||||
XCTAssertNil(restoreResult)
|
XCTAssertNil(restoreResult)
|
||||||
XCTAssertFalse(BuzzAgeRestrictionSession.isRestricted(containerURL: directory))
|
XCTAssertFalse(BuzzAgeRestrictionSession.isRestricted(containerURL: directory))
|
||||||
XCTAssertEqual(deletionCalls, 2)
|
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)
|
XCTAssertEqual(try Data(contentsOf: snapshotURL), original)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user