Fix FileStorageKey dropping writes under an immediate scheduler#213
Open
mAu888 wants to merge 1 commit into
Open
Fix FileStorageKey dropping writes under an immediate scheduler#213mAu888 wants to merge 1 commit into
mAu888 wants to merge 1 commit into
Conversation
`FileStorageKey.save(.didSet)` armed its debounce work item and called
`storage.asyncAfter(...)` while still inside `state.withValue { ... }`. With
the in-memory test storage's `.immediate` scheduler, that work item ran
re-entrantly, and because `LockIsolated.withValue` is copy-and-write-back, the
outer scope's write-back clobbered the work item's `state.workItem = nil`. The
key was then permanently pinned in its debounced branch, so every `.didSet`
save after the first was buffered into `state.value` and never persisted.
A long-lived `@Shared` masks this (its in-memory value stays correct), but a
short-lived `@Shared` — recreated on each access across `await` points, as in a
dependency-client live value — deinits before reuse, discards the buffered
write, and a freshly created reference reloads the stale value from storage.
Under parallel `@Test` load this surfaced as cross-test `@Shared` "bleed".
Schedule the work item outside `state.withValue` so it can no longer run
re-entrantly under the lock. Deferred (production `DispatchQueue.main`)
behavior is unchanged; the immediate scheduler now persists every write.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author
|
I've updated the PR as there is an easier way to reproduce the issue. First, I thought #214 was fixing the issue. But I've pinned the example repo to Let me know if there is anything missing from this PR that would make it ready to merge, or what your point of view is on the underlying issue. |
Member
|
Sorry, my last message mentioned a deadlock, but in reality it's just a slow test. We'll look into this more soon. Thanks! |
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.
Summary
FileStorageKey's.didSetdebounce can get permanently stuck so that every save after the first is buffered but never persisted — whenever the backing storage uses an immediate scheduler. That's the default under test, wheredefaultFileStorageresolves to.inMemory(scheduler: .immediate).A long-lived
@Sharedmasks this, since its in-memory value stays correct. But a short-lived@Shared— e.g. one declared inside a dependency-client closure and recreated acrossawaitpoints — deinits between uses, discards the buffered write, and the next reference reloads a stale value from storage. Under parallel Swift Testing this looks like cross-@Test@Shared"bleed", but per-test isolation is actually intact; the write is dropped within a single test.Root cause
save(_:context:continuation:)arms the debounce work item and schedules it while still insidestate.withValue { … }:With
.immediate,asyncAfterruns the work item synchronously and re-entrantly. BecauseLockIsolated.withValueis copy-and-write-back……the re-entrant work item's
state.workItem = nilis clobbered by the outer scope's write-back.state.workItemthen stays non-nil forever (the one-shot work item has already run), so every later.didSetsave takes the bufferingelsebranch and nothing is ever written.Production (
.fileSystem→DispatchQueue.main) is unaffected: there the work item is genuinely deferred and runs outside the lock.Fix
Return the work item from
state.withValueand callstorage.asyncAfterafter the lock is released, so it can't run re-entrantly under the lock. Deferred behavior is unchanged; the immediate scheduler now persists every write.Tests
Adds
immediateSchedulerPersistsConsecutiveWrites: two consecutive.didSetwrites under.immediatemust both persist. It fails onmainand passes with the fix. The existingthrottle/noThrottlingdebounce tests are unchanged.Reproduction
The bug reproduces deterministically with two consecutive writes — no concurrency required:
Standalone repro repo (deterministic test;
mainreproduces, thewith-fixbranch points at this fix and passes), with full trace and root-cause analysis: https://github.com/mAu888/swift-sharing-testing-issueIts CI shows the contrast directly:
main(released 2.8.0): https://github.com/mAu888/swift-sharing-testing-issue/actions/runs/26781467352with-fixbranch): https://github.com/mAu888/swift-sharing-testing-issue/actions/runs/26781476410Possibly related: #108.