oficina / sheets Public

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>
Paul Campbell pushed by paul@keymail.dev 82189dac9bba3b7fa2f56a334ebee081b24c1982 parent 300c4d1
10 files changed, +100 −11
  • native/apple/Package.resolved +1 −1
  • native/apple/Package.swift +3 −3
  • native/apple/Sources/SheetsKit/Resources/en.lproj/Mac.strings +1 −0
  • native/apple/Sources/SheetsKit/SheetLive.swift +15 −2
  • native/apple/Sources/SheetsKit/SheetModel.swift +8 −2
  • native/apple/Sources/SheetsKit/SheetsWorkspace.swift +4 −0
  • native/apple/Tests/SheetsKitTests/LiveConnectivityTests.swift +65 −0
  • native/ios/project.yml +1 −1
  • native/macos/SheetsMacStore.xcodeproj/project.pbxproj +1 −1
  • native/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'