Tell callers why a wait ended; ship ScriptedPathSource
- NativeConnectivity.wait(upTo:) returns true when a connectivity change ended it, so a stream loop resets its Backoff on a path change or foreground (spec §3.4) instead of carrying an outage's delay into a restored network. It is @discardableResult. - RastrilloTestSupport gains ScriptedPathSource, a NativePathSource a test drives by hand, so apps can test their loops against path changes without the machine's real network.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4 files changed,
+60
−12
Sources/RastrilloApple/NativeConnectivity.swift+13 −10Sources/RastrilloTestSupport/ScriptedPathSource.swift+26 −0Tests/RastrilloAppleTests/ConnectivityTests.swift+4 −2Tests/RastrilloTestSupportTests/ScriptedPathSourceTests.swift+17 −0
CI failed — run details
diff --git a/Sources/RastrilloApple/NativeConnectivity.swift b/Sources/RastrilloApple/NativeConnectivity.swift| index ce71828..4194a8e 100644 |
| --- a/Sources/RastrilloApple/NativeConnectivity.swift |
| +++ b/Sources/RastrilloApple/NativeConnectivity.swift |
| @@ -119,20 +119,23 @@ public final class NativeNetworkPathSource: NativePathSource, @unchecked Sendabl |
| public func foregrounded() { notify() } |
| - /// Returns after `delay` or on the next change, whichever is first. While |
| - /// the path is down it waits for the change only. Returns at once if the |
| - /// task is cancelled. |
| - public func wait(upTo delay: TimeInterval) async { |
| + /// Returns after `delay` or on the next change, whichever is first, and |
| + /// says which: true means connectivity changed, so a caller resets its |
| + /// backoff. While the path is down it waits for the change only. Returns |
| + /// false at once if the task is cancelled. |
| + @discardableResult |
| + public func wait(upTo delay: TimeInterval) async -> Bool { |
| let changed = changes |
| if !isPathSatisfied { |
| - for await _ in changed { break } |
| - return |
| + for await _ in changed { return true } |
| + return false |
| } |
| - await withTaskGroup(of: Void.self) { group in |
| - group.addTask { try? await Task.sleep(nanoseconds: UInt64(max(0, delay) * 1_000_000_000)) } |
| - group.addTask { for await _ in changed { break } } |
| - await group.next() |
| + return await withTaskGroup(of: Bool.self) { group in |
| + group.addTask { try? await Task.sleep(nanoseconds: UInt64(max(0, delay) * 1_000_000_000)); return false } |
| + group.addTask { for await _ in changed { return true }; return false } |
| + let first = await group.next() ?? false |
| group.cancelAll() |
| + return first |
| } |
| } |
diff --git a/Sources/RastrilloTestSupport/ScriptedPathSource.swift b/Sources/RastrilloTestSupport/ScriptedPathSource.swift| new file mode 100644 |
| index 0000000..8397efc |
| --- /dev/null |
| +++ b/Sources/RastrilloTestSupport/ScriptedPathSource.swift |
| @@ -0,0 +1,26 @@ |
| +import Foundation |
| +import RastrilloApple |
| + |
| +/// A `NativePathSource` a test drives by hand: `NativeConnectivity(source:)` |
| +/// then sees exactly the path changes the test sends, and none from the |
| +/// machine it runs on. |
| +public final class ScriptedPathSource: NativePathSource, @unchecked Sendable { |
| + private let lock = NSLock() |
| + private var update: (@Sendable (NativePath) -> Void)? |
| + private var initial: NativePath? |
| + |
| + /// `initial` is delivered as soon as a connectivity starts, as NWPathMonitor does. |
| + public init(_ initial: NativePath.Status? = nil) { self.initial = initial.map { NativePath(status: $0) } } |
| + |
| + public func start(_ update: @escaping @Sendable (NativePath) -> Void) { |
| + let first: NativePath? = lock.withLock { self.update = update; return initial } |
| + if let first { update(first) } |
| + } |
| + |
| + public func cancel() { lock.withLock { update = nil } } |
| + |
| + public func send(_ status: NativePath.Status, isExpensive: Bool = false, isConstrained: Bool = false) { |
| + let path = NativePath(status: status, isExpensive: isExpensive, isConstrained: isConstrained) |
| + lock.withLock { update }?(path) |
| + } |
| +} |
diff --git a/Tests/RastrilloAppleTests/ConnectivityTests.swift b/Tests/RastrilloAppleTests/ConnectivityTests.swift| index d9373cd..bccd5c9 100644 |
| --- a/Tests/RastrilloAppleTests/ConnectivityTests.swift |
| +++ b/Tests/RastrilloAppleTests/ConnectivityTests.swift |
| @@ -84,12 +84,14 @@ final class FakePathSource: NativePathSource, @unchecked Sendable { |
| let waiting = Task { await connectivity.wait(upTo: 30) } |
| try? await Task.sleep(nanoseconds: 50_000_000) |
| connectivity.foregrounded() |
| - await waiting.value |
| + let changed = await waiting.value |
| XCTAssertLessThan(Date().timeIntervalSince(start), 2, "A change must cut the backoff short") |
| + XCTAssertTrue(changed, "The caller must learn that connectivity changed, to reset its backoff") |
| let timed = Date() |
| - await connectivity.wait(upTo: 0.05) |
| + let elapsed = await connectivity.wait(upTo: 0.05) |
| XCTAssertLessThan(Date().timeIntervalSince(timed), 1, "The delay must elapse on its own") |
| + XCTAssertFalse(elapsed, "An elapsed delay is not a change") |
| } |
| /// The battery rule: while the path is down, no timer runs at all. |
diff --git a/Tests/RastrilloTestSupportTests/ScriptedPathSourceTests.swift b/Tests/RastrilloTestSupportTests/ScriptedPathSourceTests.swift| new file mode 100644 |
| index 0000000..ddb157d |
| --- /dev/null |
| +++ b/Tests/RastrilloTestSupportTests/ScriptedPathSourceTests.swift |
| @@ -0,0 +1,17 @@ |
| +import Foundation |
| +import XCTest |
| +import RastrilloApple |
| +import RastrilloTestSupport |
| + |
| +@MainActor final class ScriptedPathSourceTests: XCTestCase { |
| + func testDrivesConnectivity() async { |
| + let source = ScriptedPathSource(.satisfied) |
| + let connectivity = NativeConnectivity(source: source) |
| + let origin = URL(string: "https://docs.example")! |
| + for _ in 0..<200 where connectivity.path == nil { try? await Task.sleep(nanoseconds: 5_000_000) } |
| + XCTAssertEqual(connectivity.path?.status, .satisfied, "The initial path was not delivered") |
| + source.send(.unsatisfied) |
| + for _ in 0..<200 where !connectivity.isOffline(origin) { try? await Task.sleep(nanoseconds: 5_000_000) } |
| + XCTAssertTrue(connectivity.isOffline(origin)) |
| + } |
| +} |