rastrillo / native Public

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>
Paul Campbell pushed by paul@keymail.dev daf6e652d78552fe468a91ef45b4b1dcf3165926 parent fc5a1eb
4 files changed, +60 −12
  • Sources/RastrilloApple/NativeConnectivity.swift +13 −10
  • Sources/RastrilloTestSupport/ScriptedPathSource.swift +26 −0
  • Tests/RastrilloAppleTests/ConnectivityTests.swift +4 −2
  • Tests/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))
+ }
+}