oficina / sheets Public

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>
Paul Campbell pushed by paul@keymail.dev 705527de0b6712924d7235f353b257e27777f285 parent 22354ef
8 files changed, +80 −18
  • native/apple/Package.resolved +1 −1
  • native/apple/Package.swift +2 −2
  • native/apple/Sources/SheetsKit/SheetLive.swift +13 −12
  • native/apple/Sources/SheetsKit/SheetModel.swift +2 −0
  • native/apple/Tests/SheetsKitTests/LiveConnectivityTests.swift +59 −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.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'