Skip to content

Commit 4b7e3e8

Browse files
committed
Update Publisher to support strict concurrency (fixes #167)
1 parent 4b95957 commit 4b7e3e8

File tree

6 files changed

+47
-39
lines changed

6 files changed

+47
-39
lines changed

Examples/Package.resolved

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Sources/SwiftCrossUI/State/Cancellable.swift

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,25 @@ public class Cancellable {
66
/// Human-readable tag for debugging purposes.
77
var tag: String?
88

9+
/// If defused, the cancellable won't cancel the ongoing action on deinit.
10+
var defused = false
11+
912
/// Creates a new cancellable.
1013
public init(closure: @escaping () -> Void) {
1114
self.closure = closure
1215
}
1316

17+
/// Extends a cancellable's lifetime to match its corresponding ongoing
18+
/// action. This doesn't actually extend the
19+
func defuse() {
20+
defused = true
21+
}
22+
1423
/// Runs the cancel action.
1524
deinit {
16-
cancel()
25+
if !defused {
26+
cancel()
27+
}
1728
}
1829

1930
/// Runs the cancel action and ensures that it can't be called a second time.

Sources/SwiftCrossUI/State/ObservableObject.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,8 @@ extension ObservableObject {
7878
continue
7979
}
8080

81-
_ = publisher.link(toUpstream: property.didChange)
81+
let cancellable = publisher.link(toUpstream: property.didChange)
82+
cancellable.defuse()
8283
}
8384
mirror = aClass.superclassMirror
8485
}

Sources/SwiftCrossUI/State/Publisher.swift

Lines changed: 29 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,31 @@
11
import Dispatch
22
import Foundation
33

4-
// TODO: Keep weak references to cancellables for downstream observations (instead
5-
// of the current strong references).
6-
74
/// A type that produces valueless observations.
85
public class Publisher {
96
/// The id for the next observation (ids are used to cancel observations).
107
private var nextObservationId = 0
118
/// All current observations keyed by their id (ids are used to cancel observations).
129
private var observations: [Int: () -> Void] = [:]
13-
/// Cancellable observations of downstream observers.
14-
private var cancellables: [Cancellable] = []
1510
/// Human-readable tag for debugging purposes.
1611
private var tag: String?
1712

18-
/// The time at which the last update merging event occurred in
19-
/// `observeOnMainThreadAvoidingStarvation`.
20-
private var lastUpdateMergeTime: TimeInterval = 0
21-
/// The amount of time taken per state update, exponentially averaged over time.
22-
private var exponentiallySmoothedUpdateLength: Double = 0
13+
/// We guard this against data races, with serialUpdateHandlingQueue, and
14+
/// with our lives.
15+
private class UpdateStatistics: @unchecked Sendable {
16+
/// The time at which the last update merging event occurred in
17+
/// `observeOnMainThreadAvoidingStarvation`.
18+
var lastUpdateMergeTime: TimeInterval = 0
19+
/// The amount of time taken per state update, exponentially averaged over time.
20+
var exponentiallySmoothedUpdateLength: Double = 0
21+
}
22+
23+
private let updateStatistics = UpdateStatistics()
24+
25+
private let serialUpdateHandlingQueue = DispatchQueue(
26+
label: "serial update handling"
27+
)
28+
private let semaphore = DispatchSemaphore(value: 1)
2329

2430
/// Creates a new independent publisher.
2531
public init() {}
@@ -51,7 +57,6 @@ public class Publisher {
5157
self.send()
5258
})
5359
cancellable.tag(with: "\(tag ?? "no tag") <-> \(cancellable.tag ?? "no tag")")
54-
cancellables.append(cancellable)
5560
return cancellable
5661
}
5762

@@ -91,12 +96,11 @@ public class Publisher {
9196
/// guaranteed that updates will always run serially.
9297
func observeAsUIUpdater<Backend: AppBackend>(
9398
backend: Backend,
94-
action closure: @escaping () -> Void
99+
action: @escaping @MainActor @Sendable () -> Void
95100
) -> Cancellable {
96-
let serialUpdateHandlingQueue = DispatchQueue(
97-
label: "serial update handling"
98-
)
99-
let semaphore = DispatchSemaphore(value: 1)
101+
let semaphore = self.semaphore
102+
let serialUpdateHandlingQueue = self.serialUpdateHandlingQueue
103+
let updateStatistics = self.updateStatistics
100104
return observe {
101105
// Only allow one update to wait at a time.
102106
guard semaphore.wait(timeout: .now()) == .success else {
@@ -105,7 +109,7 @@ public class Publisher {
105109
// as long as it happens within the next update or two.
106110
let mergeTime = ProcessInfo.processInfo.systemUptime
107111
serialUpdateHandlingQueue.async {
108-
self.lastUpdateMergeTime = mergeTime
112+
updateStatistics.lastUpdateMergeTime = mergeTime
109113
}
110114
return
111115
}
@@ -125,22 +129,23 @@ public class Publisher {
125129
// Run the closure and while we're at it measure how long it takes
126130
// so that we can use it when throttling if updates start backing up.
127131
let start = ProcessInfo.processInfo.systemUptime
128-
closure()
132+
action()
129133
let elapsed = ProcessInfo.processInfo.systemUptime - start
130134

131135
// I chose exponential smoothing because it's simple to compute, doesn't
132136
// require storing a window of previous values, and quickly converges to
133-
// a sensible value when the average moves while still somewhat ignoring
137+
// a sensible value when the average moves, while still somewhat ignoring
134138
// outliers.
135-
self.exponentiallySmoothedUpdateLength =
136-
elapsed / 2 + self.exponentiallySmoothedUpdateLength / 2
139+
updateStatistics.exponentiallySmoothedUpdateLength =
140+
elapsed / 2 + updateStatistics.exponentiallySmoothedUpdateLength / 2
137141
}
138142

139-
if ProcessInfo.processInfo.systemUptime - self.lastUpdateMergeTime < 1 {
143+
if ProcessInfo.processInfo.systemUptime - updateStatistics.lastUpdateMergeTime < 1 {
140144
// The factor of 1.5 was determined empirically. This algorithm is
141145
// open for improvements since it's purely here to reduce the risk
142-
// of UI freezes.
143-
let throttlingDelay = self.exponentiallySmoothedUpdateLength * 1.5
146+
// of UI freezes. A factor of 1.5 equates to a gap between updates of
147+
// approximately 50% of the average update length.
148+
let throttlingDelay = updateStatistics.exponentiallySmoothedUpdateLength * 1.5
144149

145150
// Sleeping on a dispatch queue generally isn't a good idea because
146151
// you prevent the queue from servicing any other work, but in this

Sources/SwiftCrossUI/ViewGraph/ViewGraphNode.swift

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -130,15 +130,6 @@ public class ViewGraphNode<NodeView: View, Backend: AppBackend>: Sendable {
130130
}
131131
}
132132

133-
/// Stops observing the view's state.
134-
deinit {
135-
Task { @MainActor [cancellables] in
136-
for cancellable in cancellables {
137-
cancellable.cancel()
138-
}
139-
}
140-
}
141-
142133
/// Triggers the view to be updated as part of a bottom-up chain of updates (where either the
143134
/// current view gets updated due to a state change and has potential to trigger its parent to
144135
/// update as well, or the current view's child has propagated such an update upwards).

Tests/SwiftCrossUITests/SwiftCrossUITests.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -164,9 +164,9 @@ final class SwiftCrossUITests: XCTestCase {
164164
// Compute percentage of main thread's time taken up by updates.
165165
let ratio = Double(await updateCount.count) * updateDuration / elapsed
166166
XCTAssert(
167-
0.5 <= ratio && ratio <= 0.85,
167+
ratio <= 0.85,
168168
"""
169-
Expected throttled updates to take between 50% and 80% of the main \
169+
Expected throttled updates to take under 85% of the main \
170170
thread's time. Took \(Int(ratio * 100))%
171171
"""
172172
)

0 commit comments

Comments
 (0)