Skip to content

fix(swift-sdk): serialize SwiftData ModelContext access from FFI callbacks#3558

Merged
QuantumExplorer merged 3 commits into
v3.1-devfrom
fix/swiftdata-modelcontext-thread-safety
Apr 28, 2026
Merged

fix(swift-sdk): serialize SwiftData ModelContext access from FFI callbacks#3558
QuantumExplorer merged 3 commits into
v3.1-devfrom
fix/swiftdata-modelcontext-thread-safety

Conversation

@QuantumExplorer

Copy link
Copy Markdown
Member

Summary

PlatformWalletPersistenceHandler shares a single SwiftData ModelContext across the FFI callback shims that the Rust persister invokes — but ModelContext is not thread-safe, and those shims execute on Tokio worker threads. Concurrent fetch/save corrupts SwiftData's internal state and crashes inside ModelContext.fetch, e.g.:

#0  ___lldb_unnamed_symbol5109 ()
…
#9  dispatch thunk of SwiftData.ModelContext.fetch<…>
#10 PlatformWalletPersistenceHandler.markUtxoSpent(_:)
#11 PlatformWalletPersistenceHandler.applyAccountChangeset(walletRecord:acc:)
#12 PlatformWalletPersistenceHandler.persistWalletChangeset(walletId:changeset:)
#13 persistWalletChangesetCallback(context:walletIdPtr:changesetPtr:)
#15 …FFIPersister…store…
#16 …spawn_wallet_event_adapter…closure…
#17 tokio…task…poll…

The Tokio runtime is fanning multiple persistence callbacks (begin/changeset/identities/keys/end, plus loadWalletListloadWalletListFree pairs) at the same context; the crash above lands mid-utxos_spent because that's a frequent fetch-then-mutate path inside the changeset round.

Fix

Confine backgroundContext (and the loadAllocations dictionary backing loadWalletList/loadWalletListFree) to a private serial `DispatchQueue`. Every public entry point now wraps its body in onQueue { … }, which calls queue.sync(execute:):

  • Synchronous semantics preserve the FFI contract — each C shim still returns its Int32 status before yielding back to Rust, no async bridging needed.
  • The serial queue gives the handler the same single-thread guarantees as a @ModelActor without forcing the FFI boundary to become async.
  • Internal helpers (upsertTransaction, markUtxoSpent, applyAccountChangeset, walletNetwork, ensureWalletRecord, linkTokenBalanceRelations, deriveAndStoreIdentityKey, …) are unchanged: they assume they're already on the queue, which holds because they're only reachable from a wrapped public method.

Two cache loaders had to be split into public wrapper + private *OnQueue impl so loadWalletList — itself on the queue — can call them without re-entering `sync` and deadlocking:

  • loadCachedBalances(walletId:)loadCachedBalancesOnQueue(walletId:)
  • loadCachedSyncState(network:)loadCachedSyncStateOnQueue(network:) (the (walletId:) overload now resolves the network and reads the row in a single queue hop via the on-queue impl)

`loadAllocations` is also covered: `loadWalletList` writes the entry pointer into the dict on the queue, `loadWalletListFree` removes it on the queue, so they no longer race when Rust drives them on different worker threads.

Why not @ModelActor?

Tried first; an actor forces every callback to bridge sync → async, which the Rust persister's Int32-return contract makes painful (semaphore + wake-blocking on the worker thread). The serial queue is the same isolation in shape, with no FFI signature changes and no actor-hop between intra-changeset calls.

Test plan

  • Verified swiftc -typecheck over the SwiftDashSDK target with the iOS-simulator slice + Swift 6 strict concurrency passes
  • Reproduce the original crash on a sync-heavy workload (incoming changeset with multiple utxos_spent entries) on a build without this fix, then re-run with the fix and confirm no ModelContext crash
  • Spot-check that long-running persistence rounds (changesetBegin → many persist* callbacks → changesetEnd) still commit atomically — the per-round bracketing is unchanged, just serialized

🤖 Generated with Claude Code

…backs

`PlatformWalletPersistenceHandler` shared a single `ModelContext`
across FFI callback shims, but `ModelContext` is not thread-safe.
The Rust persister fires its callbacks on Tokio worker threads, so
multiple persistence callbacks could (and did) hit `fetch`/`save`
on the same context concurrently, corrupting SwiftData's internal
state and crashing inside `ModelContext.fetch` — typically inside
`markUtxoSpent` mid-changeset.

Confine the context to a serial `DispatchQueue` and route every
public entry point through an `onQueue { … }` helper that calls
`queue.sync(execute:)`. The synchronous bracketing matches the
synchronous FFI contract (each shim still returns its `Int32`
status before yielding back to Rust) and gives the handler the
same isolation guarantees as an actor without forcing the FFI
boundary to become async.

Two cache-loader methods (`loadCachedBalances`,
`loadCachedSyncState(network:)`) had to be split into a public
queue-wrapping wrapper plus a private `*OnQueue` impl so
`loadWalletList` — already on the queue — can call them without
re-entering `sync` and deadlocking. The `loadCachedSyncState(walletId:)`
overload now also routes through the on-queue impl after resolving
the network in a single hop.

`loadAllocations` (the dictionary that retains the heap buffers
returned to Rust) is also confined to the queue, so the
`loadWalletList` insert and the `loadWalletListFree` remove no
longer race when Rust drives them on different worker threads.
@coderabbitai

coderabbitai Bot commented Apr 28, 2026

Copy link
Copy Markdown
Contributor

Warning

Rate limit exceeded

@QuantumExplorer has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 1 minutes and 54 seconds before requesting another review.

To keep reviews running without waiting, you can enable usage-based add-on for your organization. This allows additional reviews beyond the hourly cap. Account admins can enable it under billing.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0aff8cd9-8ec0-4501-b76f-3d065290cf04

📥 Commits

Reviewing files that changed from the base of the PR and between 413363e and 5c5ea53.

📒 Files selected for processing (7)
  • packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift
  • packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPlatformAddressesSyncState.swift
  • packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageExplorerView.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift
  • packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/swiftdata-modelcontext-thread-safety

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions github-actions Bot added this to the v3.1.0 milestone Apr 28, 2026
@thepastaclaw

thepastaclaw commented Apr 28, 2026

Copy link
Copy Markdown
Collaborator

Review Gate

Commit: 5c5ea531

  • Debounce: 0m ago (need 30m)

  • CI checks: builds passed, 0/0 tests passed

  • CodeRabbit review: comment found

  • Off-peak hours: peak window (5am-11am PT) — currently 05:39 AM PT Tuesday

  • Run review now (check to override)

@github-actions

github-actions Bot commented Apr 28, 2026

Copy link
Copy Markdown
Contributor

✅ DashSDKFFI.xcframework built for this PR.

SwiftPM (host the zip at a stable URL, then use):

.binaryTarget(
  name: "DashSDKFFI",
  url: "https://your.cdn.example/DashSDKFFI.xcframework.zip",
  checksum: "7045764bb0699a08a8c8ad3a58818322e4044ac8dd81cfe59607d169d78087cb"
)

Xcode manual integration:

  • Download 'DashSDKFFI.xcframework' artifact from the run link above.
  • Drag it into your app target (Frameworks, Libraries & Embedded Content) and set Embed & Sign.
  • If using the Swift wrapper package, point its binaryTarget to the xcframework location or add the package and place the xcframework at the expected path.

…AddressesSyncState

The model only stores the BLAST address-sync watermark — its name
read as if it were a generic sync state. Rename the @model class,
its backing file, the two storage-explorer views, and the storage-
explorer row label to make the scope explicit.

Pure rename across the SwiftDashSDK target and the example app:
no behavior change, no `@Model(originalName:)` migration shim
(existing rows on the old table are orphaned — acceptable, the
next sync round repopulates).

@QuantumExplorer QuantumExplorer left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Self Reviewed

@QuantumExplorer QuantumExplorer merged commit 9bd37f2 into v3.1-dev Apr 28, 2026
7 checks passed
@QuantumExplorer QuantumExplorer deleted the fix/swiftdata-modelcontext-thread-safety branch April 28, 2026 12:39
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