|
| 1 | +#if canImport(Dispatch) |
| 2 | +import Foundation |
| 3 | +import Dispatch |
| 4 | +import Testing |
| 5 | +import ConcurrencyExtras |
| 6 | +@testable import SwiftModel |
| 7 | + |
| 8 | +/// A transaction through a model's creation handle must take the tester's write lock |
| 9 | +/// before the context lock, like every other writer. |
| 10 | +/// |
| 11 | +/// 1.1.4 let a write through a handle with no access fall back to the tree's tester |
| 12 | +/// (`fallbackTestAccess`). Writes inside a transaction resolve that fallback and take |
| 13 | +/// the tester lock, but the transaction's own lock chain didn't include it. So |
| 14 | +/// `dup.node.transaction { dup.x = … }` held the context lock and then waited for the |
| 15 | +/// tester lock, while a reader holding the tester lock waited for the context lock. |
| 16 | +/// Downstream that was a deterministic deadlock: a stream-environment model updated |
| 17 | +/// in a transaction while a media controller's task read model state. |
| 18 | +/// |
| 19 | +/// The deadlock needs a racing reader, so the test checks the invariant directly: |
| 20 | +/// while the transaction body runs, another thread must find the tester lock held. |
| 21 | +@Suite |
| 22 | +struct CreationHandleTransactionLockTests { |
| 23 | + |
| 24 | + @Test func nodeTransactionThroughCreationHandleHoldsTesterLock() { |
| 25 | + let tester = ModelTester(LockRoot(items: []), exhaustivity: .off) |
| 26 | + let root = tester.model |
| 27 | + let dup = LockItem(id: -1, value: 0) |
| 28 | + root.items.append(dup) |
| 29 | + |
| 30 | + let testerLock = tester.access.lock |
| 31 | + let heldByTransaction = LockIsolated<Bool?>(nil) |
| 32 | + dup.node.transaction { |
| 33 | + heldByTransaction.setValue(Self.isHeldByAnotherThread(testerLock)) |
| 34 | + dup.value = 1 |
| 35 | + } |
| 36 | + |
| 37 | + #expect(heldByTransaction.value == true) |
| 38 | + #expect(root.items[0].value == 1) |
| 39 | + } |
| 40 | + |
| 41 | + /// `NSRecursiveLock.try()` from a different thread fails exactly when some thread |
| 42 | + /// holds the lock. |
| 43 | + /// |
| 44 | + /// The probe runs on a dedicated `Thread`, not a GCD queue. The caller blocks until |
| 45 | + /// it answers, and it blocks a Swift-concurrency thread while holding locks. A |
| 46 | + /// `DispatchQueue.global()` block took ~60 s to be scheduled on a TSan CI runner, |
| 47 | + /// and parking a cooperative thread that long starved the parallel tests' model |
| 48 | + /// tasks. A new thread starts at once, so the caller waits only for one `try()`. |
| 49 | + static func isHeldByAnotherThread(_ lock: NSRecursiveLock) -> Bool { |
| 50 | + let result = LockIsolated(false) |
| 51 | + let done = DispatchSemaphore(value: 0) |
| 52 | + let probe = Thread { |
| 53 | + if lock.try() { |
| 54 | + lock.unlock() |
| 55 | + result.setValue(false) |
| 56 | + } else { |
| 57 | + result.setValue(true) |
| 58 | + } |
| 59 | + done.signal() |
| 60 | + } |
| 61 | + probe.start() |
| 62 | + done.wait() |
| 63 | + return result.value |
| 64 | + } |
| 65 | +} |
| 66 | + |
| 67 | +@Model private struct LockItem: Identifiable { |
| 68 | + var id: Int |
| 69 | + var value: Int |
| 70 | +} |
| 71 | + |
| 72 | +@Model private struct LockRoot { |
| 73 | + var items: [LockItem] |
| 74 | +} |
| 75 | +#endif |
0 commit comments