Run connection state as atomic blocks on one queue, and serialise reassembly#90
Open
kmatzen wants to merge 1 commit into
Open
Run connection state as atomic blocks on one queue, and serialise reassembly#90kmatzen wants to merge 1 commit into
kmatzen wants to merge 1 commit into
Conversation
kmatzen
force-pushed
the
fix/sleep-visibility
branch
from
July 19, 2026 04:48
3422467 to
303e745
Compare
…ssembly BLEManager keeps connection state in three @published dictionaries that are read from the CoreBluetooth delegate queue and mutated on main. Swift Dictionary is not thread-safe, so concurrent access is undefined behaviour rather than a stale read, and a decision made from a value read on one queue can be invalidated before the write lands on another. Dispatching only the writes to main does not fix either problem. That is the shape several sites already had, and it leaves the read racing and the decision stale. What is required is that the whole read-decide-write runs as one block on a single queue. Main is that queue for connection state, since these properties drive SwiftUI and must be published on main regardless. Sites corrected, all of which split the read from the write across a queue hop: handleConnectionSuccess looked up connectingGoPros on the CoreBluetooth queue and inserted that reference into connectedGoPros in a later main block, so the camera could be removed in between and the stale reference reinserted. didDiscover checked "already connected?" on the CoreBluetooth queue and acted on the answer in a dispatched block. powerDownGoPro looked up the camera on the caller's queue and dispatched only the removal. setDateTime, the WiFi response callbacks, and the device-query timer all read state off the state queue. Two reads were simply unnecessary and are gone: handleCommandResponse looked up a camera for a display name already in scope, and createAppStateContext built crash-report context from the CoreBluetooth queue. onStateQueue always dispatches rather than running inline when already on main. Running inline is faster but makes the order blocks land in depend on which thread issued them — work dispatched earlier from the CoreBluetooth queue would still be waiting while a later main-thread caller ran immediately. Atomicity of each block does not by itself give a consistent order between blocks. assertOnStateQueue traps in debug builds when connection state is touched off the state queue, so a new call site fails immediately in development rather than corrupting memory occasionally in the field. BLEPacketReconstructor had the same hazard on different queues: processPacket runs on the CoreBluetooth queue while checkTimeouts runs on the device-query timer's queue. Its state does not drive SwiftUI, so it gets a dedicated serial queue rather than main, which keeps packet handling off the main thread. specs/ contains the TLA+ models these designs were checked against, with a README describing what each one rules out. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AqfipPBp3eErDgZboooqxP
kmatzen
force-pushed
the
fix/state-queue-discipline
branch
from
July 19, 2026 04:52
084b852 to
2e04323
Compare
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.
Last of four stacked PRs. Base is
fix/sleep-visibility(#89), notmain. This is the largest and least verified — see review notes.The problem
BLEManagerkeeps connection state in three@Publisheddictionaries, read from the CoreBluetooth delegate queue and mutated on main. SwiftDictionaryis not thread-safe, so that is undefined behaviour, not a stale read — and a decision made from a value read on one queue can be invalidated before the write lands on another.I modelled the candidate disciplines before writing anything (
specs/ConnectionConcurrency.tla):Dispatching only the writes to main fixes nothing. That is worth stating plainly because it is the shape several sites already had, and because a diff that wraps a mutation in
DispatchQueue.main.asynclooks like a fix and is not one.Sites corrected
Each of these split the read from the write across a queue hop:
handleConnectionSuccesslooked upconnectingGoProson the CoreBluetooth queue and inserted that reference intoconnectedGoProsin a later main block — the camera could be removed in between and the stale reference reinserted.didDiscoverchecked "already connected?" on the CoreBluetooth queue and acted on it in a dispatched block.powerDownGoProlooked up the camera on the caller's queue and dispatched only the removal.setDateTime, the WiFi response callbacks, and the device-query timer all read state off the state queue.Two reads were simply unnecessary and are gone:
handleCommandResponselooked up a camera for a display name already in scope, andcreateAppStateContextbuilt crash-report context from the CoreBluetooth queue.Ordering between blocks
onStateQueuealways dispatches rather than running inline when already on main. I wrote the inline version first; a second model (specs/StateQueueOrdering.tla) inverts two operations in three steps — work dispatched earlier from the CoreBluetooth queue is still waiting while a later main-thread caller executes immediately.Atomicity of each block does not imply a consistent order between blocks. The cost is that main-thread callers are deferred by a runloop turn; I checked all nine call sites and none depend on the effect landing synchronously.
File split
BLEManager.swiftis at SwiftLint's 2600-line ceiling. The state-queue primitives move toBLEManager+StateQueue.swift, which is where they belong anyway — they are infrastructure, not connection logic. The file lands at 2556.Reassembler
Same hazard, different queues:
processPacketruns on the CoreBluetooth queue whilecheckTimeoutsruns on the device-query timer's queue. Its state does not drive SwiftUI, so it gets a dedicated serial queue rather than main, keeping packet handling off the main thread.This one is verified empirically — 4,000 concurrent iterations under ThreadSanitizer:
The control matters: it proves TSan was actually detecting, so the clean run means something.
Review notes — please read before merging
BLEManager's discipline rests on the model plus the debug assertions and nothing else. Exercising it needs real BLE traffic, so the largest part of this change has no empirical verification. The 135 passing tests (verified viaxcodebuild teston iOS 26.5) are all pure logic and would stay green whether or not the queue discipline is right; they confirm nothing was broken, not that the concurrency is correct.There were ~70 original access sites. I would assume I missed some.
Suggested validation: run with ThreadSanitizer enabled against a real camera, connect and disconnect a few times, and watch for both TSan reports and
assertOnStateQueuefailures. The assertions will find missed paths faster than TSan will.If you want to merge the first three PRs and hold this one until it has been on hardware, that split is deliberate — nothing in #87–#89 depends on this.
🤖 Generated with Claude Code
https://claude.ai/code/session_01AqfipPBp3eErDgZboooqxP