Skip to content

Commit 68b4c83

Browse files
Merge pull request #96 from bitofmind/claude/transaction-fallback-lock
Take the tester lock in transactions through a creation handle (1.1.4 deadlock)
2 parents ea99792 + 3ca44c2 commit 68b4c83

4 files changed

Lines changed: 89 additions & 3 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ All notable changes are documented here. The format follows [Keep a Changelog](h
88

99
### Fixed
1010

11+
- **1.1.4 could deadlock a test that ran `node.transaction` through a model's creation handle.** 1.1.4 made a write through a handle with no access fall back to the tree's tester, and that write takes the tester's lock. A transaction decides which lock to take before it starts, but its chain lacked the fallback. So `dup.node.transaction { dup.value = … }` took the context lock first and then waited for the tester lock, while any reader holding the tester lock (an `expect` evaluation, a model task reading state) waited for the context lock. Downstream this hung a stream-environment test every time, on 1.1.4 only.
12+
- `node.transaction`, and the internal `ModelContext.transaction` / `stateTransaction`, now include the same fallback, so the transaction takes the tester lock before the context lock, the same order as every other writer.
13+
- `CreationHandleTransactionLockTests` checks that, inside such a transaction, another thread finds the tester lock already held. Before the fix it found the lock free, every run.
1114
- **An exhaustivity report named the wrong property when the model held a collection of models before it.** The name comes from counting the model's properties as they are visited, but a collection of models (`[Item]`, `IdentifiedArray`, a collection of `@ModelContainer` enums) was never counted. Each one shifted every later property's name back by one, so a write to `trigger` after `var items: [Item]` was reported as `Root.items: 0 → 1`. Collections are now counted, so the report reads `Root.trigger: 0 → 1`.
1215
- `PropertyNameAfterCollectionTests` covers a property after a model array and after a collection of `@ModelContainer` enums. Before the fix, both reported the wrong name.
1316

‎Sources/SwiftModel/Internal/ModelContext+Internal.swift‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,9 @@ extension ModelContext {
231231
// ?? ModelAccess.current`), so the outer transaction's
232232
// `TestAccess.lock` matches the nested writes' `TestAccess.lock`
233233
// by identity. See the long comment at `Context.transaction(writeLockHolder:_:)`.
234-
let writeLockHolder = ModelAccess.active?.writeLockOwner ?? _access._reference?.access?.writeLockOwner ?? ModelAccess.current?.writeLockOwner
234+
// The trailing `fallbackTestAccess` matches the nested writes' fallback;
235+
// see `ModelNode.transaction`.
236+
let writeLockHolder = ModelAccess.active?.writeLockOwner ?? _access._reference?.access?.writeLockOwner ?? ModelAccess.current?.writeLockOwner ?? context.fallbackTestAccess?.writeLockOwner
235237
return try context.transaction(writeLockHolder: writeLockHolder, callback)
236238
} else {
237239
return try callback()
@@ -245,7 +247,7 @@ extension ModelContext {
245247
func stateTransaction<T>(_ callback: () throws -> T) rethrows -> T {
246248
if let context {
247249
// Same writeLockHolder chain as `transaction(_:)` above.
248-
let writeLockHolder = ModelAccess.active?.writeLockOwner ?? _access._reference?.access?.writeLockOwner ?? ModelAccess.current?.writeLockOwner
250+
let writeLockHolder = ModelAccess.active?.writeLockOwner ?? _access._reference?.access?.writeLockOwner ?? ModelAccess.current?.writeLockOwner ?? context.fallbackTestAccess?.writeLockOwner
249251
return try context.transaction(writeLockHolder: writeLockHolder, callback)
250252
} else {
251253
return try callback()

‎Sources/SwiftModel/ModelNode.swift‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -348,7 +348,13 @@ public extension ModelNode {
348348
// Use the same writeLockHolder chain that `stateTransaction`
349349
// uses for nested property writes — see the comment block in
350350
// `Context.transaction(writeLockHolder:_:)`.
351-
let writeLockHolder = ModelAccess.active?.writeLockOwner ?? _$modelContext._access._reference?.access?.writeLockOwner ?? ModelAccess.current?.writeLockOwner
351+
// The trailing `fallbackTestAccess` matches the nested writes: a write
352+
// whose chain resolves nothing else falls back to the tree's tester
353+
// and takes its lock, so this transaction must take it first. Without
354+
// it, a transaction through a creation handle held the context lock
355+
// while a nested write waited for the tester lock: AB-BA against any
356+
// tester-locked reader.
357+
let writeLockHolder = ModelAccess.active?.writeLockOwner ?? _$modelContext._access._reference?.access?.writeLockOwner ?? ModelAccess.current?.writeLockOwner ?? context.fallbackTestAccess?.writeLockOwner
352358
return context.transaction(writeLockHolder: writeLockHolder, callback)
353359
}
354360
} else {
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
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

Comments
 (0)