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:
Matt Toohey
2026-11-02 11:41:35 +11:00
parent d342cf141c
commit 4278e99441
2 changed files with 11 additions and 2 deletions

View File

@@ -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
) )

View File

@@ -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)
} }