rastrillo / native Public

Merge branch 'feat/k2-connectivity-backoff' into main

Merge K2: NativeConnectivity, Backoff, offline banner

Reviewed by GPT-6-Astra via Codex (#7): approve with nits. Hub CI is red
for infrastructure reasons only (no macOS runner); local make ci passes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev eb1689a0ae803c08a43bb365b06369fb30472130 parent 2986f51parent daf6e65
9 files changed, +468 −4
  • README.md +17 −0
  • Sources/RastrilloApple/NativeConnection.swift +9 −4
  • Sources/RastrilloApple/NativeConnectivity.swift +151 −0
  • Sources/RastrilloApple/NativeOfflineBanner.swift +33 −0
  • Sources/RastrilloNative/Backoff.swift +42 −0
  • Sources/RastrilloTestSupport/ScriptedPathSource.swift +26 −0
  • Tests/RastrilloAppleTests/ConnectivityTests.swift +137 −0
  • Tests/RastrilloNativeTests/BackoffTests.swift +36 −0
  • Tests/RastrilloTestSupportTests/ScriptedPathSourceTests.swift +17 −0

CI failed — run details

diff --git a/README.md b/README.md
index 00ab329..a8ecd69 100644
--- a/README.md
+++ b/README.md
@@ -108,6 +108,23 @@ from a script: a status and body, offline, a hang until cancelled, or "commit,
then lose the response". Scripted streams are framed by the same byte-level parser
as `NativeHTTP`. Use it in test targets in place of fixture servers.
+### Connectivity and backoff (in-progress primitive)
+
+`NativeConnectivity.shared` (one per process) combines `NWPathMonitor`'s path
+with what real traffic proves: every client `NativeConnection` makes reports its
+responses and offline-class failures, so `serverReachable[origin]` is learned
+without a probe. `isOffline(origin)` is true while the path is down, or while it
+is up and that server just failed to answer. `changes` fires on a path change,
+on a server becoming reachable and on return to the foreground.
+
+Loops that retry call `await connectivity.wait(upTo: backoff.next())`. It returns
+when the delay elapses or connectivity changes, and while the path is down it
+runs no timer at all. `Backoff` (in `RastrilloNative`) is 0.5 s doubling to 10 s
+with equal jitter, honours Retry-After up to 5 minutes, and for a stream resets
+only after a connection that lived longer than 5 s. `NativeOfflineBanner(origin:)`
+is a thin bar shown while that origin is offline; pass durable wording only once
+edits survive a relaunch.
+
`tools/bundle-mac.py` includes SwiftPM resource bundles in `Contents/Resources`
and reads the minimum macOS version from the package manifest. A SwiftPM
consumer with resources should first locate its resource bundle with
diff --git a/Sources/RastrilloApple/NativeConnection.swift b/Sources/RastrilloApple/NativeConnection.swift
index dfc7ce0..76ee1ec 100644
--- a/Sources/RastrilloApple/NativeConnection.swift
+++ b/Sources/RastrilloApple/NativeConnection.swift
@@ -52,7 +52,7 @@ import Security
serverAddress = account.origin.absoluteString
activeAccountID = account.id
defaults.set(account.id, forKey: "native.activeAccount")
- client = NativeHTTP(origin: account.origin, credential: credential)
+ client = NativeHTTP(origin: account.origin, credential: credential, observer: connectivity.observer(for: account.origin))
}
/// Adopts a session returned by a consumer's proof-bound sign-in protocol.
@@ -71,7 +71,7 @@ import Security
defaults.set(account.id, forKey: "native.activeAccount")
defaults.set(origin.absoluteString, forKey: "native.server")
serverAddress = origin.absoluteString
- client = NativeHTTP(origin: origin, credential: credential)
+ client = NativeHTTP(origin: origin, credential: credential, observer: connectivity.observer(for: origin))
}
public func handleOpenURL(_ url: URL) {
@@ -96,7 +96,12 @@ import Security
}
}
- public init(defaultServer: String, callbackScheme: String? = nil, defaults: UserDefaults = .standard) {
+ /// Every client this connection makes reports its outcomes here.
+ public let connectivity: NativeConnectivity
+
+ public init(defaultServer: String, callbackScheme: String? = nil, defaults: UserDefaults = .standard,
+ connectivity: NativeConnectivity? = nil) {
+ self.connectivity = connectivity ?? .shared
self.defaults = defaults
self.callbackScheme = callbackScheme ?? "works.oficina.\(URL(string: defaultServer)?.host?.components(separatedBy: ".").first ?? "app")"
serverAddress = defaults.string(forKey: "native.server") ?? defaultServer
@@ -124,7 +129,7 @@ import Security
task = Task {
do {
let origin = try NativeHTTP.origin(serverAddress)
- let http = NativeHTTP(origin: origin)
+ let http = NativeHTTP(origin: origin, observer: connectivity.observer(for: origin))
var random = [UInt8](repeating: 0, count: 32)
guard SecRandomCopyBytes(kSecRandomDefault, random.count, &random) == errSecSuccess else {
throw NativeClientError.message("Could not start secure sign-in.")
diff --git a/Sources/RastrilloApple/NativeConnectivity.swift b/Sources/RastrilloApple/NativeConnectivity.swift
new file mode 100644
index 0000000..4194a8e
--- /dev/null
+++ b/Sources/RastrilloApple/NativeConnectivity.swift
@@ -0,0 +1,151 @@
+import Foundation
+import Network
+import Observation
+#if canImport(UIKit)
+import UIKit
+#elseif canImport(AppKit)
+import AppKit
+#endif
+
+/// The network path as `NWPathMonitor` reports it.
+public struct NativePath: Sendable, Equatable {
+ public enum Status: Sendable, Equatable { case satisfied, unsatisfied, requiresConnection }
+ public var status: Status
+ public var isExpensive: Bool
+ public var isConstrained: Bool
+ public init(status: Status, isExpensive: Bool = false, isConstrained: Bool = false) {
+ self.status = status; self.isExpensive = isExpensive; self.isConstrained = isConstrained
+ }
+}
+
+/// Where path updates come from: `NativeNetworkPathSource` in apps, a fake in tests.
+public protocol NativePathSource: Sendable {
+ func start(_ update: @escaping @Sendable (NativePath) -> Void)
+ func cancel()
+}
+
+public final class NativeNetworkPathSource: NativePathSource, @unchecked Sendable {
+ private let monitor = NWPathMonitor()
+ private let queue = DispatchQueue(label: "net.amadan.rastrillo.native.path")
+ public init() {}
+ public func start(_ update: @escaping @Sendable (NativePath) -> Void) {
+ monitor.pathUpdateHandler = { path in
+ let status: NativePath.Status
+ switch path.status {
+ case .satisfied: status = .satisfied
+ case .requiresConnection: status = .requiresConnection
+ default: status = .unsatisfied
+ }
+ update(NativePath(status: status, isExpensive: path.isExpensive, isConstrained: path.isConstrained))
+ }
+ monitor.start(queue: queue)
+ }
+ public func cancel() { monitor.cancel() }
+}
+
+/// Whether this device can reach a server, learned without probing: the path
+/// from `NWPathMonitor`, and reachability per origin from real traffic
+/// (`NativeHTTP`'s outcome observer). The Docs and Sheets streams are the
+/// heartbeat; other apps learn on their next request. Nothing pings.
+///
+/// Loops that retry wait on `wait(upTo:)`, which returns when the delay
+/// elapses or connectivity changes, whichever is first, and never runs a
+/// timer while the path is down: a restored network is acted on at once, and
+/// an offline phone makes no timed attempts.
+@MainActor @Observable public final class NativeConnectivity {
+ /// One per process. Starts `NWPathMonitor` on first use.
+ public static let shared = NativeConnectivity(source: NativeNetworkPathSource())
+
+ /// nil until the first update; unknown is treated as satisfied, so a
+ /// slow first update never blocks a request that would have worked.
+ public private(set) var path: NativePath?
+ /// true after a response from that origin, false after an offline-class
+ /// failure, absent (unknown) after a path change.
+ public private(set) var serverReachable: [URL: Bool] = [:]
+
+ @ObservationIgnored private let source: NativePathSource
+ @ObservationIgnored private var subscribers: [UUID: AsyncStream<Void>.Continuation] = [:]
+ @ObservationIgnored private var foregroundObserver: NSObjectProtocol?
+
+ public init(source: NativePathSource) {
+ self.source = source
+ source.start { [weak self] path in Task { @MainActor in self?.pathChanged(path) } }
+ #if canImport(UIKit)
+ let name = UIApplication.didBecomeActiveNotification
+ #elseif canImport(AppKit)
+ let name = NSApplication.didBecomeActiveNotification
+ #endif
+ foregroundObserver = NotificationCenter.default.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in
+ Task { @MainActor in self?.foregrounded() }
+ }
+ }
+
+ deinit {
+ source.cancel()
+ if let foregroundObserver { NotificationCenter.default.removeObserver(foregroundObserver) }
+ }
+
+ public var isPathSatisfied: Bool { path.map { $0.status == .satisfied } ?? true }
+
+ /// The path is down, or it is up and that server was just unreachable.
+ public func isOffline(_ origin: URL) -> Bool { !isPathSatisfied || serverReachable[origin] == false }
+
+ /// Fires on a path change, on a server becoming reachable, and on return
+ /// to the foreground. Each access is a new subscription.
+ public var changes: AsyncStream<Void> {
+ let (stream, continuation) = AsyncStream<Void>.makeStream(bufferingPolicy: .bufferingNewest(1))
+ let id = UUID()
+ subscribers[id] = continuation
+ continuation.onTermination = { [weak self] _ in Task { @MainActor in self?.subscribers[id] = nil } }
+ return stream
+ }
+
+ public func record(_ outcome: NativeTransportOutcome, origin: URL) {
+ switch outcome {
+ case .responded:
+ let was = serverReachable[origin]
+ serverReachable[origin] = true
+ // A stream that opens must wake whatever is waiting to send (M2).
+ if was != true { notify() }
+ case .offline:
+ serverReachable[origin] = false
+ }
+ }
+
+ /// For `NativeHTTP(origin:credential:observer:)`.
+ public nonisolated func observer(for origin: URL) -> @Sendable (NativeTransportOutcome) -> Void {
+ { [weak self] outcome in Task { @MainActor in self?.record(outcome, origin: origin) } }
+ }
+
+ public func foregrounded() { notify() }
+
+ /// 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 { return true }
+ return false
+ }
+ 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
+ }
+ }
+
+ func pathChanged(_ new: NativePath) {
+ guard new != path else { return }
+ path = new
+ // What was learned about servers belonged to the old path.
+ serverReachable = [:]
+ notify()
+ }
+
+ private func notify() { for continuation in subscribers.values { continuation.yield() } }
+}
diff --git a/Sources/RastrilloApple/NativeOfflineBanner.swift b/Sources/RastrilloApple/NativeOfflineBanner.swift
new file mode 100644
index 0000000..e4bb586
--- /dev/null
+++ b/Sources/RastrilloApple/NativeOfflineBanner.swift
@@ -0,0 +1,33 @@
+import SwiftUI
+
+/// A thin inset bar shown while `origin` is offline. It says only what is
+/// true without a durable outbox: pass the durable wording ("Changes are saved
+/// on this device…") once the app's edits survive a relaunch. Entering the
+/// offline state is announced to assistive technologies; leaving it is not.
+public struct NativeOfflineBanner: View {
+ private let connectivity: NativeConnectivity
+ private let origin: URL
+ private let message: String
+
+ @MainActor public init(connectivity: NativeConnectivity? = nil, origin: URL,
+ message: String = "You’re offline. Waiting for a connection.") {
+ self.connectivity = connectivity ?? .shared; self.origin = origin; self.message = message
+ }
+
+ public var body: some View {
+ let offline = connectivity.isOffline(origin)
+ Group {
+ if offline {
+ Label(message, systemImage: "wifi.slash")
+ .font(.callout)
+ .frame(maxWidth: .infinity)
+ .padding(.horizontal, 12).padding(.vertical, 6)
+ .background(.bar)
+ .accessibilityElement(children: .combine)
+ }
+ }
+ .onChange(of: offline) { _, now in
+ if now { AccessibilityNotification.Announcement(message).post() }
+ }
+ }
+}
diff --git a/Sources/RastrilloNative/Backoff.swift b/Sources/RastrilloNative/Backoff.swift
new file mode 100644
index 0000000..65423bb
--- /dev/null
+++ b/Sources/RastrilloNative/Backoff.swift
@@ -0,0 +1,42 @@
+import Foundation
+
+/// Retry delays for sends and stream reconnects: 0.5 s doubling to 10 s, each
+/// with equal jitter (a random point in [d/2, d]), and Retry-After honoured up
+/// to 5 minutes. These are callscore's numbers, so a suite of apps retrying
+/// against one server spreads out instead of arriving in lockstep.
+///
+/// A delay is only a timer. It must never run while the network path is down:
+/// wait on `NativeConnectivity` instead, so an offline phone makes no timed
+/// attempts at all.
+public struct Backoff: Sendable {
+ public static let initial: TimeInterval = 0.5
+ public static let cap: TimeInterval = 10
+ public static let retryAfterCap: TimeInterval = 300
+ /// A connection that lived longer than this was working; the next failure
+ /// starts again from `initial` rather than where the last outage left off.
+ public static let stableConnection: TimeInterval = 5
+
+ private var attempt = 0
+ private let random: @Sendable (ClosedRange<Double>) -> Double
+
+ public init(random: @escaping @Sendable (ClosedRange<Double>) -> Double = { Double.random(in: $0) }) {
+ self.random = random
+ }
+
+ /// The delay before the next attempt. A server's Retry-After is a floor.
+ public mutating func next(retryAfter: TimeInterval? = nil) -> TimeInterval {
+ let delay = min(Self.cap, Self.initial * pow(2, Double(attempt)))
+ attempt = min(attempt + 1, 32)
+ let jittered = random((delay / 2)...delay)
+ guard let retryAfter, retryAfter > 0 else { return jittered }
+ return max(jittered, min(retryAfter, Self.retryAfterCap))
+ }
+
+ /// After a success, a path change or a return to the foreground.
+ public mutating func reset() { attempt = 0 }
+
+ /// For a stream: resets only if it stayed open long enough to have worked.
+ public mutating func connectionEnded(after lived: TimeInterval) {
+ if lived > Self.stableConnection { reset() }
+ }
+}
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
new file mode 100644
index 0000000..bccd5c9
--- /dev/null
+++ b/Tests/RastrilloAppleTests/ConnectivityTests.swift
@@ -0,0 +1,137 @@
+import Foundation
+import XCTest
+@testable import RastrilloApple
+
+final class FakePathSource: NativePathSource, @unchecked Sendable {
+ private let lock = NSLock()
+ private var update: (@Sendable (NativePath) -> Void)?
+ private(set) var cancelled = false
+ func start(_ update: @escaping @Sendable (NativePath) -> Void) { lock.withLock { self.update = update } }
+ func cancel() { lock.withLock { cancelled = true } }
+ func send(_ status: NativePath.Status) { lock.withLock { update }?(NativePath(status: status)) }
+}
+
+@MainActor private final class Counter {
+ var count = 0
+ var task: Task<Void, Never>?
+ init(_ connectivity: NativeConnectivity) {
+ let changes = connectivity.changes
+ task = Task { @MainActor [weak self] in for await _ in changes { self?.count += 1 } }
+ }
+}
+
+@MainActor private func settle(until condition: () -> Bool, seconds: Double = 2) async {
+ let deadline = Date().addingTimeInterval(seconds)
+ while !condition() && Date() < deadline { try? await Task.sleep(nanoseconds: 5_000_000) }
+}
+
+@MainActor final class ConnectivityTests: XCTestCase {
+ let origin = URL(string: "https://docs.example")!
+ let other = URL(string: "https://sheets.example")!
+
+ func testOfflineFromThePathOrFromAFailedRequest() async {
+ let source = FakePathSource()
+ let connectivity = NativeConnectivity(source: source)
+ XCTAssertFalse(connectivity.isOffline(origin), "An unknown path must not block requests")
+ source.send(.unsatisfied)
+ await settle { connectivity.path?.status == .unsatisfied }
+ XCTAssertTrue(connectivity.isOffline(origin))
+ XCTAssertTrue(connectivity.isOffline(other))
+ source.send(.satisfied)
+ await settle { connectivity.isPathSatisfied }
+ XCTAssertFalse(connectivity.isOffline(origin))
+
+ connectivity.record(.offline(.cannotConnectToHost), origin: origin)
+ XCTAssertTrue(connectivity.isOffline(origin), "The path is up but that server did not answer")
+ XCTAssertFalse(connectivity.isOffline(other), "One unreachable server says nothing about another")
+ connectivity.record(.responded(status: 503), origin: origin)
+ XCTAssertFalse(connectivity.isOffline(origin), "Any response proves the server is reachable")
+
+ connectivity.record(.offline(.timedOut), origin: origin)
+ source.send(.requiresConnection)
+ await settle { connectivity.path?.status == .requiresConnection }
+ source.send(.satisfied)
+ await settle { connectivity.isPathSatisfied }
+ XCTAssertNil(connectivity.serverReachable[origin], "A path change must forget what the old path learned")
+ }
+
+ func testChangesFireOnPathReachabilityAndForeground() async {
+ let source = FakePathSource()
+ let connectivity = NativeConnectivity(source: source)
+ let counter = Counter(connectivity)
+ defer { counter.task?.cancel() }
+ source.send(.satisfied)
+ await settle { counter.count == 1 }
+ XCTAssertEqual(counter.count, 1, "A path change must fire")
+ connectivity.record(.responded(status: 200), origin: origin)
+ await settle { counter.count == 2 }
+ XCTAssertEqual(counter.count, 2, "A server becoming reachable must wake waiting senders")
+ connectivity.record(.responded(status: 200), origin: origin)
+ connectivity.record(.offline(.timedOut), origin: origin)
+ try? await Task.sleep(nanoseconds: 50_000_000)
+ XCTAssertEqual(counter.count, 2, "Repeated responses and failures are not changes")
+ connectivity.foregrounded()
+ await settle { counter.count == 3 }
+ XCTAssertEqual(counter.count, 3)
+ }
+
+ func testWaitEndsEarlyWhenConnectivityChanges() async {
+ let source = FakePathSource()
+ let connectivity = NativeConnectivity(source: source)
+ source.send(.satisfied)
+ await settle { connectivity.path != nil }
+ let start = Date()
+ let waiting = Task { await connectivity.wait(upTo: 30) }
+ try? await Task.sleep(nanoseconds: 50_000_000)
+ connectivity.foregrounded()
+ 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()
+ 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.
+ func testNoTimedAttemptsWhileThePathIsDown() async {
+ let source = FakePathSource()
+ let connectivity = NativeConnectivity(source: source)
+ source.send(.unsatisfied)
+ await settle { connectivity.path != nil }
+ var returned = false
+ let waiting = Task { @MainActor in await connectivity.wait(upTo: 0.01); returned = true }
+ try? await Task.sleep(nanoseconds: 300_000_000)
+ XCTAssertFalse(returned, "A backoff delay elapsed while the path was down")
+ source.send(.satisfied)
+ await settle { returned }
+ XCTAssertTrue(returned, "A restored path must end the wait at once")
+ waiting.cancel()
+ }
+
+ func testNativeHTTPReportsThroughTheObserver() async throws {
+ let fd = socket(AF_INET, SOCK_STREAM, 0)
+ var address = sockaddr_in()
+ address.sin_family = sa_family_t(AF_INET)
+ address.sin_addr.s_addr = inet_addr("127.0.0.1")
+ var length = socklen_t(MemoryLayout<sockaddr_in>.size)
+ _ = withUnsafeMutablePointer(to: &address) {
+ $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { Darwin.bind(fd, $0, length) == 0 ? getsockname(fd, $0, &length) : -1 }
+ }
+ close(fd)
+ let closed = URL(string: "http://127.0.0.1:\(UInt16(bigEndian: address.sin_port))")!
+ let connectivity = NativeConnectivity(source: FakePathSource())
+ let client = NativeHTTP(origin: closed, observer: connectivity.observer(for: closed))
+ _ = try? await client.responseDetails("/")
+ await settle { connectivity.serverReachable[closed] != nil }
+ XCTAssertEqual(connectivity.serverReachable[closed], false)
+ XCTAssertTrue(connectivity.isOffline(closed))
+ }
+
+ func testNetworkPathSourceDeliversAPath() async {
+ let connectivity = NativeConnectivity(source: NativeNetworkPathSource())
+ await settle(until: { connectivity.path != nil }, seconds: 5)
+ XCTAssertNotNil(connectivity.path, "NWPathMonitor never reported a path")
+ }
+}
diff --git a/Tests/RastrilloNativeTests/BackoffTests.swift b/Tests/RastrilloNativeTests/BackoffTests.swift
new file mode 100644
index 0000000..76345f3
--- /dev/null
+++ b/Tests/RastrilloNativeTests/BackoffTests.swift
@@ -0,0 +1,36 @@
+import Foundation
+import XCTest
+import RastrilloNative
+
+final class BackoffTests: XCTestCase {
+ func testDoublesFromHalfASecondToTenWithEqualJitter() {
+ var high = Backoff(random: { $0.upperBound })
+ XCTAssertEqual((0..<7).map { _ in high.next() }, [0.5, 1, 2, 4, 8, 10, 10])
+ var low = Backoff(random: { $0.lowerBound })
+ XCTAssertEqual((0..<6).map { _ in low.next() }, [0.25, 0.5, 1, 2, 4, 5])
+ var real = Backoff()
+ for expected in [0.5, 1.0, 2.0, 4.0] {
+ let delay = real.next()
+ XCTAssertTrue((expected / 2...expected).contains(delay), "\(delay) outside [\(expected / 2), \(expected)]")
+ }
+ }
+
+ func testRetryAfterIsAFloorCappedAtFiveMinutes() {
+ var backoff = Backoff(random: { $0.upperBound })
+ XCTAssertEqual(backoff.next(retryAfter: 30), 30)
+ XCTAssertEqual(backoff.next(retryAfter: 3600), 300)
+ XCTAssertEqual(backoff.next(retryAfter: 0.1), 2, "A short Retry-After must not undercut the backoff")
+ }
+
+ func testResetsOnSuccessAndAfterAStableConnectionOnly() {
+ var backoff = Backoff(random: { $0.upperBound })
+ _ = backoff.next(); _ = backoff.next(); _ = backoff.next()
+ backoff.connectionEnded(after: 3)
+ XCTAssertEqual(backoff.next(), 4, "A connection that dropped quickly must keep backing off")
+ backoff.connectionEnded(after: 6)
+ XCTAssertEqual(backoff.next(), 0.5)
+ _ = backoff.next()
+ backoff.reset()
+ XCTAssertEqual(backoff.next(), 0.5)
+ }
+}
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))
+ }
+}