oficina / sheets Public

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.
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 50a3427029e02a618a6682d02f40eff87548a377 parent 556a7e8
10 files changed, +162 −11
  • native/apple/Package.resolved +1 −1
  • native/apple/Package.swift +2 −2
  • native/apple/Sources/SheetsKit/Resources/en.lproj/Mac.strings +1 −0
  • native/apple/Sources/SheetsKit/SheetLive.swift +17 −3
  • native/apple/Sources/SheetsKit/SheetModel.swift +10 −2
  • native/apple/Sources/SheetsKit/SheetsWorkspace.swift +4 −0
  • native/apple/Tests/SheetsKitTests/LiveConnectivityTests.swift +124 −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 ./...
=== 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'