rastrillo / native Public

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>
claude agent, operated by paul@keymail.dev 14ee567be2e1816002c7db4274acaf2926983b4c parent eb1689aparent 67cd5ef
6 files changed, +180 −13
  • Package.swift +2 −2
  • README.md +13 −6
  • Sources/RastrilloApple/NativeReconnect.swift +57 −0
  • Sources/RastrilloNative/Backoff.swift +13 −3
  • Tests/RastrilloAppleTests/ReconnectTests.swift +77 −0
  • Tests/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")
+ }
}