Pace the Sheets live stream with NativeReconnect; label it Offline truthfully
Review of #38 (Astra): - P2: a connectivity wake crossed the server's Retry-After. The kit's NativeReconnect (rastrillo/native fix/k2-retry-deadline, 67cd5ef, pinned here) keeps it as a deadline no wake crosses. - P2: the stable-stream clock started before the stream opened, so a slow refusal reset the backoff. It now starts in onOpen. - P2: a real HTTP failure to an unreachable server left "Reconnecting" showing. The label now reads "Offline" when the failure was offline or connectivity already knows the origin is unreachable (the observer's record of this failure lands asynchronously, so the error decides). Tests: Retry-After: 1 holds through a foreground wake; attempts that each fail after 10 s (injected clock) without opening keep backing off; a real NativeHTTP at a closed port leaves the label "Offline".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
8 files changed,
+80
−18
native/apple/Package.resolved+1 −1native/apple/Package.swift+2 −2native/apple/Sources/SheetsKit/SheetLive.swift+13 −12native/apple/Sources/SheetsKit/SheetModel.swift+2 −0native/apple/Tests/SheetsKitTests/LiveConnectivityTests.swift+59 −0native/ios/project.yml+1 −1native/macos/SheetsMacStore.xcodeproj/project.pbxproj+1 −1native/macos/project.yml+1 −1
CI passed — run details
CI log
amadan: .amadan/timeout: this job's budget is 15m0s (this runner allows up to 30m0s) === vet === CGO_ENABLED=0 go vet ./... === fmt === === build === CGO_ENABLED=0 go build ./... === migrations === rastrillo migration check rastrillo migration check: models and migrations agree === go-test === CGO_ENABLED=0 go test -count=1 ./... ? amadan.net/oficina/sheets/cmd/sheets [no test files] ok amadan.net/oficina/sheets/internal/guardstest 0.073s ok amadan.net/oficina/sheets/internal/sheets 10.015s ok amadan.net/oficina/sheets/internal/sheets/calc 0.009s ok amadan.net/oficina/sheets/internal/sheets/format 0.002s ok amadan.net/oficina/sheets/internal/sheets/live 0.053s ok amadan.net/oficina/sheets/internal/sheets/seal 0.003s ok amadan.net/oficina/sheets/internal/sheets/sealtest 0.003s ok amadan.net/oficina/sheets/internal/sheets/store 2.744s ok amadan.net/oficina/sheets/internal/sheets/xlsx 0.286s ? amadan.net/oficina/sheets/tools/gencorpus [no test files] === race === go test -race -count=1 ./internal/sheets/store/... ./internal/sheets/live/... ok amadan.net/oficina/sheets/internal/sheets/store 45.405s ok amadan.net/oficina/sheets/internal/sheets/live 1.059s
diff --git a/native/apple/Package.resolved b/native/apple/Package.resolved| index 0f27aff..cd36c8d 100644 |
| --- a/native/apple/Package.resolved |
| +++ b/native/apple/Package.resolved |
| @@ -6,7 +6,7 @@ |
| "kind" : "remoteSourceControl", |
| "location" : "https://amadan.net/rastrillo/native", |
| "state" : { |
| - "revision" : "daf6e652d78552fe468a91ef45b4b1dcf3165926" |
| + "revision" : "67cd5efe420f7c813254022598b7328a555781ef" |
| } |
| }, |
| { |
diff --git a/native/apple/Package.swift b/native/apple/Package.swift| index 37fa99c..8d6aeef 100644 |
| --- a/native/apple/Package.swift |
| +++ b/native/apple/Package.swift |
| @@ -3,12 +3,12 @@ import PackageDescription |
| import Foundation |
| let sharedNative: Package.Dependency = ProcessInfo.processInfo.environment["OFICINA_NATIVE_PACKAGE_PATH"].map { .package(name: "native", path: $0) } |
| - ?? .package(url: "https://amadan.net/rastrillo/native", revision: "daf6e652d78552fe468a91ef45b4b1dcf3165926") |
| + ?? .package(url: "https://amadan.net/rastrillo/native", revision: "67cd5efe420f7c813254022598b7328a555781ef") |
| let package = Package(name: "SheetsNative", defaultLocalization: "en", platforms: [.macOS(.v26), .iOS(.v26)], |
| products: [.library(name:"SheetsKit",targets:["SheetsKit"]), .executable(name:"SheetsMac",targets:["SheetsMac"]), .executable(name:"SheetsReview",targets:["SheetsReview"])], |
| dependencies:[sharedNative], |
| - targets:[.target(name:"SheetsKit",dependencies:[.product(name:"RastrilloApple",package:"native"),.product(name:"RastrilloNative",package:"native")], resources: [.process("Resources")]), |
| + targets:[.target(name:"SheetsKit",dependencies:[.product(name:"RastrilloApple",package:"native")], resources: [.process("Resources")]), |
| .executableTarget(name:"SheetsMac",dependencies:[.product(name: "RastrilloMacUpdates", package: "native", condition: .when(platforms: [.macOS])), "SheetsKit",.product(name:"RastrilloApple",package:"native")],path:"Sources/SheetsApp"), |
| .executableTarget(name:"SheetsReview",dependencies:["SheetsKit",.product(name:"RastrilloApple",package:"native")]), |
| .testTarget(name:"SheetsKitTests",dependencies:["SheetsKit",.product(name:"RastrilloTestSupport",package:"native")]), |
diff --git a/native/apple/Sources/SheetsKit/SheetLive.swift b/native/apple/Sources/SheetsKit/SheetLive.swift| index 6fb2e9d..9319dc3 100644 |
| --- a/native/apple/Sources/SheetsKit/SheetLive.swift |
| +++ b/native/apple/Sources/SheetsKit/SheetLive.swift |
| @@ -1,16 +1,17 @@ |
| import Foundation |
| import RastrilloApple |
| -import RastrilloNative |
| @MainActor extension SheetModel { |
| func watch() async { |
| guard page != nil else { return } |
| let source = grid |
| - var backoff = Backoff() |
| + // Paces reconnects: a server's Retry-After is a deadline no wake crosses; |
| + // otherwise back off, cut short by a connectivity change, with no timer |
| + // while the path is down. The backoff resets only after a stream that |
| + // stayed open for 5 s, timed from onOpen. |
| + let reconnect = NativeReconnect(connectivity: connectivity, now: reconnectClock) |
| while !Task.isCancelled && grid == source { |
| live = connectivity.isOffline(client.origin) ? "Offline" : "Connecting" |
| - let opened = ContinuousClock.now |
| - var retryAfter: TimeInterval? |
| do { |
| // Subscribe before fetching the snapshot in onOpen; buffered deltas |
| // then reconcile by version once the initial fetch finishes. |
| @@ -18,6 +19,7 @@ import RastrilloNative |
| try await client.stream( |
| source + "/stream", |
| onOpen: { [weak self] in |
| + await reconnect.opened() |
| await self?.streamOpened(source: source) |
| } |
| ) { [weak self] line in |
| @@ -27,17 +29,16 @@ import RastrilloNative |
| } |
| } catch is CancellationError { return } catch { |
| guard !Task.isCancelled, grid == source else { return } |
| - if case NativeClientError.rateLimited(let after) = error { retryAfter = after } |
| - live = "Reconnecting" |
| + reconnect.failed(error) |
| + // The failure itself says whether this server was unreachable; the |
| + // connectivity record of it arrives asynchronously. |
| + var offline = connectivity.isOffline(client.origin) |
| + if case NativeClientError.offline = error { offline = true } |
| + live = offline ? "Offline" : "Reconnecting" |
| } |
| if live == "Access changed" || live == "Paused" { return } |
| - // A fixed sleep here reconnected two seconds after the network came back |
| - // and retried on a timer while it was gone. Back off instead, cut short |
| - // by any connectivity change, with no timer at all while the path is down. |
| - let lived = opened.duration(to: .now) |
| - backoff.connectionEnded(after: Double(lived.components.seconds) + Double(lived.components.attoseconds) / 1e18) |
| if !connectivity.isPathSatisfied { live = "Offline" } |
| - if await connectivity.wait(upTo: backoff.next(retryAfter: retryAfter)) { backoff.reset() } |
| + await reconnect.pause() |
| if Task.isCancelled { return } |
| } |
| } |
diff --git a/native/apple/Sources/SheetsKit/SheetModel.swift b/native/apple/Sources/SheetsKit/SheetModel.swift| index c3f9015..db3587e 100644 |
| --- a/native/apple/Sources/SheetsKit/SheetModel.swift |
| +++ b/native/apple/Sources/SheetsKit/SheetModel.swift |
| @@ -37,6 +37,8 @@ import RastrilloApple |
| /// The live stream waits on this between attempts, so a restored network |
| /// reconnects at once and an offline device makes no timed attempts. |
| let connectivity: NativeConnectivity |
| + /// Monotonic seconds for the live stream's pacing; tests substitute a fake. |
| + var reconnectClock: @MainActor () -> TimeInterval = { ProcessInfo.processInfo.systemUptime } |
| init(client: NativeTransport, connectivity: NativeConnectivity? = nil) { |
| self.client = client |
| self.connectivity = connectivity ?? .shared |
diff --git a/native/apple/Tests/SheetsKitTests/LiveConnectivityTests.swift b/native/apple/Tests/SheetsKitTests/LiveConnectivityTests.swift| index 98abd29..90e3535 100644 |
| --- a/native/apple/Tests/SheetsKitTests/LiveConnectivityTests.swift |
| +++ b/native/apple/Tests/SheetsKitTests/LiveConnectivityTests.swift |
| @@ -63,3 +63,62 @@ private func streams(_ transport: ScriptedTransport) -> Int { transport.calls.fi |
| #expect(early >= 2, "The first retry waited longer than the first backoff step") |
| #expect(early <= 3, "Retries did not back off") |
| } |
| + |
| +/// A 429 with Retry-After: 1 holds the live stream off for a second even when |
| +/// the app is foregrounded during it. |
| +@MainActor @Test func liveStreamRetryAfterSurvivesAWake() async throws { |
| + let connectivity = NativeConnectivity(source: ScriptedPathSource(.satisfied)) |
| + let server = sheetServer(stream: .respond(status: 429, headers: ["Retry-After": "1"])) |
| + let (_, task) = try watching(server, connectivity) |
| + defer { task.cancel() } |
| + await until(2) { streams(server) >= 1 } |
| + let refused = Date() |
| + try await Task.sleep(for: .milliseconds(200)) |
| + connectivity.foregrounded() |
| + try await Task.sleep(for: .milliseconds(500)) |
| + #expect(streams(server) == 1, "A foreground wake crossed the server's Retry-After") |
| + await until(2) { streams(server) >= 2 } |
| + #expect(Date().timeIntervalSince(refused) >= 0.95) |
| +} |
| + |
| +private final class FakeClock: @unchecked Sendable { |
| + private let lock = NSLock() |
| + private var value: TimeInterval = 1_000 |
| + var now: TimeInterval { lock.withLock { value } } |
| + func advance(_ seconds: TimeInterval) { lock.withLock { value += seconds } } |
| +} |
| + |
| +/// Each attempt fails after 10 s (by the model's clock) without the stream |
| +/// ever opening. That is not a stable stream: the backoff keeps growing, so |
| +/// at most three attempts in 1.6 s. |
| +@MainActor @Test func liveStreamSlowFailuresThatNeverOpenedKeepBackingOff() async throws { |
| + let clock = FakeClock() |
| + let connectivity = NativeConnectivity(source: ScriptedPathSource(.satisfied)) |
| + let server = ScriptedTransport { call in |
| + if call.kind == .stream { clock.advance(10) } |
| + return .respond(status: 500) |
| + } |
| + let model = SheetModel(client: server, connectivity: connectivity) |
| + model.reconnectClock = { clock.now } |
| + model.page = try JSONDecoder().decode(SheetPage.self, from: pageJSON) |
| + let task = Task { await model.watch() } |
| + defer { task.cancel() } |
| + await until(2) { streams(server) >= 1 } |
| + try await Task.sleep(for: .milliseconds(1600)) |
| + #expect(streams(server) <= 3, "A slow failure that never opened reset the backoff") |
| +} |
| + |
| +/// A real request to a server that is not there leaves the label "Offline", |
| +/// not "Reconnecting", even though the device's path is fine. |
| +@MainActor @Test func liveLabelIsOfflineWhenTheServerIsUnreachable() async throws { |
| + let connectivity = NativeConnectivity(source: ScriptedPathSource(.satisfied)) |
| + let closed = URL(string: "http://127.0.0.1:9")! |
| + let client = NativeHTTP(origin: closed, observer: connectivity.observer(for: closed)) |
| + let model = SheetModel(client: client, connectivity: connectivity) |
| + model.page = try JSONDecoder().decode(SheetPage.self, from: pageJSON) |
| + let task = Task { await model.watch() } |
| + defer { task.cancel() } |
| + await until(3) { model.live != "Connecting" } |
| + try await Task.sleep(for: .milliseconds(100)) |
| + #expect(model.live == "Offline") |
| +} |
diff --git a/native/ios/project.yml b/native/ios/project.yml| index 79ec71c..9444610 100644 |
| --- a/native/ios/project.yml |
| +++ b/native/ios/project.yml |
| @@ -8,7 +8,7 @@ packages: |
| path: ../apple |
| RastrilloNative: |
| url: https://amadan.net/rastrillo/native |
| - revision: daf6e652d78552fe468a91ef45b4b1dcf3165926 |
| + revision: 67cd5efe420f7c813254022598b7328a555781ef |
| settings: |
| base: |
| SWIFT_VERSION: '6.0' |
diff --git a/native/macos/SheetsMacStore.xcodeproj/project.pbxproj b/native/macos/SheetsMacStore.xcodeproj/project.pbxproj| index a4818d6..7fc7932 100644 |
| --- a/native/macos/SheetsMacStore.xcodeproj/project.pbxproj |
| +++ b/native/macos/SheetsMacStore.xcodeproj/project.pbxproj |
| @@ -370,7 +370,7 @@ |
| repositoryURL = "https://amadan.net/rastrillo/native"; |
| requirement = { |
| kind = revision; |
| - revision = daf6e652d78552fe468a91ef45b4b1dcf3165926; |
| + revision = 67cd5efe420f7c813254022598b7328a555781ef; |
| }; |
| }; |
| /* End XCRemoteSwiftPackageReference section */ |
diff --git a/native/macos/project.yml b/native/macos/project.yml| index 6f60805..66f363c 100644 |
| --- a/native/macos/project.yml |
| +++ b/native/macos/project.yml |
| @@ -7,7 +7,7 @@ packages: |
| path: ../apple |
| RastrilloNative: |
| url: https://amadan.net/rastrillo/native |
| - revision: daf6e652d78552fe468a91ef45b4b1dcf3165926 |
| + revision: 67cd5efe420f7c813254022598b7328a555781ef |
| settings: |
| base: |
| SWIFT_VERSION: '6.0' |