Fix CacheCallbackCoordinator race and out-of-order cache actions#2542
Fix CacheCallbackCoordinator race and out-of-order cache actions#2542devzahirul wants to merge 1 commit into
Conversation
`CacheCallbackCoordinator.apply` resolved each transition with two separate
`stateQueue.sync` calls (read in the `switch`, write afterwards), so two
concurrent `apply` calls could interleave between the read and the write and
corrupt the state machine -- firing the completion twice or dropping it (a stuck
`waitForCache` completion).
It also assumed `.cacheInitiated` always arrives first. The cache store callbacks
run on a different queue than the synchronous `.cacheInitiated` call, so a write
can complete first, landing in `.imageCached` / `.originalImageCached` and
tripping `assertionFailure("... originalImageCached - cacheInitiated")` -- an
intermittent Debug/CI crash.
Resolve the whole transition inside a single `stateQueue.sync`, invoke `trigger`
only after releasing the queue, and treat a late `.cacheInitiated` the same as
`(.idle, .cacheInitiated)`. Add deterministic and concurrent tests covering every
ordering: the late-`.cacheInitiated` case crashes before the fix, and the
completion now fires exactly once.
|
Reviewed the coordinator transitions against the production cache paths. The serialized state transition and tolerance for a late One non-blocking follow-up: parameterizing the new ordering/concurrency tests for — an assistant to @onevcat |
onevclaw
left a comment
There was a problem hiding this comment.
As onevcat's assistant, I reviewed 8cbf74148ce17d66d7f370f17d17946c8425ea39.
The state transition is now serialized and completion runs outside the critical section, which addresses the reachable callback-before-.cacheInitiated ordering without introducing a reentrancy issue.
Non-blocking test coverage suggestion: please consider adding the callback-before-.cacheInitiated permutations for shouldCacheOriginal == false, for both waitForCache values, to protect that production path as well.
I also found that swift test -q cannot discover tests because the package manifest has no test target. This appears outside this PR's diff, but should be resolved or explicitly treated as a separate repository-level issue. Visible CI is still pending, so this is a comment rather than an approval.
onevclaw - an assistant to @onevcat
Review follow-up for onevcat#2542: cover the production path where only .cacheInitiated and .cachingImage are applied — both orderings, both waitForCache values, asserting waitForCache does not complete before the cache callback. Also run the concurrent-actions test for both shouldCacheOriginal values. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review follow-up for onevcat#2542: cover the production path where only .cacheInitiated and .cachingImage are applied — both orderings, both waitForCache values, asserting waitForCache does not complete before the cache callback. Also run the concurrent-actions test for both shouldCacheOriginal values.
d8a6804 to
8cbf741
Compare
What
Fixes a race and an intermittent assertion crash in
CacheCallbackCoordinator— the cache-completion coordinator used byKingfisherManagerwhen storing image + original image.Symptom
A flaky CI crash (Debug-only
assertionFailure):Two root causes
1. Non-atomic transition.
apply()readstate(in theswitchtuple) and wrote it back via separatestateQueue.synccalls:Two
applycalls (the.cachingImageand.cachingOriginalImagestore callbacks run on different threads) can interleave between the read and the write — corrupting the state machine and either firingtriggertwice (double completion) or never (a stuckwaitForCachecompletion).2. Out-of-order actions. The three actions are produced from different queues:
If a store finishes before the synchronous
.cacheInitiatedis applied,.cacheInitiatedarrives in.imageCached/.originalImageCachedinstead of.idle. The state machine had no case for that and hitdefault → assertionFailure(the CI crash). It's intermittent because it depends on store-vs-cacheInitiatedtiming.Fix
stateQueue.sync, returning whether to fire, and calltrigger()after the queue is released (so user code never runs while the queue is held). This makes each transition atomic and guaranteestriggerfires exactly once.(.imageCached, .cacheInitiated)/(.originalImageCached, .cacheInitiated)identically to(.idle, .cacheInitiated)instead of trapping. Thedefault → assertionFailureis kept as a safety net for genuinely-unreachable duplicate actions.No public API or behavior change for the in-order path; the completion semantics are unchanged.
Tests (
KingfisherManagerTests)testCacheCoordinatorToleratesLateCacheInitiated— deterministic reproduction of the CI crash (.cachingOriginalImage/.cachingImagethen.cacheInitiated).testCacheCoordinatorTriggersExactlyOnceForEveryOrdering— all 6 action orderings ×waitForCache, completion must fire exactly once.testCacheCoordinatorConcurrentActionsTriggerOnce— applies the actions concurrently 1000×, asserts exactly one trigger and stays clean under TSan.Verification (macOS, Thread Sanitizer)
testCacheCoordinatorToleratesLateCacheInitiatedcrashes withFatal error: … originalImageCached - cacheInitiated→** TEST FAILED **(the exact CI failure).** TEST SUCCEEDED **.Why it matters
This is the intermittent failure behind the "originalImageCached - cacheInitiated" crash that flakes CI. Beyond the Debug assertion, the non-atomic transition can drop a
waitForCachecompletion in Release builds — a silently never-firing callback — which is a plausible contributor to hard-to-reproduce hang reports.CI
The full test + build matrix passes on my fork across iOS, macOS, tvOS, and watchOS (Xcode 26.2 and 26.5).