Fix CacheCallbackCoordinator race and out-of-order cache actions#2542
Open
devzahirul wants to merge 1 commit into
Open
Fix CacheCallbackCoordinator race and out-of-order cache actions#2542devzahirul wants to merge 1 commit into
devzahirul 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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).