Skip to content

Fix FileStorageKey dropping writes under an immediate scheduler#213

Open
mAu888 wants to merge 1 commit into
pointfreeco:mainfrom
mAu888:fix/filestoragekey-immediate-scheduler-dropped-writes
Open

Fix FileStorageKey dropping writes under an immediate scheduler#213
mAu888 wants to merge 1 commit into
pointfreeco:mainfrom
mAu888:fix/filestoragekey-immediate-scheduler-dropped-writes

Conversation

@mAu888

@mAu888 mAu888 commented May 29, 2026

Copy link
Copy Markdown

Summary

FileStorageKey's .didSet debounce 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, where defaultFileStorage resolves to .inMemory (scheduler: .immediate).

A long-lived @Shared masks this, since its in-memory value stays correct. But a short-lived @Shared — e.g. one declared inside a dependency-client closure and recreated across await points — 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 inside state.withValue { … }:

try state.withValue { state in
  
  state.workItem = workItem
  storage.asyncAfter(.seconds(1), workItem)   // scheduled inside the lock
}

With .immediate, asyncAfter runs the work item synchronously and re-entrantly. Because LockIsolated.withValue is copy-and-write-back…

var value = self._value
defer { self._value = value }   // restores the outer copy on exit
return try operation(&value)

…the re-entrant work item's state.workItem = nil is clobbered by the outer scope's write-back. state.workItem then stays non-nil forever (the one-shot work item has already run), so every later .didSet save takes the buffering else branch and nothing is ever written.

Production (.fileSystemDispatchQueue.main) is unaffected: there the work item is genuinely deferred and runs outside the lock.

Fix

Return the work item from state.withValue and call storage.asyncAfter after 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 .didSet writes under .immediate must both persist. It fails on main and passes with the fix. The existing throttle / noThrottling debounce tests are unchanged.

Reproduction

The bug reproduces deterministically with two consecutive writes — no concurrency required:

@Test func consecutiveWritesArePersisted() async throws {
    @Shared(.fileStorage(.store)) var items = [Int]()

    $items.withLock { $0.append(1) }
    $items.withLock { $0.append(2) }

    try await $items.load()   // reload from storage

    #expect(items == [1, 2])  // ❌ 2.8.0: items == [1]
}

Standalone repro repo (deterministic test; main reproduces, the with-fix branch points at this fix and passes), with full trace and root-cause analysis: https://github.com/mAu888/swift-sharing-testing-issue

Its CI shows the contrast directly:

Possibly related: #108.

`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>
@mAu888

mAu888 commented Jun 1, 2026

Copy link
Copy Markdown
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 swift-sharing@main and it continues to fail (also with the simplified example). See https://github.com/mAu888/swift-sharing-testing-issue/actions/runs/26781956782/job/78948233737

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.

@mbrandonw

Copy link
Copy Markdown
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!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants