diff --git a/ios/Tests/ConcurrencyLimiterTests.swift b/ios/Tests/ConcurrencyLimiterTests.swift index d2e7ecc..c09db40 100644 --- a/ios/Tests/ConcurrencyLimiterTests.swift +++ b/ios/Tests/ConcurrencyLimiterTests.swift @@ -72,4 +72,45 @@ final class ConcurrencyLimiterTests: XCTestCase { let finalPeak = await counter.peak XCTAssertEqual(finalPeak, 1) } + + // Cold start: a process started by the OS has not run Dart initialize(), so the limiter it + // builds comes from the persisted value. Simulated with a throwaway defaults suite. + func test_issue_84_persisted_limit_survives_a_fresh_limiter() async throws { + let suite = "nwm.test.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + + XCTAssertEqual(ConcurrencyLimiter.persistedLimit(defaults: defaults), 4, "nothing persisted -> default") + + await ConcurrencyLimiter(max: 4).configure(max: 2, persist: true, defaults: defaults) + XCTAssertEqual(ConcurrencyLimiter.persistedLimit(defaults: defaults), 2) + + // "Process restart": brand new limiter built only from what was persisted. + let cold = ConcurrencyLimiter(max: ConcurrencyLimiter.persistedLimit(defaults: defaults)) + let counter = Counter() + try await withThrowingTaskGroup(of: Void.self) { group in + for _ in 0..<12 { + group.addTask { + try await cold.acquire() + await counter.enter() + try await Task.sleep(nanoseconds: 10_000_000) + await counter.leave() + await cold.release() + } + } + try await group.waitForAll() + } + let peak = await counter.peak + XCTAssertEqual(peak, 2, "restored limit must be enforced without any initialize() call") + } + + func test_issue_84_invalid_persisted_values_fall_back_to_default() { + let suite = "nwm.test.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + for bad: Any in [0, -3, "7", 2.5] { + defaults.set(bad, forKey: ConcurrencyLimiter.preferenceKey) + XCTAssertEqual(ConcurrencyLimiter.persistedLimit(defaults: defaults), 4, "bad value \(bad)") + } + } } diff --git a/ios/native_workmanager/Sources/native_workmanager/utils/ConcurrencyLimiter.swift b/ios/native_workmanager/Sources/native_workmanager/utils/ConcurrencyLimiter.swift index c9f2522..fb4168b 100644 --- a/ios/native_workmanager/Sources/native_workmanager/utils/ConcurrencyLimiter.swift +++ b/ios/native_workmanager/Sources/native_workmanager/utils/ConcurrencyLimiter.swift @@ -4,9 +4,16 @@ import Foundation /// Reconfiguration preserves active permits. Cancellation removes queued waiters. actor ConcurrencyLimiter { static let preferenceKey = "native_workmanager.maxConcurrentTasks" - static let shared = ConcurrencyLimiter( - max: UserDefaults.standard.object(forKey: preferenceKey) as? Int ?? 4 - ) + static let defaultLimit = 4 + + /// The limit persisted by the last `initialize()`, or the default. This is what a process + /// started cold by the OS (BGTask, no Dart `initialize()` yet) will use. + static func persistedLimit(defaults: UserDefaults = .standard) -> Int { + guard let value = defaults.object(forKey: preferenceKey) as? Int, value > 0 else { return defaultLimit } + return value + } + + static let shared = ConcurrencyLimiter(max: persistedLimit()) private var max: Int private var running = 0 @@ -14,10 +21,10 @@ actor ConcurrencyLimiter { init(max: Int) { self.max = Swift.max(1, max) } - func configure(max: Int, persist: Bool = false) { + func configure(max: Int, persist: Bool = false, defaults: UserDefaults = .standard) { precondition(max > 0) self.max = max - if persist { UserDefaults.standard.set(max, forKey: Self.preferenceKey) } + if persist { defaults.set(max, forKey: Self.preferenceKey) } drain() }