Sheets watch waits on connectivity and backs off; show the offline banner
K2 of the offline-kit spec, Sheets half. The live stream loop slept a fixed 2 s between attempts (SheetLive watch): a restored network waited out the sleep, and an offline device retried on a timer. - watch backs off with the kit's Backoff (0.5 s doubling to 10 s, jittered, Retry-After honoured, reset after a stream that lived more than 5 s) and waits with NativeConnectivity.wait, which a path change or foreground cuts short and which runs no timer while the path is down. The live label reads "Offline" while the path is down. - SheetModel takes any NativeTransport and a connectivity (default: the shared one). The workspace shows NativeOfflineBanner for its server; the copy claims nothing about saving, since edits are not durable until the Sheets outbox slice. English only until reviewed. - Pins rastrillo/native feat/k2-connectivity-backoff (daf6e65). Tests with ScriptedTransport and ScriptedPathSource: no stream attempts over 2.6 s with the path down (label "Offline") and a reconnect within 1 s of it coming back; quick failures retried two or three times in 1.5 s, not once every 2 s.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
10 files changed,
+100
−11
native/apple/Package.resolved+1 −1native/apple/Package.swift+3 −3native/apple/Sources/SheetsKit/Resources/en.lproj/Mac.strings+1 −0native/apple/Sources/SheetsKit/SheetLive.swift+15 −2native/apple/Sources/SheetsKit/SheetModel.swift+8 −2native/apple/Sources/SheetsKit/SheetsWorkspace.swift+4 −0native/apple/Tests/SheetsKitTests/LiveConnectivityTests.swift+65 −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 ./... go: downloading github.com/carlosframework/rastrillo v0.23.0 go: downloading amadan.net/oficina/estilo.git v0.0.0-20260909234655-dec3908b237c go: downloading golang.org/x/crypto v0.55.0 go: downloading github.com/xuri/excelize/v2 v2.11.0 go: downloading gorm.io/gorm v1.31.2 go: downloading amadan.net/oficina/home/connect v0.0.0-20260910000424-5b8d8410775f go: downloading amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489 go: downloading amadan.net/rastrillo/native/server v0.0.0-20260923173546-2a5d07b2c8d3 go: downloading github.com/go-chi/chi/v5 v5.3.2 go: downloading amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd go: downloading github.com/jinzhu/now v1.1.5 go: downloading github.com/richardlehane/mscfb v1.0.7 go: downloading github.com/tiendc/go-deepcopy v1.7.2 go: downloading github.com/xuri/efp v0.0.1 go: downloading github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 go: downloading golang.org/x/net v0.57.0 go: downloading golang.org/x/text v0.41.0 go: downloading github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 go: downloading github.com/jinzhu/inflection v1.0.0 go: downloading modernc.org/sqlite v1.55.0 go: downloading gorm.io/plugin/dbresolver v1.6.2 go: downloading github.com/keymaildev/signin v0.1.1 go: downloading github.com/richardlehane/msoleps v1.0.6 go: downloading modernc.org/libc v1.74.1 go: downloading golang.org/x/sys v0.47.0 go: downloading modernc.org/memory v1.11.0 go: downloading github.com/dustin/go-humanize v1.0.1 go: downloading github.com/google/uuid v1.6.0 go: downloading modernc.org/mathutil v1.7.1 go: downloading github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec === 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.088s ok amadan.net/oficina/sheets/internal/sheets 10.571s ok amadan.net/oficina/sheets/internal/sheets/calc 0.010s ok amadan.net/oficina/sheets/internal/sheets/format 0.004s 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 3.288s ok amadan.net/oficina/sheets/internal/sheets/xlsx 0.310s ? 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 58.765s ok amadan.net/oficina/sheets/internal/sheets/live 1.096s
diff --git a/native/apple/Package.resolved b/native/apple/Package.resolved| index 140b7c4..0f27aff 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" : "daf6e652d78552fe468a91ef45b4b1dcf3165926" |
| } |
| }, |
| { |
diff --git a/native/apple/Package.swift b/native/apple/Package.swift| index 7b882c9..37fa99c 100644 |
| --- a/native/apple/Package.swift |
| +++ b/native/apple/Package.swift |
| @@ -3,13 +3,13 @@ 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: "daf6e652d78552fe468a91ef45b4b1dcf3165926") |
| 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")], resources: [.process("Resources")]), |
| + targets:[.target(name:"SheetsKit",dependencies:[.product(name:"RastrilloApple",package:"native"),.product(name:"RastrilloNative",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..6fb2e9d 100644 |
| --- a/native/apple/Sources/SheetsKit/SheetLive.swift |
| +++ b/native/apple/Sources/SheetsKit/SheetLive.swift |
| @@ -1,11 +1,16 @@ |
| import Foundation |
| +import RastrilloApple |
| +import RastrilloNative |
| @MainActor extension SheetModel { |
| func watch() async { |
| guard page != nil else { return } |
| let source = grid |
| + var backoff = Backoff() |
| while !Task.isCancelled && grid == source { |
| - live = "Connecting" |
| + 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. |
| @@ -22,10 +27,18 @@ import Foundation |
| } |
| } catch is CancellationError { return } catch { |
| guard !Task.isCancelled, grid == source else { return } |
| + if case NativeClientError.rateLimited(let after) = error { retryAfter = after } |
| live = "Reconnecting" |
| } |
| if live == "Access changed" || live == "Paused" { return } |
| - do { try await Task.sleep(for: .seconds(2)) } catch { 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() } |
| + 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..c3f9015 100644 |
| --- a/native/apple/Sources/SheetsKit/SheetModel.swift |
| +++ b/native/apple/Sources/SheetsKit/SheetModel.swift |
| @@ -33,8 +33,14 @@ 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 |
| + 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..98abd29 |
| --- /dev/null |
| +++ b/native/apple/Tests/SheetsKitTests/LiveConnectivityTests.swift |
| @@ -0,0 +1,65 @@ |
| +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") |
| +} |
diff --git a/native/ios/project.yml b/native/ios/project.yml| index 4d14a98..79ec71c 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: daf6e652d78552fe468a91ef45b4b1dcf3165926 |
| settings: |
| base: |
| SWIFT_VERSION: '6.0' |
diff --git a/native/macos/SheetsMacStore.xcodeproj/project.pbxproj b/native/macos/SheetsMacStore.xcodeproj/project.pbxproj| index 04435ad..a4818d6 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 = daf6e652d78552fe468a91ef45b4b1dcf3165926; |
| }; |
| }; |
| /* End XCRemoteSwiftPackageReference section */ |
diff --git a/native/macos/project.yml b/native/macos/project.yml| index 63ce282..6f60805 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: daf6e652d78552fe468a91ef45b4b1dcf3165926 |
| settings: |
| base: |
| SWIFT_VERSION: '6.0' |