Skip to content

Commit bc3bb7c

Browse files
tclaude
andcommitted
test(deflake): deterministic joins for AccountsManager/Catalog/TokenRefresh async tests
Parallel-CI-clone starvation de-flake (continuation of #1319). Under CI's 2 sim clones, tests that poll a fire-and-forget async op on a fixed wall-clock deadline lose the CPU race and fail all 3 retries. Convert each to a deterministic JOIN that awaits the ACTUAL async work — no clock, cannot starve. - AccountsManager.swift: add an XCTest-gated first-run-task registry + `_awaitAllCrawlTasksForTesting()` join seam (mirrors the existing `_trackCrawlTask`/`fetchFromNetworkCountForTesting` conventions). Joins ONLY the first-run task (bundled decode → synchronous fetchFromNetwork kickoff), not the downstream live network crawl. Lock snapshot split into a synchronous helper (NSLock.lock/unlock are banned in async contexts under Swift 6). - AccountsManagerFirstRunDecodeTests: 3 tests → async + await the seam (was RunLoop/wait(for:timeout:5)). - CatalogRepositoryStaleWhileRevalidateTests: concurrent-reads test now joins BOTH background refreshes via _awaitAllBackgroundRefreshesForTesting() (was racily joining only the last-scheduled handle). - TokenRefreshAndRetryQueueTests: every fire-and-forget refresh/retry wait → a semaphore signalled at the exact event, bridged to async off the cooperative pool via withCheckedContinuation + DispatchQueue.global (Task.detached's closure is itself async, so sem.wait() there is a Swift 6 error). **Scope:** test-hermeticity + one XCTest-gated production seam on AccountsManager (RELEASE byte-identical — the tracking arrays only populate under XCTest, the join seam is the only consumer and production never calls it; verified _trackCrawlTask is also XCTest-gated so the call-site swap is a no-op in RELEASE). **Not done:** TokenRefreshInterceptorAuthCoordinatorTests (confirmed same timing/join class, fix in a follow-up commit this session). **Deferred:** the broader 135-file deadline-poll surface — only the observed-failing tests are converted; more may surface per CI run and get the same treatment. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 69a3a2f commit bc3bb7c

4 files changed

Lines changed: 227 additions & 104 deletions

File tree

Palace/Accounts/Library/AccountsManager.swift

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,18 @@ private struct CrawlerHandoffBox: @unchecked Sendable {
421421
ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil
422422
private let _trackedCrawlTasksLock = NSLock()
423423
private var _trackedCrawlTasks: [Task<Void, Never>] = []
424+
/// The subset of tracked tasks that are `loadCatalogs` FIRST-RUN tasks (the
425+
/// detached bundled-decode → synchronous `fetchFromNetwork` kickoff). Guarded
426+
/// by `_trackedCrawlTasksLock`. Populated only under XCTest (same env gate as
427+
/// `_trackedCrawlTasks`). The deterministic-join seam
428+
/// `_awaitAllCrawlTasksForTesting()` awaits ONLY these — NOT the downstream
429+
/// live network crawl the first-run task spawns via `fetchFromNetwork` (that
430+
/// hits the real registry and would reintroduce wall-clock nondeterminism).
431+
/// By the time a first-run task's `.value` resolves, its synchronous
432+
/// `fetchFromNetwork` call has already returned (bumping
433+
/// `_fetchFromNetworkCount`), so joining the first-run task alone is a
434+
/// deterministic barrier for the decode + fetch-fired observations.
435+
private var _trackedFirstRunTasks: [Task<Void, Never>] = []
424436

425437
/// Resolves the build-time bundled registry snapshot resource. Production
426438
/// binds `Bundle.main`; tests inject a stub (`BundleResourceResolving`) to
@@ -453,6 +465,63 @@ private struct CrawlerHandoffBox: @unchecked Sendable {
453465
_trackedCrawlTasksLock.unlock()
454466
}
455467

468+
/// Register a `loadCatalogs` FIRST-RUN task. Appends to BOTH the general
469+
/// crawl registry (so `cancelBackgroundWork()` cancels it, identical to
470+
/// `_trackCrawlTask`) AND the first-run subset the deterministic-join seam
471+
/// awaits. No-op outside an XCTest process — production never populates
472+
/// either list, so RELEASE behavior is byte-identical to a bare
473+
/// `_trackCrawlTask` (which is itself a no-op in RELEASE).
474+
private func _trackFirstRunTask(_ task: Task<Void, Never>) {
475+
guard Self._isRunningUnderXCTest else { return }
476+
_trackedCrawlTasksLock.lock()
477+
_trackedCrawlTasks.append(task)
478+
_trackedFirstRunTasks.append(task)
479+
_trackedCrawlTasksLock.unlock()
480+
}
481+
482+
/// Test-only deterministic JOIN seam. Snapshots the currently-tracked
483+
/// FIRST-RUN tasks under the lock and `await`s each to completion, so a test
484+
/// can join the ACTUAL off-main first-run work (bundled decode → synchronous
485+
/// `fetchFromNetwork` kickoff) spawned by `loadCatalogs` instead of polling a
486+
/// wall-clock deadline. Under CI's parallel sim clones a fixed-deadline poll
487+
/// starves (the detached `.utility` task loses the CPU race) and fails on all
488+
/// 3 retries; awaiting the real handle removes the clock entirely.
489+
///
490+
/// Awaits ALL tracked first-run tasks (not just the most-recent), so a
491+
/// dedupe-break that erroneously spawns a SECOND first-run task is also
492+
/// joined — `testSecondConcurrentLoad` can then assert the decode ran exactly
493+
/// once after every first-run task has finished. It deliberately does NOT
494+
/// await the downstream live network crawl that `fetchFromNetwork` spawns
495+
/// (which hits the real registry and would reintroduce nondeterministic
496+
/// latency); the first-run task's synchronous `fetchFromNetwork` call has
497+
/// already returned (bumping `_fetchFromNetworkCount`) by the time its
498+
/// `.value` resolves, so joining the first-run task alone suffices.
499+
///
500+
/// `nonisolated` + a synchronous lock-guarded snapshot: the manager is only
501+
/// touched synchronously here (the `Task<Void, Never>` handles are Sendable),
502+
/// so awaiting this from an `@MainActor` test never "sends" the non-Sendable
503+
/// manager across an isolation boundary. Behavior-identical to a no-op in
504+
/// RELEASE — the first-run-tasks array only ever populates under XCTest (the
505+
/// `_trackFirstRunTask` env gate), and production never calls this method. It
506+
/// only READS/awaits existing handles; it spawns no work and mutates no
507+
/// production state.
508+
/// Synchronous lock-guarded snapshot of the tracked first-run tasks. Split
509+
/// out of the `async` join seam because `NSLock.lock()`/`.unlock()` are
510+
/// unavailable from an async context (Swift 6 forbids them even when the
511+
/// critical section never spans a suspension point). The `await` happens in
512+
/// the caller, strictly after the lock is released here.
513+
nonisolated private func _snapshotFirstRunTasksForTesting() -> [Task<Void, Never>] {
514+
_trackedCrawlTasksLock.lock()
515+
defer { _trackedCrawlTasksLock.unlock() }
516+
return _trackedFirstRunTasks
517+
}
518+
519+
nonisolated func _awaitAllCrawlTasksForTesting() async {
520+
for task in _snapshotFirstRunTasksForTesting() {
521+
_ = await task.value
522+
}
523+
}
524+
456525
/// Initializer is `internal` rather than `private` so `AppContainer` can
457526
/// construct the single live instance directly. Outside of `AppContainer`
458527
/// (and tests that need an isolated instance), do not call this directly
@@ -1225,7 +1294,7 @@ private struct CrawlerHandoffBox: @unchecked Sendable {
12251294
Log.debug(#file, "Loading catalogs from network for hash \(hash)")
12261295
self.fetchFromNetwork(targetUrl: targetUrl, hash: hash)
12271296
}
1228-
_trackCrawlTask(firstRunTask)
1297+
_trackFirstRunTask(firstRunTask)
12291298
}
12301299

12311300
/// Fetches catalog data using the first-page fast path:
@@ -2073,6 +2142,10 @@ extension AccountsManager {
20732142
_trackedCrawlTasksLock.lock()
20742143
let crawlTasks = _trackedCrawlTasks
20752144
_trackedCrawlTasks.removeAll()
2145+
// Clear the first-run subset too — its handles are a subset of
2146+
// `_trackedCrawlTasks` (cancelled above), so dropping them here keeps the
2147+
// deterministic-join seam from re-awaiting an already-cancelled task.
2148+
_trackedFirstRunTasks.removeAll()
20762149
_trackedCrawlTasksLock.unlock()
20772150
crawlTasks.forEach { $0.cancel() }
20782151
networkExecutor.cancelNonEssentialTasks()

PalaceTests/Accounts/AccountsManagerFirstRunDecodeTests.swift

Lines changed: 23 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -129,34 +129,15 @@ final class AccountsManagerFirstRunDecodeTests: PalaceWiringTestCase {
129129
return defaults
130130
}
131131

132-
/// Spin the run loop until `condition` is true or the deadline passes.
133-
/// Used to observe the off-main first-run task's synchronous counters from
134-
/// the main test thread without a fixed sleep.
135-
@discardableResult
136-
private func waitUntil(timeout: TimeInterval = 5,
137-
_ condition: () -> Bool) -> Bool {
138-
let deadline = Date().addingTimeInterval(timeout)
139-
while !condition() && Date() < deadline {
140-
RunLoop.current.run(until: Date().addingTimeInterval(0.02))
141-
}
142-
return condition()
143-
}
144-
145132
// MARK: - 1. Dedupe fires BEFORE the bundled decode
146133

147134
/// A second load arriving while the first is still in flight must
148135
/// short-circuit on the consolidated dedupe guard — which sits ABOVE the
149136
/// bundled branch — so the ~2.4 MB bundled snapshot is decoded exactly
150137
/// once, not once per caller.
151-
func testSecondConcurrentLoad_shortCircuitsBeforeBundledDecode() throws {
138+
func testSecondConcurrentLoad_shortCircuitsBeforeBundledDecode() async throws {
152139
let snapshotURL = try writeStubSnapshot()
153-
let decodeRan = expectation(description: "bundled snapshot resolved")
154-
decodeRan.expectedFulfillmentCount = 1
155-
// A broken dedupe (decode runs per caller) over-fulfills → test fails.
156-
decodeRan.assertForOverFulfill = true
157-
let resolver = CountingSnapshotResolver(snapshotURL: snapshotURL) {
158-
decodeRan.fulfill()
159-
}
140+
let resolver = CountingSnapshotResolver(snapshotURL: snapshotURL)
160141

161142
let defaults = isolatedDefaults()
162143
let manager = makeFreshAccountsManager(defaults: defaults) {
@@ -172,10 +153,12 @@ final class AccountsManagerFirstRunDecodeTests: PalaceWiringTestCase {
172153
// second first-run task, no second decode.
173154
manager.loadCatalogs(completion: nil)
174155

175-
wait(for: [decodeRan], timeout: 5)
176-
// Settle briefly so an (erroneous) second decode would have a chance to
177-
// bump the counter before we assert.
178-
_ = waitUntil(timeout: 0.5) { false }
156+
// Deterministic JOIN: await the ACTUAL first-run task(s) to completion
157+
// rather than racing a wall-clock deadline (which starves under CI's
158+
// parallel sim clones). This awaits ALL tracked crawl tasks, so a
159+
// dedupe-break that spawned a SECOND first-run task would also be joined
160+
// here — after which callCount==1 proves the decode ran exactly once.
161+
await manager._awaitAllCrawlTasksForTesting()
179162
XCTAssertEqual(resolver.callCount, 1,
180163
"Bundled snapshot must be decoded exactly once; the second concurrent load must dedupe before the decode")
181164

@@ -190,7 +173,7 @@ final class AccountsManagerFirstRunDecodeTests: PalaceWiringTestCase {
190173
/// own registration and `return` before the fetch — leaving this counter
191174
/// at 0 and stranding the picker on stale bundled data. This test fails
192175
/// loudly on that regression.
193-
func testFirstRun_networkFetchStillFiresOnce_afterBundledDecode() throws {
176+
func testFirstRun_networkFetchStillFiresOnce_afterBundledDecode() async throws {
194177
let snapshotURL = try writeStubSnapshot()
195178
let resolver = CountingSnapshotResolver(snapshotURL: snapshotURL)
196179

@@ -204,12 +187,14 @@ final class AccountsManagerFirstRunDecodeTests: PalaceWiringTestCase {
204187

205188
manager.loadCatalogs(completion: nil)
206189

207-
// The fetch is dispatched from the off-main first-run task AFTER the
208-
// bundled decode. A swallowed fetch keeps this at 0 → the wait fails.
209-
let fired = waitUntil { manager.fetchFromNetworkCountForTesting >= 1 }
210-
XCTAssertTrue(fired, "Network fetch must fire after the bundled decode — it appears to have been swallowed by the dedupe")
190+
// Deterministic JOIN: the fetch is dispatched from the off-main first-run
191+
// task AFTER the bundled decode. Awaiting the actual task (instead of
192+
// polling a wall-clock deadline that starves under parallel CI clones)
193+
// guarantees the fetch has fired by the time the await returns; a
194+
// swallowed fetch leaves the counter at 0 and fails the assert below.
195+
await manager._awaitAllCrawlTasksForTesting()
211196
XCTAssertEqual(manager.fetchFromNetworkCountForTesting, 1,
212-
"Network fetch must fire exactly once")
197+
"Network fetch must fire exactly once after the bundled decode — it appears to have been swallowed by the dedupe")
213198
XCTAssertEqual(resolver.callCount, 1,
214199
"The bundled snapshot must have been decoded once before the network fetch (fetch fires AFTER it, not instead of it)")
215200

@@ -222,14 +207,9 @@ final class AccountsManagerFirstRunDecodeTests: PalaceWiringTestCase {
222207
/// must be hopped off-main — otherwise the ~2.4 MB decode blocks the main
223208
/// thread on cold first launch (the original bug via
224209
/// `presentFirstRunFlowIfNeeded`).
225-
func testFirstRun_bundledDecode_runsOffMainThread() throws {
210+
func testFirstRun_bundledDecode_runsOffMainThread() async throws {
226211
let snapshotURL = try writeStubSnapshot()
227-
let decodeRan = expectation(description: "bundled snapshot resolved")
228-
let resolver = CountingSnapshotResolver(snapshotURL: snapshotURL) {
229-
decodeRan.fulfill()
230-
}
231-
decodeRan.expectedFulfillmentCount = 1
232-
decodeRan.assertForOverFulfill = false
212+
let resolver = CountingSnapshotResolver(snapshotURL: snapshotURL)
233213

234214
let defaults = isolatedDefaults()
235215
let manager = makeFreshAccountsManager(defaults: defaults) {
@@ -239,7 +219,11 @@ final class AccountsManagerFirstRunDecodeTests: PalaceWiringTestCase {
239219
XCTAssertTrue(Thread.isMainThread, "test drives loadCatalogs from the main thread")
240220
manager.loadCatalogs(completion: nil)
241221

242-
wait(for: [decodeRan], timeout: 5)
222+
// Deterministic JOIN: await the off-main first-run task to completion
223+
// instead of racing a 5s wall-clock deadline that starves under parallel
224+
// CI clones. Once it returns, the decode has run and recorded the thread
225+
// it ran on.
226+
await manager._awaitAllCrawlTasksForTesting()
243227
XCTAssertEqual(resolver.lastCallOnMainThread, false,
244228
"The bundled snapshot decode must not run on the main thread")
245229

PalaceTests/CatalogDomain/CatalogRepositoryStaleWhileRevalidateTests.swift

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -91,10 +91,13 @@ final class CatalogRepositoryStaleWhileRevalidateTests: XCTestCase {
9191
}
9292

9393
// NOTE: the former private `awaitCondition` poll helper was removed — every
94-
// background-refresh wait now joins the repository's refresh Task directly
95-
// via `_awaitBackgroundRefreshForTesting()`, which is deterministic under
96-
// pool oversubscription (a fixed-deadline poll of a `.utility` detached
97-
// task's side effect was the parallel-clone flake, CI run 29805821296).
94+
// background-refresh wait now joins the repository's refresh Task(s) directly
95+
// via `_awaitBackgroundRefreshForTesting()` (single-refresh tests) or
96+
// `_awaitAllBackgroundRefreshesForTesting()` (the concurrent test, which
97+
// schedules two refreshes and arms `_trackRefreshTasksForTesting()` first).
98+
// Both are deterministic under pool oversubscription (a fixed-deadline poll
99+
// of a `.utility` detached task's side effect was the parallel-clone flake,
100+
// CI run 29805821296).
98101

99102
// MARK: - Fresh cache window (< 10 min)
100103

@@ -369,6 +372,16 @@ final class CatalogRepositoryStaleWhileRevalidateTests: XCTestCase {
369372
testNow.value = testNow.value.addingTimeInterval(1800) // 30 minutes
370373
api.stubbedFeeds[testURL] = CatalogAPIMock.makeMockFeed(title: "Refreshed-concurrent")
371374

375+
// Arm multi-refresh tracking BEFORE firing the concurrent reads so the
376+
// repository retains EVERY scheduled refresh handle, not just the
377+
// most-recently-scheduled one. Both stale reads each schedule their own
378+
// `.utility` detached refresh; the single-handle
379+
// `_awaitBackgroundRefreshForTesting()` only joins the last one, which
380+
// would let this test observe the cache mid-flight if the un-joined
381+
// refresh is the one still pending. Tracking is opt-in and defaults off,
382+
// so this is a no-op in production.
383+
sut._trackRefreshTasksForTesting()
384+
372385
// Sendable local so the async-let children capture it instead of reading
373386
// @MainActor `self.testURL` (which would send self). `sut` is already local.
374387
let testURL = testURL
@@ -381,16 +394,16 @@ final class CatalogRepositoryStaleWhileRevalidateTests: XCTestCase {
381394
XCTAssertEqual(resultB?.title, "Original-concurrent",
382395
"Concurrent stale read B must return cached value")
383396

384-
// Join the background refresh deterministically. Both stale reads
397+
// Join BOTH background refreshes deterministically. Each stale read
385398
// scheduled a `.utility` detached refresh SYNCHRONOUSLY before
386-
// returning; the repository retains the last-scheduled one. Awaiting
387-
// it blocks until that refresh's cache write lands — regardless of how
388-
// starved the cooperative pool is under parallel-clone oversubscription
389-
// (the old two-poll approach timed out at :401/:408 exactly there in CI
390-
// run 29805821296). Both refreshes fetch + write the same
391-
// "Refreshed-concurrent" feed, so the last one landing proves the
392-
// contract.
393-
await sut._awaitBackgroundRefreshForTesting()
399+
// returning; awaiting ALL tracked handles blocks until every refresh's
400+
// cache write lands — regardless of how starved the cooperative pool is
401+
// under parallel-clone oversubscription (the old two-poll approach timed
402+
// out at :401/:408 exactly there in CI run 29805821296). Using the "all"
403+
// variant (not the single-handle join) makes the fetch-count and
404+
// cache-refresh assertions below independent of which of the two
405+
// concurrent refreshes finishes last.
406+
await sut._awaitAllBackgroundRefreshesForTesting()
394407

395408
// The refresh fired (mutation kill: a no-op'd background branch never
396409
// bumps the count past 1). Deterministic now that the Task has joined.

0 commit comments

Comments
 (0)