WIP: preserve rotation and independent-lane retry fixes at the disk stop
9 files changed,
+192
−13
Package.swift+1 −1Sources/RastrilloApple/NativeSealedStore.swift+92 −5Sources/RastrilloApple/OutboxDrainer.swift+7 −3Sources/RastrilloNative/Outbox.swift+2 −1Sources/RastrilloTestSupport/FaultingBlobStore.swift+14 −3Tests/RastrilloAppleTests/OutboxDrainerTests.swift+16 −0Tests/RastrilloAppleTests/SealedStoreTests.swift+32 −0Tests/RastrilloNativeTests/OutboxTests.swift+1 −0docs/testing/k3-evidence.md+27 −0
CI failed — run details
Intent
diff --git a/Package.swift b/Package.swift| index ba69758..4be7b0e 100644 |
| --- a/Package.swift |
| +++ b/Package.swift |
| @@ -16,7 +16,7 @@ let package = Package( |
| .target(name: "RastrilloTestSupport", dependencies: ["RastrilloApple", "RastrilloNative"]), |
| .target(name: "RastrilloMacUpdates", dependencies: [.product(name: "Sparkle", package: "Sparkle", condition: .when(platforms: [.macOS]))], resources: [.process("Resources")]), |
| .testTarget(name: "RastrilloMacUpdatesTests", dependencies: ["RastrilloMacUpdates"]), |
| - .testTarget(name: "RastrilloAppleTests", dependencies: ["RastrilloApple", "RastrilloNative"]), |
| + .testTarget(name: "RastrilloAppleTests", dependencies: ["RastrilloApple", "RastrilloNative", "RastrilloTestSupport"]), |
| .testTarget(name: "RastrilloNativeTests", dependencies: ["RastrilloNative", "RastrilloTestSupport"]), |
| .testTarget(name: "RastrilloTestSupportTests", dependencies: ["RastrilloTestSupport", "RastrilloApple"]), |
| ] |
diff --git a/Sources/RastrilloApple/NativeSealedStore.swift b/Sources/RastrilloApple/NativeSealedStore.swift| index ff16d34..6d75f02 100644 |
| --- a/Sources/RastrilloApple/NativeSealedStore.swift |
| +++ b/Sources/RastrilloApple/NativeSealedStore.swift |
| @@ -3,6 +3,17 @@ import Foundation |
| import RastrilloNative |
| import Security |
| +/// A single Keychain item makes key rotation crash recoverable: retain the old |
| +/// key until every file has been atomically resealed for its original path. |
| +public struct StoreKeyState: Codable, Sendable { |
| + public var current: Data |
| + public var previous: Data? |
| + public var seals: UInt64 |
| + public init(current: Data, previous: Data? = nil, seals: UInt64 = 0) { |
| + self.current = current; self.previous = previous; self.seals = seals |
| + } |
| +} |
| + |
| /// Where an account's store key lives. `KeychainKeyProvider` is the real one; |
| /// tests script every Keychain status with `ScriptedKeyProvider`. |
| /// |
| @@ -13,6 +24,8 @@ public protocol KeyProvider: Sendable { |
| func copyKey() -> (OSStatus, SymmetricKey?) |
| func addKey(_ key: SymmetricKey) -> OSStatus |
| func deleteKey() -> OSStatus |
| + func sealState() -> (OSStatus, StoreKeyState?) |
| + func saveSealState(_ state: StoreKeyState) -> OSStatus |
| } |
| /// One AES-256 key per account, a generic password under service |
| @@ -39,8 +52,10 @@ public struct KeychainKeyProvider: KeyProvider { |
| var result: CFTypeRef? |
| let status = SecItemCopyMatching(q as CFDictionary, &result) |
| guard status == errSecSuccess else { return (status, nil) } |
| - guard let data = result as? Data, data.count == 32 else { return (errSecDecode, nil) } |
| - return (status, SymmetricKey(data: data)) |
| + guard let data = result as? Data else { return (errSecDecode, nil) } |
| + if data.count == 32 { return (status, SymmetricKey(data: data)) } |
| + guard let state = try? JSONDecoder().decode(StoreKeyState.self, from: data), state.current.count == 32 else { return (errSecDecode, nil) } |
| + return (status, SymmetricKey(data: state.current)) |
| } |
| public func addKey(_ key: SymmetricKey) -> OSStatus { |
| @@ -50,6 +65,21 @@ public struct KeychainKeyProvider: KeyProvider { |
| return SecItemAdd(q as CFDictionary, nil) |
| } |
| + public func sealState() -> (OSStatus, StoreKeyState?) { |
| + var q = query; q[kSecReturnData as String] = true; q[kSecMatchLimit as String] = kSecMatchLimitOne |
| + var result: CFTypeRef? |
| + let status = SecItemCopyMatching(q as CFDictionary, &result) |
| + guard status == errSecSuccess, let data = result as? Data else { return (status, nil) } |
| + if data.count == 32 { return (status, StoreKeyState(current: data)) } |
| + guard let state = try? JSONDecoder().decode(StoreKeyState.self, from: data), state.current.count == 32, |
| + state.previous == nil || state.previous?.count == 32 else { return (errSecDecode, nil) } |
| + return (status, state) |
| + } |
| + public func saveSealState(_ state: StoreKeyState) -> OSStatus { |
| + guard let bytes = try? JSONEncoder().encode(state) else { return errSecParam } |
| + return SecItemUpdate(query as CFDictionary, [kSecValueData as String: bytes] as CFDictionary) |
| + } |
| + |
| public func deleteKey() -> OSStatus { SecItemDelete(query as CFDictionary) } |
| } |
| @@ -62,6 +92,7 @@ public final class NativeSealedStore: BlobStore, @unchecked Sendable { |
| private let keys: KeyProvider |
| private let lock = NSLock() |
| private let fm = FileManager.default |
| + var rotationLimit: UInt64 = 1 << 31 |
| public init(root: URL, accountID: String, keys: KeyProvider) { self.root = root; self.accountID = accountID; self.keys = keys } |
| static let writeOptions: Data.WritingOptions = [.atomic, .completeFileProtection] |
| private func url(_ path: String, allowRoot: Bool = false) throws -> URL { |
| @@ -111,10 +142,65 @@ public final class NativeSealedStore: BlobStore, @unchecked Sendable { |
| throw BlobStoreError.unavailable |
| } |
| private func aad(_ path: String) -> Data { Data("\(accountID)|\(Self.formatVersion)|\(path)".utf8) } |
| + private func readKeyState() throws -> StoreKeyState { |
| + let (status, state) = keys.sealState() |
| + guard status == errSecSuccess, let state else { throw BlobStoreError.unavailable } |
| + return state |
| + } |
| + private func saveKeyState(_ state: StoreKeyState) throws { |
| + guard keys.saveSealState(state) == errSecSuccess else { throw BlobStoreError.unavailable } |
| + } |
| + private func reserveSeal(_ state: inout StoreKeyState) throws { |
| + guard state.seals < rotationLimit else { throw BlobStoreError.unavailable } |
| + state.seals += 1 |
| + // Count before sealing: a kill can overcount, but can never reuse a quota. |
| + try saveKeyState(state) |
| + } |
| + private func open(_ data: Data, path: String, state: StoreKeyState) throws -> Data { |
| + let box = try AES.GCM.SealedBox(combined: data) |
| + do { return try AES.GCM.open(box, using: SymmetricKey(data: state.current), authenticating: aad(path)) } |
| + catch { |
| + guard let previous = state.previous else { throw error } |
| + return try AES.GCM.open(box, using: SymmetricKey(data: previous), authenticating: aad(path)) |
| + } |
| + } |
| + private func files(_ directory: URL) throws -> [URL] { |
| + let children: [URL] |
| + do { children = try fm.contentsOfDirectory(at: directory, includingPropertiesForKeys: [.isDirectoryKey]) } |
| + catch { if missing(error) { return [] }; throw error } |
| + return try children.flatMap { child in |
| + try child.resourceValues(forKeys: [.isDirectoryKey]).isDirectory == true ? files(child) : [child] |
| + } |
| + } |
| + private func rotateIfNeeded(_ state: inout StoreKeyState) throws { |
| + guard state.previous != nil || state.seals >= rotationLimit else { return } |
| + if state.previous == nil { |
| + state = StoreKeyState(current: SymmetricKey(size: .bits256).withUnsafeBytes { Data($0) }, previous: state.current) |
| + try saveKeyState(state) |
| + } |
| + for file in try files(root) { |
| + let path = String(file.resolvingSymlinksInPath().path.dropFirst(root.resolvingSymlinksInPath().path.count + 1)) |
| + let combined = try Data(contentsOf: file) |
| + if let box = try? AES.GCM.SealedBox(combined: combined), |
| + (try? AES.GCM.open(box, using: SymmetricKey(data: state.current), authenticating: aad(path))) != nil { continue } |
| + let plaintext: Data |
| + do { plaintext = try open(combined, path: path, state: state) } |
| + catch { try fm.removeItem(at: file); throw BlobStoreError.corrupt(path) } |
| + try reserveSeal(&state) |
| + let resealed = try AES.GCM.seal(plaintext, using: SymmetricKey(data: state.current), authenticating: aad(path)) |
| + try resealed.combined!.write(to: file, options: Self.writeOptions) |
| + } |
| + state.previous = nil |
| + try saveKeyState(state) |
| + } |
| public func write(_ data: Data, to path: String) throws { |
| try lock.withLock { try io { |
| let target = try url(path) |
| - guard let key = try key(create: true) else { throw BlobStoreError.unavailable } |
| + _ = try key(create: true) |
| + var state = try readKeyState() |
| + try rotateIfNeeded(&state) |
| + try reserveSeal(&state) |
| + let key = SymmetricKey(data: state.current) |
| try fm.createDirectory(at: target.deletingLastPathComponent(), withIntermediateDirectories: true) |
| var directory = root; var values = URLResourceValues(); values.isExcludedFromBackup = true |
| try directory.setResourceValues(values) |
| @@ -128,8 +214,9 @@ public final class NativeSealedStore: BlobStore, @unchecked Sendable { |
| let target = try url(path) |
| let data: Data |
| do { data = try Data(contentsOf: target) } catch { if missing(error) { return nil }; throw error } |
| - guard let key = try key(create: false) else { throw BlobStoreError.unavailable } |
| - do { return try AES.GCM.open(AES.GCM.SealedBox(combined: data), using: key, authenticating: aad(path)) } |
| + guard try key(create: false) != nil else { throw BlobStoreError.unavailable } |
| + let state = try readKeyState() |
| + do { return try open(data, path: path, state: state) } |
| catch { |
| // Only an authentication/decode failure reaches this catch. I/O or |
| // Keychain refusal must never turn into a destructive corruption path. |
diff --git a/Sources/RastrilloApple/OutboxDrainer.swift b/Sources/RastrilloApple/OutboxDrainer.swift| index 11c08ce..604392f 100644 |
| --- a/Sources/RastrilloApple/OutboxDrainer.swift |
| +++ b/Sources/RastrilloApple/OutboxDrainer.swift |
| @@ -143,16 +143,20 @@ import RastrilloNative |
| if !connectivity.isPathSatisfied { phase = .waitingForNetwork; return .waitingForNetwork } |
| if let deadline = serverDeadline, deadline > now { phase = .backingOff; return .rateLimited(deadline - now) } |
| serverDeadline = nil |
| + var retryLanes: Set<String> = [] |
| while !Task.isCancelled { |
| summary = await outbox.summary |
| var batch: [OutboxEntry] = [] |
| do { |
| - for lane in summary.lanes.keys.sorted() { |
| + for lane in summary.lanes.keys.sorted() where !retryLanes.contains(lane) { |
| batch = try await outbox.beginSend(lane: lane, maxBytes: maxBatchBytes) |
| if !batch.isEmpty { break } |
| } |
| } catch { phase = .storeUnavailable; return .storeUnavailable } |
| - guard let first = batch.first else { phase = .idle; return .idle } |
| + guard let first = batch.first else { |
| + if !retryLanes.isEmpty { return retry() } |
| + phase = .idle; return .idle |
| + } |
| let ids = batch.map(\.id) |
| guard let transport = transport() else { |
| await outbox.endSend(ids, failed: false); sessionEnded = true; phase = .sessionEnded; return .sessionEnded |
| @@ -184,7 +188,7 @@ import RastrilloNative |
| } |
| await outbox.endSend(ids, failed: false) |
| summary = await outbox.summary |
| - if mustRetry || !progressed { return retry() } |
| + if mustRetry || !progressed { retryLanes.insert(first.lane); continue } |
| backoff.reset(); retryDeadline = nil |
| } catch { |
| await outbox.endSend(ids); summary = await outbox.summary |
diff --git a/Sources/RastrilloNative/Outbox.swift b/Sources/RastrilloNative/Outbox.swift| index da9b878..0ddc905 100644 |
| --- a/Sources/RastrilloNative/Outbox.swift |
| +++ b/Sources/RastrilloNative/Outbox.swift |
| @@ -164,7 +164,8 @@ public actor Outbox { |
| try load() |
| guard flying.isEmpty else { return [] } |
| let batch = sendable(lane: lane, maxBytes: maxBytes) |
| - flying.formUnion(batch.map(\.id)); notify(); return batch |
| + if !batch.isEmpty { flying.formUnion(batch.map(\.id)); notify() } |
| + return batch |
| } |
| public func endSend(_ ids: [UUID], failed: Bool = true) { |
| for id in ids { |
diff --git a/Sources/RastrilloTestSupport/FaultingBlobStore.swift b/Sources/RastrilloTestSupport/FaultingBlobStore.swift| index c45658c..ecf5c77 100644 |
| --- a/Sources/RastrilloTestSupport/FaultingBlobStore.swift |
| +++ b/Sources/RastrilloTestSupport/FaultingBlobStore.swift |
| @@ -64,10 +64,11 @@ public final class FaultingBlobStore: BlobStore, @unchecked Sendable { |
| public final class ScriptedKeyProvider: KeyProvider, @unchecked Sendable { |
| private let lock = NSLock() |
| private var key: SymmetricKey? |
| + private var state: StoreKeyState? |
| private var copyFailure: OSStatus? |
| private var addFailure: OSStatus? |
| private var history: [String] = [] |
| - public init(key: SymmetricKey? = nil) { self.key = key } |
| + public init(key: SymmetricKey? = nil) { self.key = key; state = key.map { StoreKeyState(current: $0.withUnsafeBytes { Data($0) }) } } |
| public func failCopies(with status: OSStatus?) { lock.withLock { copyFailure = status } } |
| public func failAdds(with status: OSStatus?) { lock.withLock { addFailure = status } } |
| public var storedKey: SymmetricKey? { lock.withLock { key } } |
| @@ -80,8 +81,18 @@ public final class ScriptedKeyProvider: KeyProvider, @unchecked Sendable { |
| history.append("add") |
| if let addFailure { return addFailure } |
| guard key == nil else { return errSecDuplicateItem } |
| - key = new; return errSecSuccess |
| + key = new; state = StoreKeyState(current: new.withUnsafeBytes { Data($0) }); return errSecSuccess |
| } |
| } |
| - public func deleteKey() -> OSStatus { lock.withLock { history.append("delete"); key = nil; return errSecSuccess } } |
| + public func deleteKey() -> OSStatus { lock.withLock { history.append("delete"); key = nil; state = nil; return errSecSuccess } } |
| + public func sealState() -> (OSStatus, StoreKeyState?) { |
| + lock.withLock { copyFailure.map { ($0, nil) } ?? (state == nil ? errSecItemNotFound : errSecSuccess, state) } |
| + } |
| + public func saveSealState(_ value: StoreKeyState) -> OSStatus { |
| + lock.withLock { |
| + if let addFailure { return addFailure } |
| + state = value; key = SymmetricKey(data: value.current); return errSecSuccess |
| + } |
| + } |
| + |
| } |
diff --git a/Tests/RastrilloAppleTests/OutboxDrainerTests.swift b/Tests/RastrilloAppleTests/OutboxDrainerTests.swift| index a7baacb..aa3df64 100644 |
| --- a/Tests/RastrilloAppleTests/OutboxDrainerTests.swift |
| +++ b/Tests/RastrilloAppleTests/OutboxDrainerTests.swift |
| @@ -269,4 +269,20 @@ import XCTest |
| await settle { drainer.summary.isEmpty } |
| XCTAssertEqual(drainer.status(), .saved) |
| } |
| + func testAnAppRetryDoesNotStarveOtherLanes() async throws { |
| + let (connectivity, _) = await connectivity() |
| + let outbox = try await outbox([("doc:a-draft", "1"), ("doc:z-ready", "2")]) |
| + let transport = ScriptedTransport([]) |
| + var sent: [String] = [] |
| + let drainer = OutboxDrainer(outbox: outbox, transport: { transport }, connectivity: connectivity) { lane, entries, _ in |
| + sent.append(lane) |
| + return Dictionary(uniqueKeysWithValues: entries.map { ($0.id, lane == "doc:a-draft" ? .retry : .sent) }) |
| + } |
| + let result = await drainer.drain() |
| + guard case .backingOff = result else { return XCTFail("The draft still needs a retry") } |
| + XCTAssertEqual(sent, ["doc:a-draft", "doc:z-ready"]) |
| + let remaining = await outbox.entries(lane: "doc:z-ready") |
| + XCTAssertTrue(remaining.isEmpty, "An unattached draft must not block another document") |
| + } |
| + |
| } |
diff --git a/Tests/RastrilloAppleTests/SealedStoreTests.swift b/Tests/RastrilloAppleTests/SealedStoreTests.swift| index d4be5cd..63a0d15 100644 |
| --- a/Tests/RastrilloAppleTests/SealedStoreTests.swift |
| +++ b/Tests/RastrilloAppleTests/SealedStoreTests.swift |
| @@ -172,4 +172,36 @@ final class SealedStoreTests: XCTestCase { |
| let lane = await relaunched.entries(lane: "doc:1") |
| XCTAssertEqual(lane.map(\.id), [entry.id]) |
| } |
| + func testSealQuotaRotatesAndResealsInPlace() throws { |
| + let keys = ScriptedKeyProvider() |
| + let sealed = store(keys: keys) |
| + sealed.rotationLimit = 8 |
| + for _ in 0..<8 { try sealed.write(Data("A".utf8), to: "cache/a") } |
| + let old = try XCTUnwrap(keys.storedKey) |
| + try sealed.write(Data("B".utf8), to: "cache/b") |
| + XCTAssertNotEqual(keys.storedKey, old) |
| + XCTAssertEqual(try sealed.read("cache/a"), Data("A".utf8)) |
| + XCTAssertEqual(try sealed.read("cache/b"), Data("B".utf8)) |
| + XCTAssertNil(keys.sealState().1?.previous) |
| + XCTAssertEqual(keys.sealState().1?.seals, 2, "One reseal and the requested write") |
| + let bytes = try Data(contentsOf: file("account-a", "cache/a")) |
| + XCTAssertThrowsError(try AES.GCM.open(AES.GCM.SealedBox(combined: bytes), using: old, authenticating: Data("account-a|1|cache/a".utf8))) |
| + } |
| + |
| + func testInterruptedRotationReadsBothKeysAndFinishesOnNextWrite() throws { |
| + let keys = ScriptedKeyProvider() |
| + let sealed = store(keys: keys) |
| + try sealed.write(Data("A".utf8), to: "cache/a") |
| + let previous = try XCTUnwrap(keys.sealState().1) |
| + let new = SymmetricKey(size: .bits256) |
| + // Crash just after installing the new key, before any file is resealed. |
| + XCTAssertEqual(keys.saveSealState(StoreKeyState(current: new.withUnsafeBytes { Data($0) }, previous: previous.current)), errSecSuccess) |
| + let reopened = store(keys: keys) |
| + XCTAssertEqual(try reopened.read("cache/a"), Data("A".utf8)) |
| + try reopened.write(Data("B".utf8), to: "cache/b") |
| + XCTAssertNil(keys.sealState().1?.previous) |
| + XCTAssertEqual(try reopened.read("cache/a"), Data("A".utf8)) |
| + XCTAssertEqual(try reopened.read("cache/b"), Data("B".utf8)) |
| + } |
| + |
| } |
diff --git a/Tests/RastrilloNativeTests/OutboxTests.swift b/Tests/RastrilloNativeTests/OutboxTests.swift| index ff23d4c..892b07a 100644 |
| --- a/Tests/RastrilloNativeTests/OutboxTests.swift |
| +++ b/Tests/RastrilloNativeTests/OutboxTests.swift |
| @@ -107,6 +107,7 @@ final class OutboxTests: XCTestCase { |
| try await outbox.enqueue(lane: "doc:b", kind: "u", payload: bytes("b\(index)")) |
| } |
| let b = await outbox.entries(lane: "doc:b").map(\.id) |
| + guard b.count == 4 else { return XCTFail("Expected four durable entries in lane b") } |
| let inFlight = try await outbox.beginSend(lane: "doc:a", maxBytes: 15) |
| XCTAssertEqual(inFlight.map(\.id), [a[0]]) |
diff --git a/docs/testing/k3-evidence.md b/docs/testing/k3-evidence.md| index 163ebf0..8165ae0 100644 |
| --- a/docs/testing/k3-evidence.md |
| +++ b/docs/testing/k3-evidence.md |
| @@ -18,3 +18,30 @@ companion builds, Go race tests, and Python bundle tests; transcript: |
| Remaining: complete Docs adoption and crash/convergence evidence, seal-count |
| rotation, final audit and full gates. Nothing has been merged or deployed. |
| + |
| +## Disk stop, 2026-09-24 |
| + |
| +Work stopped when `df` reported 1.2 GiB free (user's stop threshold: 1.5 GiB). |
| +Only session-created Docs build outputs and companion DerivedData were removed. |
| +The inherited `.build` directory in this worktree was preserved. |
| + |
| +Additional focused evidence before the stop: |
| + |
| +- Seal-count rotation and interrupted-rotation tests: both failed with the |
| + rotation call stubbed (`/tmp/k3-rotation-red.log`); all 14 sealed-store tests |
| + passed after restoring/fixing rotation (`/tmp/k3-rotation-green-2.log`). |
| +- Independent-lane retry regression: failed with the first retry starving the |
| + next lane (`/tmp/k3-lane-retry-red.log`); all 15 drainer tests then passed |
| + (`/tmp/k3-lane-retry-green.log`). |
| +- Fixed an inherited undeclared AppleTests → RastrilloTestSupport dependency: |
| + incremental builds could compile tests against a stale support module. |
| +- Fixed an inherited test's unchecked empty-array indexing so stub failures |
| + produce an assertion instead of terminating the test process. |
| + |
| +The latest changes have NOT had another full `make ci`. The previous full |
| +pass predates rotation and lane-retry fixes. Hub CI on 05bf466 failed while |
| +unpacking Sparkle because `unzip` is unavailable; no macOS runner exists. |
| +This branch is WIP and must not merge. |
| + |
| +Docs adoption is saved on oficina/docs `feat/k3-outbox-lane`, discussion #69. |
| +Its evidence file lists unresolved title/lifecycle issues and remaining gates. |