Pace the native live stream with NativeConnectivity and NativeReconnect (offline K2)
The fixed 2 s reconnect loop is gone. The stream waits for connectivity (no attempts while the path is down; reconnects under a second after it returns), honours Retry-After as a deadline that wakes can't cross, and resets its backoff only after a stream has opened and stayed open more than 5 s. It shows the shared offline banner. Pins rastrillo/native main 14ee567. Reviewed by GPT-6-Astra via Codex; findings fixed in the kit (rastrillo/native #8) and checked by the coordinating session. The Sheets live label reads Offline when the origin is unreachable. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Squashed from feat/k2-connectivity into main.
10 files changed,
+162
−11
native/apple/Package.resolved+1 −1native/apple/Package.swift+2 −2native/apple/Sources/SheetsKit/Resources/en.lproj/Mac.strings+1 −0native/apple/Sources/SheetsKit/SheetLive.swift+17 −3native/apple/Sources/SheetsKit/SheetModel.swift+10 −2native/apple/Sources/SheetsKit/SheetsWorkspace.swift+4 −0native/apple/Tests/SheetsKitTests/LiveConnectivityTests.swift+124 −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.084s ok amadan.net/oficina/sheets/internal/sheets 11.078s ok amadan.net/oficina/sheets/internal/sheets/calc 0.010s ok amadan.net/oficina/sheets/internal/sheets/format 0.003s ok amadan.net/oficina/sheets/internal/sheets/live 0.054s ok amadan.net/oficina/sheets/internal/sheets/seal 0.003s ok amadan.net/oficina/sheets/internal/sheets/sealtest 0.004s ok amadan.net/oficina/sheets/internal/sheets/store 2.830s ok amadan.net/oficina/sheets/internal/sheets/xlsx 0.298s ? 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 46.489s ok amadan.net/oficina/sheets/internal/sheets/live 1.060s
diff --git a/native/apple/Package.resolved b/native/apple/Package.resolved| index 140b7c4..b361d78 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" : "2a5d07b2c8d3dfb3fd8526dc7e1c802f97865c67" |
| + "revision" : "14ee567be2e1816002c7db4274acaf2926983b4c" |
| } |
| }, |
| { |
diff --git a/native/apple/Package.swift b/native/apple/Package.swift| index 7b882c9..2252239 100644 |
| --- a/native/apple/Package.swift |
| +++ b/native/apple/Package.swift |
| @@ -3,7 +3,7 @@ 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: "2a5d07b2c8d3dfb3fd8526dc7e1c802f97865c67") |
| + ?? .package(url: "https://amadan.net/rastrillo/native", revision: "14ee567be2e1816002c7db4274acaf2926983b4c") |
| 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"])], |
| @@ -11,5 +11,5 @@ let package = Package(name: "SheetsNative", defaultLocalization: "en", platforms |
| 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"]), |
| + .testTarget(name:"SheetsKitTests",dependencies:["SheetsKit",.product(name:"RastrilloTestSupport",package:"native")]), |
| .testTarget(name:"SheetsWindowTests",dependencies:["SheetsMac",.product(name:"RastrilloApple",package:"native")])]) |
diff --git a/native/apple/Sources/SheetsKit/Resources/en.lproj/Mac.strings b/native/apple/Sources/SheetsKit/Resources/en.lproj/Mac.strings| index b14897d..0d9f837 100644 |
| --- a/native/apple/Sources/SheetsKit/Resources/en.lproj/Mac.strings |
| +++ b/native/apple/Sources/SheetsKit/Resources/en.lproj/Mac.strings |
| @@ -70,3 +70,4 @@ |
| "Search spreadsheets" = "Search spreadsheets"; |
| "The server did not confirm the resize." = "The server did not confirm the resize."; |
| +"You’re offline. Waiting for a connection." = "You’re offline. Waiting for a connection."; |
diff --git a/native/apple/Sources/SheetsKit/SheetLive.swift b/native/apple/Sources/SheetsKit/SheetLive.swift| index 45de52d..9319dc3 100644 |
| --- a/native/apple/Sources/SheetsKit/SheetLive.swift |
| +++ b/native/apple/Sources/SheetsKit/SheetLive.swift |
| @@ -1,11 +1,17 @@ |
| import Foundation |
| +import RastrilloApple |
| @MainActor extension SheetModel { |
| func watch() async { |
| guard page != nil else { return } |
| let source = grid |
| + // 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 = "Connecting" |
| + live = connectivity.isOffline(client.origin) ? "Offline" : "Connecting" |
| do { |
| // Subscribe before fetching the snapshot in onOpen; buffered deltas |
| // then reconcile by version once the initial fetch finishes. |
| @@ -13,6 +19,7 @@ import Foundation |
| try await client.stream( |
| source + "/stream", |
| onOpen: { [weak self] in |
| + await reconnect.opened() |
| await self?.streamOpened(source: source) |
| } |
| ) { [weak self] line in |
| @@ -22,10 +29,17 @@ import Foundation |
| } |
| } catch is CancellationError { return } catch { |
| guard !Task.isCancelled, grid == source else { return } |
| - 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 } |
| - do { try await Task.sleep(for: .seconds(2)) } catch { return } |
| + if !connectivity.isPathSatisfied { live = "Offline" } |
| + await reconnect.pause() |
| + if Task.isCancelled { return } |
| } |
| } |
| private func streamOpened(source: String) async { |
diff --git a/native/apple/Sources/SheetsKit/SheetModel.swift b/native/apple/Sources/SheetsKit/SheetModel.swift| index 964f83a..db3587e 100644 |
| --- a/native/apple/Sources/SheetsKit/SheetModel.swift |
| +++ b/native/apple/Sources/SheetsKit/SheetModel.swift |
| @@ -33,8 +33,16 @@ import RastrilloApple |
| var copied: [[CellWrite]]? |
| var copiedText = "" |
| let wire = SheetWireWorker() |
| - let client: NativeHTTP |
| - init(client: NativeHTTP) { self.client = client } |
| + let client: NativeTransport |
| + /// 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 |
| + } |
| var base: String { "/sheets/\(page?.Sheet.ID ?? 0)" } |
| var grid: String { "/api\(base)/tabs/\(page?.Sheet.TabID ?? 0)" } |
| var rows: Int { page?.Rows ?? 100 } |
diff --git a/native/apple/Sources/SheetsKit/SheetsWorkspace.swift b/native/apple/Sources/SheetsKit/SheetsWorkspace.swift| index 72dec82..f362578 100644 |
| --- a/native/apple/Sources/SheetsKit/SheetsWorkspace.swift |
| +++ b/native/apple/Sources/SheetsKit/SheetsWorkspace.swift |
| @@ -65,6 +65,10 @@ import SwiftUI |
| .onChange(of: scenePhase) { _, value in |
| if value != .active { model.clearCopyProvenance() } |
| } |
| + .safeAreaInset(edge: .top, spacing: 0) { |
| + NativeOfflineBanner(connectivity: model.connectivity, origin: model.client.origin, |
| + message: sheetsMacString("You’re offline. Waiting for a connection.")) |
| + } |
| .task(id: "\(model.page?.Sheet.ID ?? 0)-\(scenePhase == .active)") { |
| if scenePhase == .active { await model.watch() } |
| } |
diff --git a/native/apple/Tests/SheetsKitTests/LiveConnectivityTests.swift b/native/apple/Tests/SheetsKitTests/LiveConnectivityTests.swift| new file mode 100644 |
| index 0000000..90e3535 |
| --- /dev/null |
| +++ b/native/apple/Tests/SheetsKitTests/LiveConnectivityTests.swift |
| @@ -0,0 +1,124 @@ |
| +import Foundation |
| +import RastrilloApple |
| +import RastrilloTestSupport |
| +import Testing |
| +@testable import SheetsKit |
| + |
| +private let pageJSON = Data(#""" |
| +{"Sheet":{"ID":7,"Title":"Budget","TabID":1,"TabName":"Sheet1"},"Rows":1,"Cols":1,"Truncated":false, |
| + "MayWrite":true,"MayComment":false,"MayShare":false,"FillSwatches":[],"InkSwatches":[],"NumberFormats":[]} |
| +"""#.utf8) |
| + |
| +/// A sheet whose live stream answers with `stream`; everything else is refused. |
| +private func sheetServer(stream: ScriptedTransport.Step) -> ScriptedTransport { |
| + ScriptedTransport { call in call.kind == .stream ? stream : .respond(status: 503) } |
| +} |
| + |
| +private func streams(_ transport: ScriptedTransport) -> Int { transport.calls.filter { $0.kind == .stream }.count } |
| + |
| +@MainActor private func until(_ seconds: Double, _ condition: () -> Bool) async { |
| + let deadline = Date().addingTimeInterval(seconds) |
| + while !condition() && Date() < deadline { try? await Task.sleep(for: .milliseconds(10)) } |
| +} |
| + |
| +@MainActor private func watching(_ server: ScriptedTransport, _ connectivity: NativeConnectivity) throws -> (SheetModel, Task<Void, Never>) { |
| + let model = SheetModel(client: server, connectivity: connectivity) |
| + model.page = try JSONDecoder().decode(SheetPage.self, from: pageJSON) |
| + return (model, Task { await model.watch() }) |
| +} |
| + |
| +/// Offline, the live stream makes no timed attempts and shows "Offline"; when |
| +/// the path comes back it reconnects at once rather than after a fixed sleep. |
| +@MainActor @Test func liveStreamWaitsForThePathAndReconnectsWhenItReturns() async throws { |
| + let path = ScriptedPathSource(.satisfied) |
| + let connectivity = NativeConnectivity(source: path) |
| + let server = sheetServer(stream: .offline(.notConnectedToInternet)) |
| + let (model, task) = try watching(server, connectivity) |
| + defer { task.cancel() } |
| + await until(2) { streams(server) >= 1 } |
| + path.send(.unsatisfied) |
| + await until(2) { !connectivity.isPathSatisfied } |
| + try await Task.sleep(for: .milliseconds(600)) |
| + let offline = streams(server) |
| + #expect(model.live == "Offline") |
| + try await Task.sleep(for: .milliseconds(2600)) |
| + #expect(streams(server) == offline, "The stream retried on a timer while the path was down") |
| + let restored = Date() |
| + path.send(.satisfied) |
| + await until(2) { streams(server) > offline } |
| + #expect(streams(server) > offline, "The restored path never reconnected the stream") |
| + #expect(Date().timeIntervalSince(restored) < 1, "The reconnect waited out a sleep") |
| +} |
| + |
| +/// A stream that keeps failing at once is retried at 0.5 s, 1 s, 2 s (with |
| +/// jitter), not at a fixed interval. |
| +@MainActor @Test func liveStreamBacksOffBetweenQuickFailures() async throws { |
| + let connectivity = NativeConnectivity(source: ScriptedPathSource(.satisfied)) |
| + let server = sheetServer(stream: .respond(status: 503)) |
| + let (_, task) = try watching(server, connectivity) |
| + defer { task.cancel() } |
| + await until(2) { streams(server) >= 1 } |
| + try await Task.sleep(for: .milliseconds(1500)) |
| + let early = streams(server) |
| + #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 4d14a98..76001d6 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: 2a5d07b2c8d3dfb3fd8526dc7e1c802f97865c67 |
| + revision: 14ee567be2e1816002c7db4274acaf2926983b4c |
| settings: |
| base: |
| SWIFT_VERSION: '6.0' |
diff --git a/native/macos/SheetsMacStore.xcodeproj/project.pbxproj b/native/macos/SheetsMacStore.xcodeproj/project.pbxproj| index 04435ad..d344dfa 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 = 2a5d07b2c8d3dfb3fd8526dc7e1c802f97865c67; |
| + revision = 14ee567be2e1816002c7db4274acaf2926983b4c; |
| }; |
| }; |
| /* End XCRemoteSwiftPackageReference section */ |
diff --git a/native/macos/project.yml b/native/macos/project.yml| index 63ce282..6d06575 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: 2a5d07b2c8d3dfb3fd8526dc7e1c802f97865c67 |
| + revision: 14ee567be2e1816002c7db4274acaf2926983b4c |
| settings: |
| base: |
| SWIFT_VERSION: '6.0' |