Skip to content

Commit 3be4a8b

Browse files
committed
Turn ForegroundPOSCatalogSyncDispatcher into actor to guard against threading issues
1 parent b015712 commit 3be4a8b

File tree

3 files changed

+102
-46
lines changed

3 files changed

+102
-46
lines changed

WooCommerce/Classes/Tools/BackgroundTasks/ForegroundPOSCatalogSyncDispatcher.swift

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import Experiments
77
/// Periodically syncs POS catalog while the app is in the foreground.
88
/// Triggers `catalogCoordinator.performSmartSync()` every hour when active.
99
///
10-
final class ForegroundPOSCatalogSyncDispatcher {
10+
final actor ForegroundPOSCatalogSyncDispatcher {
1111
private enum Constants {
1212
static let syncInterval: TimeInterval = 60 * 60 // 1 hour
1313
static let initialSyncDelay: TimeInterval = 5 // 5 seconds after becoming active
@@ -19,7 +19,7 @@ final class ForegroundPOSCatalogSyncDispatcher {
1919
private let timerProvider: DispatchTimerProviding
2020
private let featureFlagService: FeatureFlagService
2121
private let stores: StoresManager
22-
private let isAppActive: () -> Bool
22+
private let isAppActive: () async -> Bool
2323
private var observers: [NSObjectProtocol] = []
2424
private var timer: DispatchTimerProtocol?
2525

@@ -33,7 +33,7 @@ final class ForegroundPOSCatalogSyncDispatcher {
3333
timerProvider: DispatchTimerProviding = DefaultDispatchTimerProvider(),
3434
featureFlagService: FeatureFlagService = ServiceLocator.featureFlagService,
3535
stores: StoresManager = ServiceLocator.stores,
36-
isAppActive: @escaping () -> Bool = { UIApplication.shared.applicationState == .active }) {
36+
isAppActive: @escaping () async -> Bool = UIApplication.isApplicationActive) {
3737
self.interval = interval
3838
self.notificationCenter = notificationCenter
3939
self.timerProvider = timerProvider
@@ -42,11 +42,7 @@ final class ForegroundPOSCatalogSyncDispatcher {
4242
self.isAppActive = isAppActive
4343
}
4444

45-
deinit {
46-
stop()
47-
}
48-
49-
func start() {
45+
func start() async {
5046
guard featureFlagService.isFeatureFlagEnabled(.pointOfSaleLocalCatalogi1) else {
5147
return
5248
}
@@ -65,28 +61,34 @@ final class ForegroundPOSCatalogSyncDispatcher {
6561
object: nil,
6662
queue: .main
6763
) { [weak self] _ in
68-
self?.startTimer()
64+
Task {
65+
await self?.startTimer()
66+
}
6967
}
7068

7169
let backgroundObserver = notificationCenter.addObserver(
7270
forName: UIApplication.didEnterBackgroundNotification,
7371
object: nil,
7472
queue: .main
7573
) { [weak self] _ in
76-
self?.stopTimer()
74+
Task {
75+
await self?.stopTimer()
76+
}
7777
}
7878

7979
let defaultSiteObserver = notificationCenter.addObserver(
8080
forName: .StoresManagerDidUpdateDefaultSite,
8181
object: nil,
8282
queue: .main
8383
) { [weak self] _ in
84-
self?.stop()
84+
Task {
85+
await self?.stop()
86+
}
8587
}
8688

8789
observers = [activeObserver, backgroundObserver, defaultSiteObserver]
8890

89-
if isAppActive() {
91+
if await isAppActive() {
9092
startTimer()
9193
}
9294

@@ -112,7 +114,9 @@ final class ForegroundPOSCatalogSyncDispatcher {
112114
let timer = timerProvider.makeTimer(queue: queue)
113115
timer.schedule(deadline: .now() + Constants.initialSyncDelay, repeating: interval, leeway: .seconds(Constants.leeway))
114116
timer.setEventHandler { [weak self] in
115-
self?.performSync()
117+
Task {
118+
await self?.performSync()
119+
}
116120
}
117121
timer.resume()
118122
self.timer = timer
@@ -211,3 +215,9 @@ struct DefaultDispatchTimerProvider: DispatchTimerProviding {
211215
DispatchSource.makeTimerSource(queue: queue).asTimer()
212216
}
213217
}
218+
219+
extension UIApplication {
220+
static func isApplicationActive() async -> Bool {
221+
shared.applicationState == .active
222+
}
223+
}

WooCommerce/Classes/ViewRelated/MainTabBarController.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -722,7 +722,7 @@ private extension MainTabBarController {
722722
viewModel.loadHubMenuTabBadge()
723723

724724
// Begin foreground synchronization if POS tab becomes visible
725-
isPOSTabVisible ? posSyncDispatcher.start() : posSyncDispatcher.stop()
725+
await isPOSTabVisible ? posSyncDispatcher.start() : posSyncDispatcher.stop()
726726
}
727727
}
728728

WooCommerce/WooCommerceTests/Tools/ForegroundPOSCatalogSyncDispatcherTests.swift

Lines changed: 78 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import Testing
22
import Foundation
33
import Yosemite
44
@testable import WooCommerce
5+
import UIKit
56

67
@MainActor
78
struct ForegroundPOSCatalogSyncDispatcherTests {
@@ -29,7 +30,7 @@ struct ForegroundPOSCatalogSyncDispatcherTests {
2930
@Test
3031
func start_whenFeatureFlagEnabled_createsTimer() async throws {
3132
// When
32-
sut.start()
33+
await sut.start()
3334

3435
// Then
3536
#expect(timerProvider.createdTimers.count == 1)
@@ -39,18 +40,10 @@ struct ForegroundPOSCatalogSyncDispatcherTests {
3940
@Test
4041
func start_whenFeatureFlagDisabled_doesNotCreateTimer() async throws {
4142
// Given
42-
let disabledFlags = MockFeatureFlagService()
43-
disabledFlags.isFeatureFlagEnabledReturnValue[.pointOfSaleLocalCatalogi1] = false
44-
let dispatcher = ForegroundPOSCatalogSyncDispatcher(
45-
notificationCenter: notificationCenter,
46-
timerProvider: timerProvider,
47-
featureFlagService: disabledFlags,
48-
stores: stores,
49-
isAppActive: { true }
50-
)
43+
featureFlags.isFeatureFlagEnabledReturnValue[.pointOfSaleLocalCatalogi1] = false
5144

5245
// When
53-
dispatcher.start()
46+
await sut.start()
5447

5548
// Then
5649
#expect(timerProvider.createdTimers.isEmpty)
@@ -62,7 +55,7 @@ struct ForegroundPOSCatalogSyncDispatcherTests {
6255
let coordinator = MockPOSCatalogSyncCoordinator()
6356
stores.testPOSCatalogSyncCoordinator = coordinator
6457

65-
sut.start()
58+
await sut.start()
6659
let timer = try #require(timerProvider.createdTimers.first)
6760

6861
// When - fire timer and wait for sync to be called
@@ -82,19 +75,10 @@ struct ForegroundPOSCatalogSyncDispatcherTests {
8275
func timerFires_whenNoDefaultStore_skipsSync() async throws {
8376
// Given
8477
let coordinator = MockPOSCatalogSyncCoordinator()
85-
let noStoreSession = SessionManager.testingInstance
86-
noStoreSession.setStoreId(nil)
87-
let noStoreStores = MockStoresManager(sessionManager: noStoreSession)
88-
noStoreStores.testPOSCatalogSyncCoordinator = coordinator
89-
let dispatcher = ForegroundPOSCatalogSyncDispatcher(
90-
notificationCenter: notificationCenter,
91-
timerProvider: timerProvider,
92-
featureFlagService: featureFlags,
93-
stores: noStoreStores,
94-
isAppActive: { true }
95-
)
78+
sessionManager.setStoreId(nil)
79+
stores.testPOSCatalogSyncCoordinator = coordinator
9680

97-
dispatcher.start()
81+
await sut.start()
9882
let timer = try #require(timerProvider.createdTimers.first)
9983

10084
// When
@@ -111,7 +95,7 @@ struct ForegroundPOSCatalogSyncDispatcherTests {
11195
coordinator.performSmartSyncResult = .failure(POSCatalogSyncError.syncAlreadyInProgress(siteID: 123))
11296
stores.testPOSCatalogSyncCoordinator = coordinator
11397

114-
sut.start()
98+
await sut.start()
11599
let timer = try #require(timerProvider.createdTimers.first)
116100

117101
// When - fire timer and wait for sync to be called
@@ -129,13 +113,13 @@ struct ForegroundPOSCatalogSyncDispatcherTests {
129113
@Test
130114
func start_whenSiteChanges_resetsAndRestartsSync() async throws {
131115
// Given - start with site 123
132-
sut.start()
116+
await sut.start()
133117
let firstTimer = try #require(timerProvider.createdTimers.first)
134118
#expect(timerProvider.createdTimers.count == 1)
135119

136120
// When - change site to 456 and start again
137121
sessionManager.setStoreId(456)
138-
sut.start()
122+
await sut.start()
139123

140124
// Then - old timer cancelled, new timer created
141125
#expect(firstTimer.isCancelled == true)
@@ -160,18 +144,76 @@ struct ForegroundPOSCatalogSyncDispatcherTests {
160144
@Test
161145
func defaultSiteNotification_stopsDispatcher() async throws {
162146
// Given
163-
sut.start()
147+
await sut.start()
164148
let timer = try #require(timerProvider.createdTimers.first)
165149
#expect(timer.isResumed == true)
166150

167151
// When - default site changes
168-
notificationCenter.post(name: .StoresManagerDidUpdateDefaultSite, object: nil)
152+
await withCheckedContinuation { continuation in
153+
timer.onCancelled = { continuation.resume() }
169154

170-
// Then - dispatcher stops
171-
#expect(timer.isCancelled == true)
155+
notificationCenter.post(name: .StoresManagerDidUpdateDefaultSite, object: nil)
156+
}
172157

173158
// Verify dispatcher can restart after notification
174-
sut.start()
159+
await sut.start()
160+
#expect(timerProvider.createdTimers.count == 2)
161+
#expect(timerProvider.createdTimers.last?.isResumed == true)
162+
}
163+
164+
@Test
165+
func didBecomeActiveNotification_startsTimer() async throws {
166+
// Given - dispatcher started but app not active initially
167+
let inactiveDispatcher = ForegroundPOSCatalogSyncDispatcher(
168+
notificationCenter: notificationCenter,
169+
timerProvider: timerProvider,
170+
featureFlagService: featureFlags,
171+
stores: stores,
172+
isAppActive: { false }
173+
)
174+
await inactiveDispatcher.start()
175+
176+
// Then - no timer created yet
177+
#expect(timerProvider.createdTimers.isEmpty)
178+
179+
// When - app becomes active, wait for timer to be created
180+
await withCheckedContinuation { continuation in
181+
timerProvider.onTimerCreated = { continuation.resume() }
182+
183+
notificationCenter.post(name: UIApplication.didBecomeActiveNotification, object: nil)
184+
}
185+
186+
// Then - timer should be created and started
187+
#expect(timerProvider.createdTimers.count == 1)
188+
#expect(timerProvider.createdTimers.first?.isResumed == true)
189+
}
190+
191+
@Test
192+
func didEnterBackgroundNotification_stopsTimer() async throws {
193+
// Given
194+
await sut.start()
195+
let timer = try #require(timerProvider.createdTimers.first)
196+
#expect(timer.isResumed == true)
197+
#expect(timer.isCancelled == false)
198+
199+
// When - app enters background
200+
await withCheckedContinuation { continuation in
201+
timer.onCancelled = { continuation.resume() }
202+
203+
notificationCenter.post(name: UIApplication.didEnterBackgroundNotification, object: nil)
204+
}
205+
206+
// Then - timer should be cancelled
207+
#expect(timer.isCancelled == true)
208+
209+
// When - app becomes active again, wait for new timer
210+
await withCheckedContinuation { continuation in
211+
timerProvider.onTimerCreated = { continuation.resume() }
212+
213+
notificationCenter.post(name: UIApplication.didBecomeActiveNotification, object: nil)
214+
}
215+
216+
// Then - new timer should be created
175217
#expect(timerProvider.createdTimers.count == 2)
176218
#expect(timerProvider.createdTimers.last?.isResumed == true)
177219
}
@@ -182,6 +224,7 @@ struct ForegroundPOSCatalogSyncDispatcherTests {
182224
private final class MockDispatchTimer: DispatchTimerProtocol {
183225
private(set) var isResumed = false
184226
private(set) var isCancelled = false
227+
var onCancelled: () -> Void = { }
185228
private var eventHandler: (() -> Void)?
186229

187230
func schedule(deadline: DispatchTime, repeating: Double, leeway: DispatchTimeInterval) {
@@ -198,6 +241,7 @@ private final class MockDispatchTimer: DispatchTimerProtocol {
198241

199242
func cancel() {
200243
isCancelled = true
244+
onCancelled()
201245
}
202246

203247
/// Test helper to manually fire the timer
@@ -208,10 +252,12 @@ private final class MockDispatchTimer: DispatchTimerProtocol {
208252

209253
private final class MockDispatchTimerProvider: DispatchTimerProviding {
210254
private(set) var createdTimers: [MockDispatchTimer] = []
255+
var onTimerCreated: () -> Void = { }
211256

212257
func makeTimer(queue: DispatchQueue) -> DispatchTimerProtocol {
213258
let timer = MockDispatchTimer()
214259
createdTimers.append(timer)
260+
onTimerCreated()
215261
return timer
216262
}
217263
}

0 commit comments

Comments
 (0)