Skip to content

Commit 7fe6b4a

Browse files
authored
fix(state): stale pointer access could cause app crashes (#121)
* fix(state): stale pointer access could cause app crashes * fix(state): potential race conditions * fix(events): check isAccessTokenValid synchronously before subscribing When signInCompleted is emitted and the access token is already valid, fire the event immediately rather than creating a Combine subscription. This prevents a race condition where the subscription misses the already-settled value and the event is permanently lost. Extracted notifyListeners helper to reduce duplication. * fix(state): correct dispatchToMainActor docs and throttled objectDidChange timing - Updated dispatchToMainActor docstring to accurately describe the DispatchQueue.main.async + @mainactor Task pattern (was incorrectly referencing a Combine subject). - Moved objectDidChange notifications in throttled state variants to fire after current is actually updated in the throttle sink, rather than prematurely in the applyThrottledStateUpdate method where current still holds the old value.
1 parent c575477 commit 7fe6b4a

6 files changed

Lines changed: 153 additions & 90 deletions

File tree

Sources/Rownd/Models/Context/ReSwiftObserver.swift

Lines changed: 128 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,49 @@ import Foundation
1010
import ReSwift
1111
import SwiftUI
1212

13-
// MARK: - Main Thread Dispatch Helper
14-
15-
/// Helper to centralize main-thread dispatch with weak self handling.
16-
/// Reduces duplication and ensures consistent patterns across observable state types.
17-
private func dispatchOnMain<T: AnyObject>(_ instance: T, execute work: @escaping (T) -> Void) {
13+
// MARK: - Main Actor Dispatch Helper
14+
15+
/// Dispatches work to the MainActor from a nonisolated context.
16+
///
17+
/// Uses `DispatchQueue.main.async` to preserve FIFO ordering of state updates,
18+
/// then hops into a `@MainActor` Task for proper isolation. This prevents the
19+
/// ordering issues that can occur with unstructured Task spawning under
20+
/// high-frequency state changes.
21+
///
22+
/// - Parameters:
23+
/// - instance: The object to operate on (captured weakly)
24+
/// - state: The state value to process
25+
/// - work: The MainActor-isolated work to perform
26+
private func dispatchToMainActor<T: AnyObject, S>(
27+
_ instance: T,
28+
state: S,
29+
work: @escaping @MainActor (T, S) -> Void
30+
) {
31+
// Use DispatchQueue.main.async for FIFO ordering, then Task for @MainActor isolation
1832
DispatchQueue.main.async { [weak instance] in
1933
guard let instance = instance else { return }
20-
work(instance)
34+
Task { @MainActor in
35+
work(instance, state)
36+
}
2137
}
2238
}
2339

40+
// MARK: - ObservableState
41+
42+
/// Observable wrapper for ReSwift state slices that publishes changes to SwiftUI.
43+
/// Uses @MainActor to ensure all @Published property access is thread-safe.
44+
///
45+
/// ## Thread Safety
46+
/// ReSwift may call `newState(state:)` from any thread. This class uses @MainActor
47+
/// isolation to ensure all @Published property access occurs on the main thread,
48+
/// preventing crashes in swift_retain when accessing Combine's Published wrapper.
49+
///
50+
/// ## State Update Ordering
51+
/// State updates are dispatched through DispatchQueue.main.async to maintain FIFO ordering,
52+
/// then processed on the MainActor. While this preserves ordering of dispatch calls,
53+
/// the actual property updates occur asynchronously. For most SwiftUI use cases this is
54+
/// acceptable since SwiftUI will render the final state.
55+
@MainActor
2456
public class ObservableState<T: Hashable>: ObservableObject, StoreSubscriber, ObservableSubscription
2557
{
2658

@@ -42,43 +74,45 @@ public class ObservableState<T: Hashable>: ObservableObject, StoreSubscriber, Ob
4274

4375
public func subscribe() {
4476
guard !isSubscribed else { return }
45-
// Capture selector directly to avoid retaining self in the transform closure
4677
let selector = self.selector
47-
dispatchOnMain(self) { instance in
48-
guard !instance.isSubscribed else { return }
49-
Context.currentContext.store.subscribe(
50-
instance, transform: { $0.select(selector) })
51-
instance.isSubscribed = true
52-
}
78+
Context.currentContext.store.subscribe(self, transform: { $0.select(selector) })
79+
isSubscribed = true
5380
}
5481

5582
func unsubscribe() {
5683
guard isSubscribed else { return }
57-
dispatchOnMain(self) { instance in
58-
guard instance.isSubscribed else { return }
59-
Context.currentContext.store.unsubscribe(instance)
60-
instance.isSubscribed = false
61-
}
84+
Context.currentContext.store.unsubscribe(self)
85+
isSubscribed = false
6286
}
6387

6488
deinit {
65-
unsubscribe()
89+
// Note: deinit is nonisolated even for @MainActor classes.
90+
// ReSwift's SubscriptionBox holds a weak reference to subscribers,
91+
// so cleanup happens automatically when this object is deallocated.
6692
}
6793

68-
public func newState(state: T) {
69-
// All @Published property access must happen on main thread
70-
dispatchOnMain(self) { instance in
71-
guard instance.current != state else { return }
72-
let old = instance.current
73-
if let animation = instance.animation {
74-
withAnimation(animation) {
75-
instance.current = state
76-
}
77-
} else {
78-
instance.current = state
94+
/// Called by ReSwift when state changes. This method is nonisolated because
95+
/// ReSwift may call it from any thread. Updates are dispatched to MainActor
96+
/// via DispatchQueue.main to maintain FIFO ordering.
97+
nonisolated public func newState(state: T) {
98+
dispatchToMainActor(self, state: state) { instance, newState in
99+
instance.applyStateUpdate(newState)
100+
}
101+
}
102+
103+
/// Applies the state update on MainActor. Separated from newState to keep
104+
/// the dispatch logic clean and enable subclass overrides.
105+
fileprivate func applyStateUpdate(_ state: T) {
106+
guard current != state else { return }
107+
let old = current
108+
if let animation = animation {
109+
withAnimation(animation) {
110+
current = state
79111
}
80-
instance.objectDidChange.send(DidChangeSubject(old: old, new: instance.current))
112+
} else {
113+
current = state
81114
}
115+
objectDidChange.send(DidChangeSubject(old: old, new: current))
82116
}
83117

84118
public let objectDidChange = PassthroughSubject<DidChangeSubject<T>, Never>()
@@ -101,30 +135,36 @@ public class ObservableThrottledState<T: Hashable>: ObservableState<T> {
101135

102136
objectThrottled
103137
.throttle(for: .milliseconds(throttleInMs), scheduler: DispatchQueue.main, latest: true)
104-
.sink { [weak self] in self?.current = $0 }
138+
.sink { [weak self] in
139+
guard let self = self else { return }
140+
let old = self.current
141+
self.current = $0
142+
self.objectDidChange.send(DidChangeSubject(old: old, new: self.current))
143+
}
105144
.store(in: &cancellables)
106145
}
107146

108-
override public func newState(state: T) {
109-
// All @Published property access must happen on main thread to avoid crashes
110-
// in swift_retain when accessing Combine's Published wrapper from background threads
111-
dispatchOnMain(self) { instance in
112-
guard instance.current != state else { return }
113-
let old = instance.current
114-
if let animation = instance.animation {
115-
withAnimation(animation) {
116-
instance.objectThrottled.send(state)
117-
}
118-
} else {
119-
instance.objectThrottled.send(state)
147+
nonisolated override public func newState(state: T) {
148+
dispatchToMainActor(self, state: state) { instance, newState in
149+
instance.applyThrottledStateUpdate(newState)
150+
}
151+
}
152+
153+
fileprivate func applyThrottledStateUpdate(_ state: T) {
154+
guard current != state else { return }
155+
if let animation = animation {
156+
withAnimation(animation) {
157+
objectThrottled.send(state)
120158
}
121-
instance.objectDidChange.send(DidChangeSubject(old: old, new: instance.current))
159+
} else {
160+
objectThrottled.send(state)
122161
}
123162
}
124163

125164
private let objectThrottled = PassthroughSubject<T, Never>()
126165
}
127166

167+
@MainActor
128168
public class ObservableDerivedState<Original: Hashable, Derived: Hashable>: ObservableObject,
129169
StoreSubscriber, ObservableSubscription
130170
{
@@ -151,43 +191,41 @@ public class ObservableDerivedState<Original: Hashable, Derived: Hashable>: Obse
151191

152192
func subscribe() {
153193
guard !isSubscribed else { return }
154-
// Capture selector directly to avoid retaining self in the transform closure
155194
let selector = self.selector
156-
dispatchOnMain(self) { instance in
157-
guard !instance.isSubscribed else { return }
158-
Context.currentContext.store.subscribe(
159-
instance, transform: { $0.select(selector) })
160-
instance.isSubscribed = true
161-
}
195+
Context.currentContext.store.subscribe(self, transform: { $0.select(selector) })
196+
isSubscribed = true
162197
}
163198

164199
func unsubscribe() {
165200
guard isSubscribed else { return }
166-
dispatchOnMain(self) { instance in
167-
guard instance.isSubscribed else { return }
168-
Context.currentContext.store.unsubscribe(instance)
169-
instance.isSubscribed = false
170-
}
201+
Context.currentContext.store.unsubscribe(self)
202+
isSubscribed = false
171203
}
172204

173205
deinit {
174-
unsubscribe()
206+
// Note: deinit is nonisolated even for @MainActor classes.
207+
// ReSwift's SubscriptionBox holds a weak reference to subscribers,
208+
// so cleanup happens automatically when this object is deallocated.
209+
}
210+
211+
nonisolated public func newState(state original: Original) {
212+
dispatchToMainActor(self, state: original) { instance, newState in
213+
instance.applyStateUpdate(newState)
214+
}
175215
}
176216

177-
public func newState(state original: Original) {
178-
dispatchOnMain(self) { instance in
179-
let old = instance.current
180-
instance.objectWillChange.send(ChangeSubject(old: old, new: instance.current))
181-
182-
if let animation = instance.animation {
183-
withAnimation(animation) {
184-
instance.current = instance.transform(original)
185-
}
186-
} else {
187-
instance.current = instance.transform(original)
217+
fileprivate func applyStateUpdate(_ original: Original) {
218+
let old = current
219+
objectWillChange.send(ChangeSubject(old: old, new: current))
220+
221+
if let animation = animation {
222+
withAnimation(animation) {
223+
current = transform(original)
188224
}
189-
instance.objectDidChange.send(ChangeSubject(old: old, new: instance.current))
225+
} else {
226+
current = transform(original)
190227
}
228+
objectDidChange.send(ChangeSubject(old: old, new: current))
191229
}
192230

193231
public let objectWillChange = PassthroughSubject<ChangeSubject<Derived>, Never>()
@@ -215,22 +253,27 @@ public class ObservableDerivedThrottledState<Original: Hashable, Derived: Hashab
215253
objectThrottled
216254
.throttle(for: .milliseconds(throttleInMs), scheduler: DispatchQueue.main, latest: true)
217255
.sink { [weak self] in
218-
self?.current = transform($0)
256+
guard let self = self else { return }
257+
let old = self.current
258+
self.current = transform($0)
259+
self.objectDidChange.send(ChangeSubject(old: old, new: self.current))
219260
}
220261
.store(in: &cancellables)
221262
}
222263

223-
override public func newState(state original: Original) {
224-
dispatchOnMain(self) { instance in
225-
let old = instance.current
226-
if let animation = instance.animation {
227-
withAnimation(animation) {
228-
instance.objectThrottled.send(original)
229-
}
230-
} else {
231-
instance.objectThrottled.send(original)
264+
nonisolated override public func newState(state original: Original) {
265+
dispatchToMainActor(self, state: original) { instance, newState in
266+
instance.applyThrottledStateUpdate(newState)
267+
}
268+
}
269+
270+
fileprivate func applyThrottledStateUpdate(_ original: Original) {
271+
if let animation = animation {
272+
withAnimation(animation) {
273+
objectThrottled.send(original)
232274
}
233-
instance.objectDidChange.send(ChangeSubject(old: old, new: instance.current))
275+
} else {
276+
objectThrottled.send(original)
234277
}
235278
}
236279

@@ -239,26 +282,30 @@ public class ObservableDerivedThrottledState<Original: Hashable, Derived: Hashab
239282

240283
extension Store where State == RowndState {
241284

285+
@MainActor
242286
public func subscribe<T>(
243287
select selector: @escaping (RowndState) -> (T), animation: SwiftUI.Animation? = nil
244288
) -> ObservableState<T> {
245289
ObservableState(select: selector, animation: animation)
246290
}
247291

292+
@MainActor
248293
public func subscribe<Original, Derived>(
249294
select selector: @escaping (RowndState) -> (Original),
250295
transform: @escaping (Original) -> Derived, animation: SwiftUI.Animation? = nil
251296
) -> ObservableDerivedState<Original, Derived> {
252297
ObservableDerivedState(select: selector, transform: transform, animation: animation)
253298
}
254299

300+
@MainActor
255301
public func subscribeThrottled<T>(
256302
select selector: @escaping (RowndState) -> (T), throttleInMs: Int = 350,
257303
animation: SwiftUI.Animation? = nil
258304
) -> ObservableThrottledState<T> {
259305
ObservableThrottledState(select: selector, animation: animation, throttleInMs: throttleInMs)
260306
}
261307

308+
@MainActor
262309
public func subscribeThrottled<Original, Derived>(
263310
select selector: @escaping (RowndState) -> (Original),
264311
transform: @escaping (Original) -> Derived, throttleInMs: Int = 350,

Sources/Rownd/Rownd.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,10 +95,10 @@ public class Rownd: NSObject {
9595
store.dispatch(UserData.fetch())
9696
store.dispatch(PasskeyData.fetchPasskeyRegistration())
9797
}
98-
}
9998

100-
InstantUsers(context: Context.currentContext)
101-
.tmpForceInstantUserConversionIfRequested()
99+
InstantUsers(context: Context.currentContext)
100+
.tmpForceInstantUserConversionIfRequested()
101+
}
102102

103103
return state
104104
}

Sources/Rownd/framework/InstantUsers.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import Combine
99

10+
@MainActor
1011
class InstantUsers {
1112
private let context: Context
1213
private var cancellables = Set<AnyCancellable>()

Sources/Rownd/framework/RowndEvent.swift

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,23 +30,36 @@ public protocol RowndEventHandlerDelegate: AnyObject {
3030
func handleRowndEvent(_ event: RowndEvent)
3131
}
3232

33+
@MainActor
3334
class RowndEventEmitter {
3435
static private var cancellables = Set<AnyCancellable>()
3536
static func emit(_ event: RowndEvent) {
3637
if event.event == .signInCompleted {
38+
// Check if the access token is already valid — if so, fire immediately
39+
// to avoid a race where the Combine subscription misses a value that's
40+
// already settled before the sink is attached.
41+
let authState = Context.currentContext.store.state.auth
42+
if authState.isAccessTokenValid {
43+
Self.notifyListeners(event)
44+
return
45+
}
46+
47+
// Token not yet valid — subscribe and wait for it
3748
let subscription = Context.currentContext.store.subscribe { $0.auth.isAccessTokenValid }
3849
subscription.$current.sink { isAccessTokenValid in
3950
if isAccessTokenValid {
4051
subscription.unsubscribe()
41-
Context.currentContext.eventListeners.forEach { listener in
42-
listener.handleRowndEvent(event)
43-
}
52+
Self.notifyListeners(event)
4453
}
4554
}.store(in: &Self.cancellables)
4655
} else {
47-
Context.currentContext.eventListeners.forEach { listener in
48-
listener.handleRowndEvent(event)
49-
}
56+
Self.notifyListeners(event)
57+
}
58+
}
59+
60+
private static func notifyListeners(_ event: RowndEvent) {
61+
Context.currentContext.eventListeners.forEach { listener in
62+
listener.handleRowndEvent(event)
5063
}
5164
}
5265
}

Tests/RowndTests/ObservableStateTests.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import Testing
1414

1515
@testable import Rownd
1616

17+
@MainActor
1718
struct ObservableStateTests {
1819

1920
/// Tests that ObservableState can handle newState being called from background threads.

Tests/RowndTests/SubscriberMutationTests.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import Testing
55

66
@testable import Rownd
77

8+
@MainActor
89
struct SubscriberMutationTests {
910
@Test
1011
func rapidClockSyncAndObserverChurnDoesNotCrash() async throws {

0 commit comments

Comments
 (0)