Merge branch 'fix/k2-retry-deadline' into main
Merge fix/k2-retry-deadline: NativeReconnect, Retry-After deadline, open-based stable lifetime
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6 files changed,
+180
−13
Package.swift+2 −2README.md+13 −6Sources/RastrilloApple/NativeReconnect.swift+57 −0Sources/RastrilloNative/Backoff.swift+13 −3Tests/RastrilloAppleTests/ReconnectTests.swift+77 −0Tests/RastrilloNativeTests/BackoffTests.swift+18 −2
CI failed — run details
diff --git a/Package.swift b/Package.swift| index 404051a..974a178 100644 |
| --- a/Package.swift |
| +++ b/Package.swift |
| @@ -12,11 +12,11 @@ let package = Package( |
| dependencies: [.package(url: "https://github.com/sparkle-project/Sparkle", exact: "2.9.6")], |
| targets: [ |
| .target(name: "RastrilloNative"), |
| - .target(name: "RastrilloApple"), |
| + .target(name: "RastrilloApple", dependencies: ["RastrilloNative"]), |
| .target(name: "RastrilloTestSupport", dependencies: ["RastrilloApple"]), |
| .target(name: "RastrilloMacUpdates", dependencies: [.product(name: "Sparkle", package: "Sparkle", condition: .when(platforms: [.macOS]))], resources: [.process("Resources")]), |
| .testTarget(name: "RastrilloMacUpdatesTests", dependencies: ["RastrilloMacUpdates"]), |
| - .testTarget(name: "RastrilloAppleTests", dependencies: ["RastrilloApple"]), |
| + .testTarget(name: "RastrilloAppleTests", dependencies: ["RastrilloApple", "RastrilloNative"]), |
| .testTarget(name: "RastrilloNativeTests", dependencies: ["RastrilloNative"]), |
| .testTarget(name: "RastrilloTestSupportTests", dependencies: ["RastrilloTestSupport", "RastrilloApple"]), |
| ] |
diff --git a/README.md b/README.md| index a8ecd69..6655f48 100644 |
| --- a/README.md |
| +++ b/README.md |
| @@ -117,13 +117,20 @@ 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:)` |
| +A stream loop uses `NativeReconnect(connectivity:)`: call `opened()` from the |
| +stream's `onOpen`, `failed(error)` when an attempt ends in an error, and |
| +`await pause()` before the next attempt. A server's Retry-After (up to 5 minutes) |
| +is a deadline nothing crosses, not even a connectivity change or foreground. |
| +Otherwise the pause waits out `Backoff` (in `RastrilloNative`: 0.5 s doubling to |
| +10 s with equal jitter), cut short and reset by a connectivity change, with no |
| +timer at all while the path is down. The backoff resets only after a stream that |
| +stayed open longer than 5 s, timed from `opened()`. Lower-level loops can call |
| +`connectivity.wait(upTo:)` and `Backoff.opened(at:)`/`ended(at:)` directly. |
| +`NativeOfflineBanner(origin:)` |
| is a thin bar shown while that origin is offline; pass durable wording only once |
| -edits survive a relaunch. |
| +edits survive a relaunch. The kit has no string catalog yet: apps pass their own |
| +localised copy. The catalog, reviewed in English and then translated into the |
| +package's 12 languages, is a requirement of K3, together with `SyncStatus`. |
| `tools/bundle-mac.py` includes SwiftPM resource bundles in `Contents/Resources` |
| and reads the minimum macOS version from the package manifest. A SwiftPM |
diff --git a/Sources/RastrilloApple/NativeReconnect.swift b/Sources/RastrilloApple/NativeReconnect.swift| new file mode 100644 |
| index 0000000..79b9a4e |
| --- /dev/null |
| +++ b/Sources/RastrilloApple/NativeReconnect.swift |
| @@ -0,0 +1,57 @@ |
| +import Foundation |
| +import RastrilloNative |
| + |
| +/// The pause between attempts of a long-lived stream, so every app gets the |
| +/// same rules: |
| +/// - a server's Retry-After is a deadline that nothing crosses, including a |
| +/// connectivity change or a return to the foreground; |
| +/// - otherwise it waits out `Backoff`, cut short (and reset) by a |
| +/// connectivity change, with no timer at all while the path is down; |
| +/// - the backoff resets after a stream that stayed open longer than 5 s, |
| +/// timed from `opened()`, never from the start of the attempt. |
| +/// |
| +/// Call `opened()` from the stream's `onOpen`, `failed(_:)` with the error |
| +/// that ended an attempt, and `pause()` before the next attempt. |
| +@MainActor public final class NativeReconnect { |
| + private let connectivity: NativeConnectivity |
| + private var backoff: Backoff |
| + private let now: @MainActor () -> TimeInterval |
| + private var notBefore: TimeInterval? |
| + |
| + public init(connectivity: NativeConnectivity, backoff: Backoff = Backoff(), |
| + now: @escaping @MainActor () -> TimeInterval = { ProcessInfo.processInfo.systemUptime }) { |
| + self.connectivity = connectivity |
| + self.backoff = backoff |
| + self.now = now |
| + } |
| + |
| + /// The stream was accepted. |
| + public func opened() { backoff.opened(at: now()) } |
| + |
| + /// Records a server's Retry-After as a deadline, capped at 5 minutes. |
| + public func failed(_ error: Error) { |
| + guard case NativeClientError.rateLimited(let after?) = error, after > 0 else { return } |
| + let deadline = now() + min(after, Backoff.retryAfterCap) |
| + notBefore = max(notBefore ?? 0, deadline) |
| + } |
| + |
| + /// Waits before the next attempt. Returns the backoff delay chosen, for tests. |
| + @discardableResult |
| + public func pause() async -> TimeInterval { |
| + let start = now() |
| + backoff.ended(at: start) |
| + let delay = backoff.next() |
| + var remaining = delay |
| + if let deadline = notBefore, deadline > start { |
| + // Only the server's deadline is uninterruptible; cancellation still ends it. |
| + try? await Task.sleep(nanoseconds: UInt64((deadline - start) * 1_000_000_000)) |
| + remaining -= now() - start |
| + } |
| + notBefore = nil |
| + if Task.isCancelled { return delay } |
| + if remaining > 0 || !connectivity.isPathSatisfied { |
| + if await connectivity.wait(upTo: max(0, remaining)) { backoff.reset() } |
| + } |
| + return delay |
| + } |
| +} |
diff --git a/Sources/RastrilloNative/Backoff.swift b/Sources/RastrilloNative/Backoff.swift| index 65423bb..44a476d 100644 |
| --- a/Sources/RastrilloNative/Backoff.swift |
| +++ b/Sources/RastrilloNative/Backoff.swift |
| @@ -35,8 +35,18 @@ public struct Backoff: Sendable { |
| /// 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() } |
| + /// A stream was accepted: its `onOpen` ran. `at` is any monotonic time in |
| + /// seconds, such as `ProcessInfo.processInfo.systemUptime`. |
| + public mutating func opened(at time: TimeInterval) { openedAt = time } |
| + |
| + /// The attempt is over. Resets only if a stream opened and then stayed |
| + /// open longer than `stableConnection`. A slow refusal, a connect that |
| + /// timed out, or an access check after a failure never opened anything, |
| + /// so however long it took it is not evidence the server was working. |
| + public mutating func ended(at time: TimeInterval) { |
| + if let openedAt, time - openedAt > Self.stableConnection { reset() } |
| + openedAt = nil |
| } |
| + |
| + private var openedAt: TimeInterval? |
| } |
diff --git a/Tests/RastrilloAppleTests/ReconnectTests.swift b/Tests/RastrilloAppleTests/ReconnectTests.swift| new file mode 100644 |
| index 0000000..16df05d |
| --- /dev/null |
| +++ b/Tests/RastrilloAppleTests/ReconnectTests.swift |
| @@ -0,0 +1,77 @@ |
| +import Foundation |
| +import XCTest |
| +import RastrilloNative |
| +@testable import RastrilloApple |
| + |
| +@MainActor final class ReconnectTests: XCTestCase { |
| + private func connectivity(_ status: NativePath.Status = .satisfied) async -> (NativeConnectivity, FakePathSource) { |
| + let source = FakePathSource() |
| + let connectivity = NativeConnectivity(source: source) |
| + source.send(status) |
| + for _ in 0..<200 where connectivity.path == nil { try? await Task.sleep(nanoseconds: 5_000_000) } |
| + return (connectivity, source) |
| + } |
| + |
| + /// A Retry-After of 1 s holds even when the app is foregrounded and the |
| + /// path changes during it: waking must not cross the server's deadline. |
| + func testRetryAfterSurvivesConnectivityWakes() async { |
| + let (connectivity, source) = await connectivity() |
| + let reconnect = NativeReconnect(connectivity: connectivity, backoff: Backoff(random: { $0.lowerBound })) |
| + reconnect.failed(NativeClientError.rateLimited(retryAfter: 1)) |
| + let start = Date() |
| + var finished: Date? |
| + let pausing = Task { @MainActor in await reconnect.pause(); finished = Date() } |
| + try? await Task.sleep(nanoseconds: 150_000_000) |
| + connectivity.foregrounded() |
| + source.send(.requiresConnection) |
| + try? await Task.sleep(nanoseconds: 100_000_000) |
| + source.send(.satisfied) |
| + try? await Task.sleep(nanoseconds: 300_000_000) |
| + XCTAssertNil(finished, "A wake crossed the server's Retry-After") |
| + await pausing.value |
| + XCTAssertGreaterThanOrEqual(finished?.timeIntervalSince(start) ?? 0, 0.95) |
| + } |
| + |
| + /// After the deadline, the pause still waits for the path if it is down. |
| + func testRetryAfterThenWaitsForThePath() async { |
| + let (connectivity, source) = await connectivity() |
| + let reconnect = NativeReconnect(connectivity: connectivity) |
| + reconnect.failed(NativeClientError.rateLimited(retryAfter: 0.2)) |
| + source.send(.unsatisfied) |
| + var finished = false |
| + let pausing = Task { @MainActor in await reconnect.pause(); finished = true } |
| + try? await Task.sleep(nanoseconds: 1_000_000_000) |
| + XCTAssertFalse(finished, "It retried with the path down") |
| + source.send(.satisfied) |
| + await pausing.value |
| + XCTAssertTrue(finished) |
| + } |
| + |
| + /// The stable-stream clock starts at `opened()`: an attempt that spent |
| + /// longer than 5 s failing, and never opened, does not reset the backoff. |
| + func testStableLifetimeIsTimedFromOpen() async { |
| + let (connectivity, _) = await connectivity() |
| + var clock: TimeInterval = 0 |
| + let reconnect = NativeReconnect(connectivity: connectivity, backoff: Backoff(random: { $0.lowerBound }), now: { clock }) |
| + let first = await reconnect.pause() // 0.25 |
| + clock += 10 // a 10 s refusal, never opened |
| + let second = await reconnect.pause() |
| + XCTAssertEqual(second, first * 2, "A slow failure counted as a stable stream") |
| + reconnect.opened() |
| + clock += 6 // open for 6 s, then closed |
| + let third = await reconnect.pause() |
| + XCTAssertEqual(third, first, "A stream open longer than 5 s must reset the backoff") |
| + } |
| + |
| + /// Without a Retry-After, a wake still cuts the backoff short. |
| + func testWakeCutsAnOrdinaryBackoffShort() async { |
| + let (connectivity, _) = await connectivity() |
| + let reconnect = NativeReconnect(connectivity: connectivity, backoff: Backoff(random: { _ in 30 })) |
| + let start = Date() |
| + let pausing = Task { await reconnect.pause() } |
| + try? await Task.sleep(nanoseconds: 100_000_000) |
| + connectivity.foregrounded() |
| + _ = await pausing.value |
| + XCTAssertLessThan(Date().timeIntervalSince(start), 2) |
| + } |
| +} |
diff --git a/Tests/RastrilloNativeTests/BackoffTests.swift b/Tests/RastrilloNativeTests/BackoffTests.swift| index 76345f3..265bccc 100644 |
| --- a/Tests/RastrilloNativeTests/BackoffTests.swift |
| +++ b/Tests/RastrilloNativeTests/BackoffTests.swift |
| @@ -25,12 +25,28 @@ final class BackoffTests: XCTestCase { |
| func testResetsOnSuccessAndAfterAStableConnectionOnly() { |
| var backoff = Backoff(random: { $0.upperBound }) |
| _ = backoff.next(); _ = backoff.next(); _ = backoff.next() |
| - backoff.connectionEnded(after: 3) |
| + backoff.opened(at: 100) |
| + backoff.ended(at: 103) |
| XCTAssertEqual(backoff.next(), 4, "A connection that dropped quickly must keep backing off") |
| - backoff.connectionEnded(after: 6) |
| + backoff.opened(at: 200) |
| + backoff.ended(at: 206) |
| XCTAssertEqual(backoff.next(), 0.5) |
| _ = backoff.next() |
| backoff.reset() |
| XCTAssertEqual(backoff.next(), 0.5) |
| } |
| + |
| + /// The stable clock starts when the stream opens, not when the attempt |
| + /// starts: a slow refusal or a long access check must not count. |
| + func testAnAttemptThatNeverOpenedIsNeverStable() { |
| + var backoff = Backoff(random: { $0.upperBound }) |
| + _ = backoff.next(); _ = backoff.next() |
| + backoff.ended(at: 1_000) |
| + XCTAssertEqual(backoff.next(), 2, "An attempt that never opened reset the backoff") |
| + backoff.opened(at: 2_000) |
| + backoff.ended(at: 2_006) |
| + _ = backoff.next() |
| + backoff.ended(at: 9_000) |
| + XCTAssertEqual(backoff.next(), 1, "An earlier open was counted for a later attempt") |
| + } |
| } |