From 458d475dcfbb497f033a01d2f4aa9c0f7e82f0b4 Mon Sep 17 00:00:00 2001 From: Johnny IV Young Ospino Date: Mon, 5 Oct 2026 15:40:14 -0500 Subject: [PATCH 01/14] fix(storage): preserve isolated recovery generations and resume interrupted moves --- .../GanchoKit/GRDBClipboardStore.swift | 33 ++--- .../Sources/GanchoKit/RawKeyAdoption.swift | 7 +- .../GanchoKit/StoreGenerationRecovery.swift | 120 ++++++++++++++++++ .../GanchoKitTests/GRDBEncryptionTests.swift | 4 +- .../GRDBRawKeyAdoptionTests.swift | 2 +- .../StoreGenerationRecoveryTests.swift | 87 +++++++++++++ 6 files changed, 232 insertions(+), 21 deletions(-) create mode 100644 Packages/GanchoKit/Sources/GanchoKit/StoreGenerationRecovery.swift create mode 100644 Packages/GanchoKit/Tests/GanchoKitTests/StoreGenerationRecoveryTests.swift diff --git a/Packages/GanchoKit/Sources/GanchoKit/GRDBClipboardStore.swift b/Packages/GanchoKit/Sources/GanchoKit/GRDBClipboardStore.swift index 593f4ee6..28489730 100644 --- a/Packages/GanchoKit/Sources/GanchoKit/GRDBClipboardStore.swift +++ b/Packages/GanchoKit/Sources/GanchoKit/GRDBClipboardStore.swift @@ -19,6 +19,8 @@ public final class GRDBClipboardStore: ClipboardStore, ClipImporting { /// and the test harness can run statements without widening the API. let writer: any DatabaseWriter private let blobs: BlobStore + /// Retained for the pool's lifetime so recovery cannot relocate a live generation. + private let generationLease: StoreGenerationLease? /// Maintenance-only blob access for same-module engines (orphan sweeps). var blobsForMaintenance: BlobStore { blobs } @@ -33,6 +35,7 @@ public final class GRDBClipboardStore: ClipboardStore, ClipImporting { /// path used by tests and the perf harness. public convenience init(directory: URL, passphrase: String? = nil) throws { try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let generationLease = try StoreGenerationRecovery.openLease(in: directory) let dbPath = directory.appendingPathComponent("gancho.sqlite").path var configuration = Configuration() @@ -76,15 +79,18 @@ public final class GRDBClipboardStore: ClipboardStore, ClipImporting { blobEncryptionKeyData = nil #endif + // The pool retains its generation lease even if callers retain the + // writer independently of this facade. + configuration.prepareDatabase { _ in withExtendedLifetime(generationLease) {} } let pool = try DatabasePool(path: dbPath, configuration: configuration) + try GanchoDatabaseMigrator.make().migrate(pool) let blobStore = BlobStore( directory: directory.appendingPathComponent("blobs"), encryptionKeyData: blobEncryptionKeyData) try blobStore.encryptPlaintextFilesIfNeeded() self.init( writer: pool, - blobs: blobStore) - try GanchoDatabaseMigrator.make().migrate(pool) + blobs: blobStore, generationLease: generationLease) // NOTE: the cosmetic legacy-preview backfill is deliberately NOT run // here — it scanned image rows inside a write transaction on every // open, taxing cold launch in the app and every extension. Apps call @@ -156,20 +162,10 @@ public final class GRDBClipboardStore: ClipboardStore, ClipImporting { return dbError.resultCode == .SQLITE_NOTADB } - /// Moves an unreadable encrypted database (and its WAL/SHM siblings) aside to - /// a timestamped, collision-resistant `.unreadable-*` name so a fresh store - /// can take its place. - /// Content-addressed blobs are left in place — the fresh database won't - /// reference them, and an orphan sweep reclaims them later. + /// Preserve database/WAL/SHM and sealed blobs (including thumbnails) as + /// one resumable generation, outside the live content-addressed namespace. static func archiveUnreadableStore(in directory: URL) throws { - let fileManager = FileManager.default - let suffix = unreadableStoreArchiveSuffix() - for name in ["gancho.sqlite", "gancho.sqlite-wal", "gancho.sqlite-shm"] { - let source = directory.appendingPathComponent(name) - guard fileManager.fileExists(atPath: source.path) else { continue } - let destination = directory.appendingPathComponent("\(name).unreadable-\(suffix)") - try fileManager.moveItem(at: source, to: destination) - } + try StoreGenerationRecovery.archive(in: directory, suffix: unreadableStoreArchiveSuffix()) } static func unreadableStoreArchiveSuffix( @@ -259,9 +255,14 @@ public final class GRDBClipboardStore: ClipboardStore, ClipImporting { #endif /// Injectable writer for tests (`DatabaseQueue()` in-memory). - public init(writer: any DatabaseWriter, blobs: BlobStore) { + public convenience init(writer: any DatabaseWriter, blobs: BlobStore) { + self.init(writer: writer, blobs: blobs, generationLease: nil) + } + + init(writer: any DatabaseWriter, blobs: BlobStore, generationLease: StoreGenerationLease?) { self.writer = writer self.blobs = blobs + self.generationLease = generationLease } /// Tests call this for in-memory databases; the directory initializer diff --git a/Packages/GanchoKit/Sources/GanchoKit/RawKeyAdoption.swift b/Packages/GanchoKit/Sources/GanchoKit/RawKeyAdoption.swift index cef886dd..58cae791 100644 --- a/Packages/GanchoKit/Sources/GanchoKit/RawKeyAdoption.swift +++ b/Packages/GanchoKit/Sources/GanchoKit/RawKeyAdoption.swift @@ -48,6 +48,7 @@ import GRDB ) throws -> GRDBClipboardStore { try FileManager.default.createDirectory( at: directory, withIntermediateDirectories: true) + let generationLease = try StoreGenerationRecovery.openLease(in: directory) let dbPath = directory.appendingPathComponent("gancho.sqlite").path var configuration = Configuration() @@ -67,15 +68,17 @@ import GRDB configuration.prepareDatabase { db in try db.usePassphrase(sqlcipherKey) } + configuration.prepareDatabase { _ in withExtendedLifetime(generationLease) {} } let pool = try DatabasePool(path: dbPath, configuration: configuration) + try GanchoDatabaseMigrator.make().migrate(pool) let blobStore = BlobStore( directory: directory.appendingPathComponent("blobs"), // The blob key derives from the PLAIN hex string — never the // x'…' literal — or every sealed blob/thumbnail is lost. encryptionKeyData: BlobStore.encryptionKeyData(for: passphrase)) try blobStore.encryptPlaintextFilesIfNeeded() - let store = GRDBClipboardStore(writer: pool, blobs: blobStore) - try store.migrate() + let store = GRDBClipboardStore( + writer: pool, blobs: blobStore, generationLease: generationLease) return store } diff --git a/Packages/GanchoKit/Sources/GanchoKit/StoreGenerationRecovery.swift b/Packages/GanchoKit/Sources/GanchoKit/StoreGenerationRecovery.swift new file mode 100644 index 00000000..699dc3f4 --- /dev/null +++ b/Packages/GanchoKit/Sources/GanchoKit/StoreGenerationRecovery.swift @@ -0,0 +1,120 @@ +import Darwin +import Foundation + +/// An open database pins its filesystem generation. Recovery requires an +/// exclusive cross-process lease, so it cannot move bytes beneath another pool. +final class StoreGenerationLease: @unchecked Sendable { + private var descriptor: Int32 + + init(in directory: URL, exclusive: Bool) throws { + let path = directory.appendingPathComponent(".store-generation.lock").path + descriptor = open(path, O_CREAT | O_RDWR | O_CLOEXEC, 0o600) + guard descriptor >= 0 else { throw POSIXError(.EIO) } + guard flock(descriptor, (exclusive ? LOCK_EX : LOCK_SH) | LOCK_NB) == 0 else { + let failure = errno + close(descriptor) + descriptor = -1 + throw POSIXError(POSIXErrorCode(rawValue: failure) ?? .EBUSY) + } + } + + func downgrade() throws { + guard flock(descriptor, LOCK_SH | LOCK_NB) == 0 else { throw POSIXError(.EBUSY) } + } + + func release() { + guard descriptor >= 0 else { return } + _ = flock(descriptor, LOCK_UN) + close(descriptor) + descriptor = -1 + } + + deinit { release() } +} + +/// Preserves a whole database/blob/thumbnail generation before creating a new +/// namespace. The journal makes every member move idempotent after interruption. +/// A partial or ambiguous generation fails closed; it is never opened as empty. +enum StoreGenerationRecovery { + static let journalName = ".store-recovery.json" + static let members = ["gancho.sqlite", "gancho.sqlite-wal", "gancho.sqlite-shm", "blobs"] + + private struct Journal: Codable { + var archive: String + var members: [String] + } + + static func openLease(in directory: URL) throws -> StoreGenerationLease { + let lease = try StoreGenerationLease(in: directory, exclusive: false) + guard FileManager.default.fileExists( + atPath: directory.appendingPathComponent(journalName).path) + else { return lease } + // Release the shared descriptor before acquiring exclusive ownership. + lease.release() + return try resumeLease(in: directory) + } + + private static func resumeLease(in directory: URL) throws -> StoreGenerationLease { + let exclusive = try StoreGenerationLease(in: directory, exclusive: true) + try resume(in: directory) + try exclusive.downgrade() + return exclusive + } + + static func archive( + in directory: URL, suffix: String, + afterMove: (String) throws -> Void = { _ in } + ) throws { + let lease = try StoreGenerationLease(in: directory, exclusive: true) + defer { withExtendedLifetime(lease) {} } + let manager = FileManager.default + let journalURL = directory.appendingPathComponent(journalName) + if manager.fileExists(atPath: journalURL.path) { + try resume(in: directory, afterMove: afterMove) + return + } + let archiveName = ".unreadable-\(suffix)" + let archive = directory.appendingPathComponent(archiveName, isDirectory: true) + try manager.createDirectory( + at: archive, withIntermediateDirectories: false, attributes: [.posixPermissions: 0o700]) + let present = members.filter { manager.fileExists(atPath: directory.appendingPathComponent($0).path) } + guard present.contains("gancho.sqlite") else { throw CocoaError(.fileReadNoSuchFile) } + let journal = Journal(archive: archiveName, members: present) + try JSONEncoder().encode(journal).write(to: journalURL, options: .atomic) + let handle = try FileHandle(forWritingTo: journalURL) + try handle.synchronize() + try handle.close() + try resume(in: directory, afterMove: afterMove) + } + + static func resume( + in directory: URL, afterMove: (String) throws -> Void = { _ in } + ) throws { + let manager = FileManager.default + let journalURL = directory.appendingPathComponent(journalName) + guard manager.fileExists(atPath: journalURL.path) else { return } + let journal = try JSONDecoder().decode(Journal.self, from: Data(contentsOf: journalURL)) + guard journal.archive.hasPrefix(".unreadable-"), + !journal.archive.contains("/"), !journal.archive.contains(".."), + Set(journal.members).count == journal.members.count, + journal.members.contains("gancho.sqlite"), + journal.members.allSatisfy({ members.contains($0) }) + else { throw CocoaError(.fileReadCorruptFile) } + let archive = directory.appendingPathComponent(journal.archive, isDirectory: true) + guard (try archive.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey])).isDirectory == true, + (try archive.resourceValues(forKeys: [.isSymbolicLinkKey])).isSymbolicLink != true + else { throw CocoaError(.fileReadCorruptFile) } + for name in journal.members { + let source = directory.appendingPathComponent(name) + let destination = archive.appendingPathComponent(name) + let sourceExists = manager.fileExists(atPath: source.path) + let archived = manager.fileExists(atPath: destination.path) + guard sourceExists != archived else { throw CocoaError(.fileReadCorruptFile) } + if sourceExists { + try manager.moveItem(at: source, to: destination) + try afterMove(name) + } + } + try manager.removeItem(at: journalURL) + } +} diff --git a/Packages/GanchoKit/Tests/GanchoKitTests/GRDBEncryptionTests.swift b/Packages/GanchoKit/Tests/GanchoKitTests/GRDBEncryptionTests.swift index 7d07fc01..eb1af36e 100644 --- a/Packages/GanchoKit/Tests/GanchoKitTests/GRDBEncryptionTests.swift +++ b/Packages/GanchoKit/Tests/GanchoKitTests/GRDBEncryptionTests.swift @@ -181,7 +181,7 @@ import Testing let names = try FileManager.default.contentsOfDirectory(atPath: dir.path) #expect( - names.contains { $0.hasPrefix("gancho.sqlite.unreadable-") }, + names.contains { $0.hasPrefix(".unreadable-") }, "the unreadable database is moved aside, never deleted") } @@ -219,7 +219,7 @@ import Testing } let names = try FileManager.default.contentsOfDirectory(atPath: dir.path) #expect( - !names.contains { $0.hasPrefix("gancho.sqlite.unreadable-") }, + !names.contains { $0.hasPrefix(".unreadable-") }, "a non-fresh failure must leave the store untouched") } diff --git a/Packages/GanchoKit/Tests/GanchoKitTests/GRDBRawKeyAdoptionTests.swift b/Packages/GanchoKit/Tests/GanchoKitTests/GRDBRawKeyAdoptionTests.swift index a02525a1..7cdc0d62 100644 --- a/Packages/GanchoKit/Tests/GanchoKitTests/GRDBRawKeyAdoptionTests.swift +++ b/Packages/GanchoKit/Tests/GanchoKitTests/GRDBRawKeyAdoptionTests.swift @@ -177,7 +177,7 @@ import Testing let names = try FileManager.default.contentsOfDirectory(atPath: dir.path) #expect( - names.contains { $0.hasPrefix("gancho.sqlite.unreadable-") }, + names.contains { $0.hasPrefix(".unreadable-") }, "raw-key adoption must archive the unreachable store before starting fresh") } diff --git a/Packages/GanchoKit/Tests/GanchoKitTests/StoreGenerationRecoveryTests.swift b/Packages/GanchoKit/Tests/GanchoKitTests/StoreGenerationRecoveryTests.swift new file mode 100644 index 00000000..ecbb17a3 --- /dev/null +++ b/Packages/GanchoKit/Tests/GanchoKitTests/StoreGenerationRecoveryTests.swift @@ -0,0 +1,87 @@ +import Foundation +import Testing + +@_spi(GanchoInternal) @testable import GanchoKit + +@Suite("Store generation recovery — preservation and interrupted moves") +struct StoreGenerationRecoveryTests { + private struct Interrupted: Error {} + + private func fixture() throws -> URL { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory( + at: root.appendingPathComponent("blobs/thumbnails"), withIntermediateDirectories: true) + for name in ["gancho.sqlite", "gancho.sqlite-wal", "gancho.sqlite-shm", "blobs/hash", + "blobs/thumbnails/hash.png"] + { + try Data(name.utf8).write(to: root.appendingPathComponent(name)) + } + return root + } + + @Test("Every interrupted member move resumes without mixing a new generation") + func resumesEachMove() throws { + for interruptedName in StoreGenerationRecovery.members { + let root = try fixture() + defer { try? FileManager.default.removeItem(at: root) } + #expect(throws: Interrupted.self) { + try StoreGenerationRecovery.archive(in: root, suffix: "fixture", afterMove: { + if $0 == interruptedName { throw Interrupted() } + }) + } + #expect(FileManager.default.fileExists( + atPath: root.appendingPathComponent(StoreGenerationRecovery.journalName).path)) + let lease = try StoreGenerationRecovery.openLease(in: root) + defer { lease.release() } + let archived = root.appendingPathComponent(".unreadable-fixture") + for member in StoreGenerationRecovery.members { + #expect(!FileManager.default.fileExists(atPath: root.appendingPathComponent(member).path)) + #expect(FileManager.default.fileExists(atPath: archived.appendingPathComponent(member).path)) + } + #expect(try Data(contentsOf: archived.appendingPathComponent("blobs/hash")) == Data("blobs/hash".utf8)) + #expect(try Data(contentsOf: archived.appendingPathComponent("blobs/thumbnails/hash.png")) + == Data("blobs/thumbnails/hash.png".utf8)) + } + } + + @Test("An active generation blocks recovery across independent file descriptors") + func activeGenerationCannotBeMoved() throws { + let root = try fixture() + defer { try? FileManager.default.removeItem(at: root) } + let shared = try StoreGenerationRecovery.openLease(in: root) + #expect(throws: (any Error).self) { + try StoreGenerationRecovery.archive(in: root, suffix: "blocked") + } + #expect(try Data(contentsOf: root.appendingPathComponent("gancho.sqlite")) == Data("gancho.sqlite".utf8)) + shared.release() + try StoreGenerationRecovery.archive(in: root, suffix: "allowed") + #expect(FileManager.default.fileExists(atPath: root.appendingPathComponent(".unreadable-allowed/blobs/hash").path)) + } + + #if SQLITE_HAS_CODEC + @Test("Fresh-key binary recapture is readable; old generation survives maintenance") + func binaryRecovery() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let oldKey = String(repeating: "a1", count: 32) + let newKey = String(repeating: "b2", count: 32) + let payload = Data("old-generation-binary-payload".utf8) + let original = ClipItem(kind: .image, preview: "fixture", contentHash: "original") + var old: GRDBClipboardStore? = try GRDBClipboardStore(directory: root, passphrase: oldKey) + try await old?.insert(original, content: .binary(data: payload, typeIdentifier: "public.data")) + old = nil + let oldBlob = root.appendingPathComponent("blobs/\(GanchoArchive.sha256(payload))") + let oldBytes = try Data(contentsOf: oldBlob) + let recovered = try GRDBClipboardStore.openEncrypted(directory: root, key: newKey, keyIsFresh: true) + let recaptured = ClipItem(kind: .image, preview: "fixture", contentHash: "recaptured") + try await recovered.insert(recaptured, content: .binary(data: payload, typeIdentifier: "public.data")) + #expect(try await recovered.content(for: recaptured.id) == .binary(data: payload, typeIdentifier: "public.data")) + _ = try await recovered.removeOrphanedBlobs() + let archive = try #require(try FileManager.default.contentsOfDirectory( + at: root, includingPropertiesForKeys: nil).first { $0.lastPathComponent.hasPrefix(".unreadable-") }) + #expect(try Data(contentsOf: archive.appendingPathComponent("blobs/\(GanchoArchive.sha256(payload))")) == oldBytes) + let preserved = try GRDBClipboardStore(directory: archive, passphrase: oldKey) + #expect(try await preserved.content(for: original.id) == .binary(data: payload, typeIdentifier: "public.data")) + } + #endif +} From 3124bdeb2b67a8bbabb166579970041e201f6f87 Mon Sep 17 00:00:00 2001 From: Johnny IV Young Ospino Date: Mon, 5 Oct 2026 15:52:07 -0500 Subject: [PATCH 02/14] fix(storage): preserve interrupted migration stages with recovery generations --- .../GanchoKit/StoreGenerationRecovery.swift | 14 ++-- .../StoreGenerationRecoveryTests.swift | 65 ++++++++++++------- 2 files changed, 50 insertions(+), 29 deletions(-) diff --git a/Packages/GanchoKit/Sources/GanchoKit/StoreGenerationRecovery.swift b/Packages/GanchoKit/Sources/GanchoKit/StoreGenerationRecovery.swift index 699dc3f4..503e1a2a 100644 --- a/Packages/GanchoKit/Sources/GanchoKit/StoreGenerationRecovery.swift +++ b/Packages/GanchoKit/Sources/GanchoKit/StoreGenerationRecovery.swift @@ -8,7 +8,7 @@ final class StoreGenerationLease: @unchecked Sendable { init(in directory: URL, exclusive: Bool) throws { let path = directory.appendingPathComponent(".store-generation.lock").path - descriptor = open(path, O_CREAT | O_RDWR | O_CLOEXEC, 0o600) + descriptor = open(path, O_CREAT | O_RDWR | O_CLOEXEC | O_NOFOLLOW, 0o600) guard descriptor >= 0 else { throw POSIXError(.EIO) } guard flock(descriptor, (exclusive ? LOCK_EX : LOCK_SH) | LOCK_NB) == 0 else { let failure = errno @@ -37,7 +37,9 @@ final class StoreGenerationLease: @unchecked Sendable { /// A partial or ambiguous generation fails closed; it is never opened as empty. enum StoreGenerationRecovery { static let journalName = ".store-recovery.json" - static let members = ["gancho.sqlite", "gancho.sqlite-wal", "gancho.sqlite-shm", "blobs"] + static let members = [ + "gancho.sqlite", "gancho.sqlite-wal", "gancho.sqlite-shm", "gancho.sqlite.encrypting", "blobs" + ] private struct Journal: Codable { var archive: String @@ -77,7 +79,9 @@ enum StoreGenerationRecovery { let archive = directory.appendingPathComponent(archiveName, isDirectory: true) try manager.createDirectory( at: archive, withIntermediateDirectories: false, attributes: [.posixPermissions: 0o700]) - let present = members.filter { manager.fileExists(atPath: directory.appendingPathComponent($0).path) } + let present = members.filter { + manager.fileExists(atPath: directory.appendingPathComponent($0).path) + } guard present.contains("gancho.sqlite") else { throw CocoaError(.fileReadNoSuchFile) } let journal = Journal(archive: archiveName, members: present) try JSONEncoder().encode(journal).write(to: journalURL, options: .atomic) @@ -101,8 +105,8 @@ enum StoreGenerationRecovery { journal.members.allSatisfy({ members.contains($0) }) else { throw CocoaError(.fileReadCorruptFile) } let archive = directory.appendingPathComponent(journal.archive, isDirectory: true) - guard (try archive.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey])).isDirectory == true, - (try archive.resourceValues(forKeys: [.isSymbolicLinkKey])).isSymbolicLink != true + let attributes = try archive.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]) + guard attributes.isDirectory == true, attributes.isSymbolicLink != true else { throw CocoaError(.fileReadCorruptFile) } for name in journal.members { let source = directory.appendingPathComponent(name) diff --git a/Packages/GanchoKit/Tests/GanchoKitTests/StoreGenerationRecoveryTests.swift b/Packages/GanchoKit/Tests/GanchoKitTests/StoreGenerationRecoveryTests.swift index ecbb17a3..73af6e87 100644 --- a/Packages/GanchoKit/Tests/GanchoKitTests/StoreGenerationRecoveryTests.swift +++ b/Packages/GanchoKit/Tests/GanchoKitTests/StoreGenerationRecoveryTests.swift @@ -11,9 +11,10 @@ struct StoreGenerationRecoveryTests { let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) try FileManager.default.createDirectory( at: root.appendingPathComponent("blobs/thumbnails"), withIntermediateDirectories: true) - for name in ["gancho.sqlite", "gancho.sqlite-wal", "gancho.sqlite-shm", "blobs/hash", - "blobs/thumbnails/hash.png"] - { + for name in [ + "gancho.sqlite", "gancho.sqlite-wal", "gancho.sqlite-shm", "gancho.sqlite.encrypting", + "blobs/hash", "blobs/thumbnails/hash.png" + ] { try Data(name.utf8).write(to: root.appendingPathComponent(name)) } return root @@ -25,22 +26,27 @@ struct StoreGenerationRecoveryTests { let root = try fixture() defer { try? FileManager.default.removeItem(at: root) } #expect(throws: Interrupted.self) { - try StoreGenerationRecovery.archive(in: root, suffix: "fixture", afterMove: { + try StoreGenerationRecovery.archive(in: root, suffix: "fixture") { if $0 == interruptedName { throw Interrupted() } - }) + } } - #expect(FileManager.default.fileExists( - atPath: root.appendingPathComponent(StoreGenerationRecovery.journalName).path)) + let journal = root.appendingPathComponent(StoreGenerationRecovery.journalName) + #expect(FileManager.default.fileExists(atPath: journal.path)) let lease = try StoreGenerationRecovery.openLease(in: root) defer { lease.release() } let archived = root.appendingPathComponent(".unreadable-fixture") for member in StoreGenerationRecovery.members { - #expect(!FileManager.default.fileExists(atPath: root.appendingPathComponent(member).path)) - #expect(FileManager.default.fileExists(atPath: archived.appendingPathComponent(member).path)) + let sourceExists = FileManager.default.fileExists( + atPath: root.appendingPathComponent(member).path) + let archivedExists = FileManager.default.fileExists( + atPath: archived.appendingPathComponent(member).path) + #expect(!sourceExists && archivedExists) } - #expect(try Data(contentsOf: archived.appendingPathComponent("blobs/hash")) == Data("blobs/hash".utf8)) - #expect(try Data(contentsOf: archived.appendingPathComponent("blobs/thumbnails/hash.png")) - == Data("blobs/thumbnails/hash.png".utf8)) + let blob = try Data(contentsOf: archived.appendingPathComponent("blobs/hash")) + let thumbnail = try Data( + contentsOf: archived.appendingPathComponent("blobs/thumbnails/hash.png")) + #expect(blob == Data("blobs/hash".utf8)) + #expect(thumbnail == Data("blobs/thumbnails/hash.png".utf8)) } } @@ -52,36 +58,47 @@ struct StoreGenerationRecoveryTests { #expect(throws: (any Error).self) { try StoreGenerationRecovery.archive(in: root, suffix: "blocked") } - #expect(try Data(contentsOf: root.appendingPathComponent("gancho.sqlite")) == Data("gancho.sqlite".utf8)) + let original = try Data(contentsOf: root.appendingPathComponent("gancho.sqlite")) + #expect(original == Data("gancho.sqlite".utf8)) shared.release() try StoreGenerationRecovery.archive(in: root, suffix: "allowed") - #expect(FileManager.default.fileExists(atPath: root.appendingPathComponent(".unreadable-allowed/blobs/hash").path)) + let preserved = root.appendingPathComponent(".unreadable-allowed/blobs/hash") + #expect(FileManager.default.fileExists(atPath: preserved.path)) } #if SQLITE_HAS_CODEC @Test("Fresh-key binary recapture is readable; old generation survives maintenance") func binaryRecovery() async throws { - let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: root) } let oldKey = String(repeating: "a1", count: 32) let newKey = String(repeating: "b2", count: 32) let payload = Data("old-generation-binary-payload".utf8) let original = ClipItem(kind: .image, preview: "fixture", contentHash: "original") - var old: GRDBClipboardStore? = try GRDBClipboardStore(directory: root, passphrase: oldKey) - try await old?.insert(original, content: .binary(data: payload, typeIdentifier: "public.data")) + var old: GRDBClipboardStore? = try GRDBClipboardStore( + directory: root, passphrase: oldKey) + let content = ClipContent.binary(data: payload, typeIdentifier: "public.data") + try await old?.insert(original, content: content) old = nil let oldBlob = root.appendingPathComponent("blobs/\(GanchoArchive.sha256(payload))") let oldBytes = try Data(contentsOf: oldBlob) - let recovered = try GRDBClipboardStore.openEncrypted(directory: root, key: newKey, keyIsFresh: true) + let recovered = try GRDBClipboardStore.openEncrypted( + directory: root, key: newKey, keyIsFresh: true) let recaptured = ClipItem(kind: .image, preview: "fixture", contentHash: "recaptured") - try await recovered.insert(recaptured, content: .binary(data: payload, typeIdentifier: "public.data")) - #expect(try await recovered.content(for: recaptured.id) == .binary(data: payload, typeIdentifier: "public.data")) + try await recovered.insert(recaptured, content: content) + #expect(try await recovered.content(for: recaptured.id) == content) _ = try await recovered.removeOrphanedBlobs() - let archive = try #require(try FileManager.default.contentsOfDirectory( - at: root, includingPropertiesForKeys: nil).first { $0.lastPathComponent.hasPrefix(".unreadable-") }) - #expect(try Data(contentsOf: archive.appendingPathComponent("blobs/\(GanchoArchive.sha256(payload))")) == oldBytes) + let children = try FileManager.default.contentsOfDirectory( + at: root, includingPropertiesForKeys: nil) + let archive = try #require(children.first { + $0.lastPathComponent.hasPrefix(".unreadable-") + }) + let archivedBytes = try Data( + contentsOf: archive.appendingPathComponent("blobs/\(GanchoArchive.sha256(payload))")) + #expect(archivedBytes == oldBytes) let preserved = try GRDBClipboardStore(directory: archive, passphrase: oldKey) - #expect(try await preserved.content(for: original.id) == .binary(data: payload, typeIdentifier: "public.data")) + #expect(try await preserved.content(for: original.id) == content) } #endif } From 0fe22f5e2e97692854bb9eac0057fc9a6a01f1bd Mon Sep 17 00:00:00 2001 From: Johnny IV Young Ospino Date: Mon, 5 Oct 2026 16:33:55 -0500 Subject: [PATCH 03/14] style(storage): keep recovery fixtures within formatter width --- .../GanchoKit/Sources/GanchoKit/StoreGenerationRecovery.swift | 3 ++- .../Tests/GanchoKitTests/StoreGenerationRecoveryTests.swift | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/Packages/GanchoKit/Sources/GanchoKit/StoreGenerationRecovery.swift b/Packages/GanchoKit/Sources/GanchoKit/StoreGenerationRecovery.swift index 503e1a2a..ea1e8e55 100644 --- a/Packages/GanchoKit/Sources/GanchoKit/StoreGenerationRecovery.swift +++ b/Packages/GanchoKit/Sources/GanchoKit/StoreGenerationRecovery.swift @@ -38,7 +38,8 @@ final class StoreGenerationLease: @unchecked Sendable { enum StoreGenerationRecovery { static let journalName = ".store-recovery.json" static let members = [ - "gancho.sqlite", "gancho.sqlite-wal", "gancho.sqlite-shm", "gancho.sqlite.encrypting", "blobs" + "gancho.sqlite", "gancho.sqlite-wal", "gancho.sqlite-shm", + "gancho.sqlite.encrypting", "blobs" ] private struct Journal: Codable { diff --git a/Packages/GanchoKit/Tests/GanchoKitTests/StoreGenerationRecoveryTests.swift b/Packages/GanchoKit/Tests/GanchoKitTests/StoreGenerationRecoveryTests.swift index 73af6e87..b82d4e19 100644 --- a/Packages/GanchoKit/Tests/GanchoKitTests/StoreGenerationRecoveryTests.swift +++ b/Packages/GanchoKit/Tests/GanchoKitTests/StoreGenerationRecoveryTests.swift @@ -94,8 +94,8 @@ struct StoreGenerationRecoveryTests { let archive = try #require(children.first { $0.lastPathComponent.hasPrefix(".unreadable-") }) - let archivedBytes = try Data( - contentsOf: archive.appendingPathComponent("blobs/\(GanchoArchive.sha256(payload))")) + let archivedBlob = archive.appendingPathComponent("blobs/\(GanchoArchive.sha256(payload))") + let archivedBytes = try Data(contentsOf: archivedBlob) #expect(archivedBytes == oldBytes) let preserved = try GRDBClipboardStore(directory: archive, passphrase: oldKey) #expect(try await preserved.content(for: original.id) == content) From 9a5ead682c3cd6bac6162c81ecbc33f48f2b0c87 Mon Sep 17 00:00:00 2001 From: Johnny IV Young Ospino Date: Mon, 5 Oct 2026 17:06:13 -0500 Subject: [PATCH 04/14] style(storage): resolve native recovery formatter diagnostics --- .../Sources/GanchoKit/StoreGenerationRecovery.swift | 5 ++--- .../GanchoKitTests/StoreGenerationRecoveryTests.swift | 10 ++++++---- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/Packages/GanchoKit/Sources/GanchoKit/StoreGenerationRecovery.swift b/Packages/GanchoKit/Sources/GanchoKit/StoreGenerationRecovery.swift index ea1e8e55..b0b5c218 100644 --- a/Packages/GanchoKit/Sources/GanchoKit/StoreGenerationRecovery.swift +++ b/Packages/GanchoKit/Sources/GanchoKit/StoreGenerationRecovery.swift @@ -49,9 +49,8 @@ enum StoreGenerationRecovery { static func openLease(in directory: URL) throws -> StoreGenerationLease { let lease = try StoreGenerationLease(in: directory, exclusive: false) - guard FileManager.default.fileExists( - atPath: directory.appendingPathComponent(journalName).path) - else { return lease } + let journalURL = directory.appendingPathComponent(journalName) + guard FileManager.default.fileExists(atPath: journalURL.path) else { return lease } // Release the shared descriptor before acquiring exclusive ownership. lease.release() return try resumeLease(in: directory) diff --git a/Packages/GanchoKit/Tests/GanchoKitTests/StoreGenerationRecoveryTests.swift b/Packages/GanchoKit/Tests/GanchoKitTests/StoreGenerationRecoveryTests.swift index b82d4e19..6628b3ce 100644 --- a/Packages/GanchoKit/Tests/GanchoKitTests/StoreGenerationRecoveryTests.swift +++ b/Packages/GanchoKit/Tests/GanchoKitTests/StoreGenerationRecoveryTests.swift @@ -81,7 +81,8 @@ struct StoreGenerationRecoveryTests { let content = ClipContent.binary(data: payload, typeIdentifier: "public.data") try await old?.insert(original, content: content) old = nil - let oldBlob = root.appendingPathComponent("blobs/\(GanchoArchive.sha256(payload))") + let blobName = GanchoArchive.sha256(payload) + let oldBlob = root.appendingPathComponent("blobs/\(blobName)") let oldBytes = try Data(contentsOf: oldBlob) let recovered = try GRDBClipboardStore.openEncrypted( directory: root, key: newKey, keyIsFresh: true) @@ -91,10 +92,11 @@ struct StoreGenerationRecoveryTests { _ = try await recovered.removeOrphanedBlobs() let children = try FileManager.default.contentsOfDirectory( at: root, includingPropertiesForKeys: nil) - let archive = try #require(children.first { + let archivedGeneration = children.first { $0.lastPathComponent.hasPrefix(".unreadable-") - }) - let archivedBlob = archive.appendingPathComponent("blobs/\(GanchoArchive.sha256(payload))") + } + let archive = try #require(archivedGeneration) + let archivedBlob = archive.appendingPathComponent("blobs/\(blobName)") let archivedBytes = try Data(contentsOf: archivedBlob) #expect(archivedBytes == oldBytes) let preserved = try GRDBClipboardStore(directory: archive, passphrase: oldKey) From d9f7ab8c8b4ef20c20d63c17839aa17e31e391f9 Mon Sep 17 00:00:00 2001 From: Johnny IV Young Ospino Date: Mon, 5 Oct 2026 18:01:32 -0500 Subject: [PATCH 05/14] fix(storage): keep durable ownership safe across iOS suspension --- .../GanchoKit/DatabaseSuspension.swift | 7 + .../GanchoKit/GRDBClipboardStore.swift | 4 +- .../Sources/GanchoKit/RawKeyAdoption.swift | 4 +- .../GanchoKit/StoreGenerationRecovery.swift | 73 ++--- .../GanchoKit/StoreProcessOwnership.swift | 276 ++++++++++++++++++ .../DatabaseSuspensionTests.swift | 6 +- .../StoreGenerationRecoveryTests.swift | 28 +- .../StoreProcessOwnershipTests.swift | 185 ++++++++++++ docs/STORE_RECOVERY.md | 68 +++++ 9 files changed, 610 insertions(+), 41 deletions(-) create mode 100644 Packages/GanchoKit/Sources/GanchoKit/StoreProcessOwnership.swift create mode 100644 Packages/GanchoKit/Tests/GanchoKitTests/StoreProcessOwnershipTests.swift create mode 100644 docs/STORE_RECOVERY.md diff --git a/Packages/GanchoKit/Sources/GanchoKit/DatabaseSuspension.swift b/Packages/GanchoKit/Sources/GanchoKit/DatabaseSuspension.swift index ff409ec5..ff2092e8 100644 --- a/Packages/GanchoKit/Sources/GanchoKit/DatabaseSuspension.swift +++ b/Packages/GanchoKit/Sources/GanchoKit/DatabaseSuspension.swift @@ -18,6 +18,9 @@ public enum DatabaseSuspension { /// releases its locks; any in-flight write fails with `SQLITE_INTERRUPT` or /// `SQLITE_ABORT` rather than risking the `0xDEAD10CC` termination. public static func suspend() { + #if os(iOS) + StoreOwnershipLifecycle.shared.suspend() + #endif NotificationCenter.default.post(name: Database.suspendNotification, object: nil) } @@ -25,5 +28,9 @@ public enum DatabaseSuspension { /// databases start accepting writes again. public static func resume() { NotificationCenter.default.post(name: Database.resumeNotification, object: nil) + #if os(iOS) + StoreOwnershipLifecycle.shared.resume() + StoreOwnershipLifecycle.shared.flushPending() + #endif } } diff --git a/Packages/GanchoKit/Sources/GanchoKit/GRDBClipboardStore.swift b/Packages/GanchoKit/Sources/GanchoKit/GRDBClipboardStore.swift index 28489730..6573b203 100644 --- a/Packages/GanchoKit/Sources/GanchoKit/GRDBClipboardStore.swift +++ b/Packages/GanchoKit/Sources/GanchoKit/GRDBClipboardStore.swift @@ -35,7 +35,8 @@ public final class GRDBClipboardStore: ClipboardStore, ClipImporting { /// path used by tests and the perf harness. public convenience init(directory: URL, passphrase: String? = nil) throws { try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - let generationLease = try StoreGenerationRecovery.openLease(in: directory) + let generationLease = try StoreGenerationRecovery.openLease( + in: directory, checkingPlaintextConversion: passphrase != nil) let dbPath = directory.appendingPathComponent("gancho.sqlite").path var configuration = Configuration() @@ -88,6 +89,7 @@ public final class GRDBClipboardStore: ClipboardStore, ClipImporting { directory: directory.appendingPathComponent("blobs"), encryptionKeyData: blobEncryptionKeyData) try blobStore.encryptPlaintextFilesIfNeeded() + try generationLease.downgrade() self.init( writer: pool, blobs: blobStore, generationLease: generationLease) diff --git a/Packages/GanchoKit/Sources/GanchoKit/RawKeyAdoption.swift b/Packages/GanchoKit/Sources/GanchoKit/RawKeyAdoption.swift index 58cae791..d8adbb71 100644 --- a/Packages/GanchoKit/Sources/GanchoKit/RawKeyAdoption.swift +++ b/Packages/GanchoKit/Sources/GanchoKit/RawKeyAdoption.swift @@ -48,7 +48,8 @@ import GRDB ) throws -> GRDBClipboardStore { try FileManager.default.createDirectory( at: directory, withIntermediateDirectories: true) - let generationLease = try StoreGenerationRecovery.openLease(in: directory) + let generationLease = try StoreGenerationRecovery.openLease( + in: directory, checkingPlaintextConversion: true) let dbPath = directory.appendingPathComponent("gancho.sqlite").path var configuration = Configuration() @@ -77,6 +78,7 @@ import GRDB // x'…' literal — or every sealed blob/thumbnail is lost. encryptionKeyData: BlobStore.encryptionKeyData(for: passphrase)) try blobStore.encryptPlaintextFilesIfNeeded() + try generationLease.downgrade() let store = GRDBClipboardStore( writer: pool, blobs: blobStore, generationLease: generationLease) return store diff --git a/Packages/GanchoKit/Sources/GanchoKit/StoreGenerationRecovery.swift b/Packages/GanchoKit/Sources/GanchoKit/StoreGenerationRecovery.swift index b0b5c218..8eb16735 100644 --- a/Packages/GanchoKit/Sources/GanchoKit/StoreGenerationRecovery.swift +++ b/Packages/GanchoKit/Sources/GanchoKit/StoreGenerationRecovery.swift @@ -1,35 +1,21 @@ -import Darwin import Foundation -/// An open database pins its filesystem generation. Recovery requires an -/// exclusive cross-process lease, so it cannot move bytes beneath another pool. +/// An open database registers its filesystem generation durably. Recovery +/// requires exclusive logical ownership, not a file lock held during suspension. final class StoreGenerationLease: @unchecked Sendable { - private var descriptor: Int32 + private let ownership: StoreProcessOwnership + let directory: URL init(in directory: URL, exclusive: Bool) throws { - let path = directory.appendingPathComponent(".store-generation.lock").path - descriptor = open(path, O_CREAT | O_RDWR | O_CLOEXEC | O_NOFOLLOW, 0o600) - guard descriptor >= 0 else { throw POSIXError(.EIO) } - guard flock(descriptor, (exclusive ? LOCK_EX : LOCK_SH) | LOCK_NB) == 0 else { - let failure = errno - close(descriptor) - descriptor = -1 - throw POSIXError(POSIXErrorCode(rawValue: failure) ?? .EBUSY) - } - } - - func downgrade() throws { - guard flock(descriptor, LOCK_SH | LOCK_NB) == 0 else { throw POSIXError(.EBUSY) } - } - - func release() { - guard descriptor >= 0 else { return } - _ = flock(descriptor, LOCK_UN) - close(descriptor) - descriptor = -1 + guard let ownership = try StoreProcessOwnership.acquire( + in: directory, scope: .generation, exclusive: exclusive) + else { throw StoreProcessOwnership.Failure.busy } + self.ownership = ownership + self.directory = directory.standardizedFileURL.resolvingSymlinksInPath() } - deinit { release() } + func downgrade() throws { try ownership.downgrade() } + func release() { ownership.release() } } /// Preserves a whole database/blob/thumbnail generation before creating a new @@ -47,20 +33,35 @@ enum StoreGenerationRecovery { var members: [String] } - static func openLease(in directory: URL) throws -> StoreGenerationLease { - let lease = try StoreGenerationLease(in: directory, exclusive: false) + static func openLease( + in directory: URL, checkingPlaintextConversion: Bool = false + ) throws -> StoreGenerationLease { + var lease = try StoreGenerationLease(in: directory, exclusive: false) let journalURL = directory.appendingPathComponent(journalName) - guard FileManager.default.fileExists(atPath: journalURL.path) else { return lease } - // Release the shared descriptor before acquiring exclusive ownership. - lease.release() - return try resumeLease(in: directory) + let pending = FileManager.default.fileExists(atPath: journalURL.path) + let converting = try checkingPlaintextConversion && needsPlaintextConversion(in: directory) + if pending || converting { + lease.release() + lease = try StoreGenerationLease(in: directory, exclusive: true) + // Recheck after exclusive acquisition: another opener may have + // completed recovery while this shared registration was released. + if FileManager.default.fileExists(atPath: journalURL.path) { + try resume(in: directory) + } + if !checkingPlaintextConversion { try lease.downgrade() } + } + return lease } - private static func resumeLease(in directory: URL) throws -> StoreGenerationLease { - let exclusive = try StoreGenerationLease(in: directory, exclusive: true) - try resume(in: directory) - try exclusive.downgrade() - return exclusive + private static func needsPlaintextConversion(in directory: URL) throws -> Bool { + let database = directory.appendingPathComponent("gancho.sqlite") + let manager = FileManager.default + if !manager.fileExists(atPath: database.path) { + return true // Creation can race a plaintext opener too. + } + let handle = try FileHandle(forReadingFrom: database) + defer { try? handle.close() } + return try handle.read(upToCount: 16) == Data("SQLite format 3\u{0}".utf8) } static func archive( diff --git a/Packages/GanchoKit/Sources/GanchoKit/StoreProcessOwnership.swift b/Packages/GanchoKit/Sources/GanchoKit/StoreProcessOwnership.swift new file mode 100644 index 00000000..82f32046 --- /dev/null +++ b/Packages/GanchoKit/Sources/GanchoKit/StoreProcessOwnership.swift @@ -0,0 +1,276 @@ +import Darwin +import Foundation +import GRDB + +/// Durable logical ownership, not a kernel lock. Transactions contain only +/// metadata; no transaction/file lock survives a return or an async suspension. +/// The sidecar holds no key, clip content, hash or path from a clipboard item. +final class StoreProcessOwnership: @unchecked Sendable { + enum Scope: String, Sendable, Equatable { case generation, blob } + enum ProcessState: Sendable, Equatable { case live, exited, unverifiable } + enum Failure: Error { case busy, suspended, malformedMetadata } + + static let fileName = ".store-ownership.sqlite" + static let processToken = UUID().uuidString + struct Identity: Sendable { + var pid: Int32 + var token: String + static var current: Self { Self(pid: getpid(), token: processToken) } + } + private struct Owner: Decodable, FetchableRecord { + var token: String + var processToken: String + var pid: Int64 + var scope: String + var exclusive: Int + } + private let queue: DatabaseQueue + private let lifecycle: StoreOwnershipLifecycle + private let directory: URL + private let token: String + private let owner: String + private let mutex = NSLock() + private var released = false + + private init( + queue: DatabaseQueue, directory: URL, token: String, owner: String, + lifecycle: StoreOwnershipLifecycle + ) { + self.queue = queue + self.lifecycle = lifecycle + self.directory = directory + self.token = token + self.owner = owner + } + + static func acquire( + in directory: URL, scope: Scope, exclusive: Bool, + identity: Identity = .current, + probe: @Sendable (Int32) -> ProcessState = processState, + lifecycle: StoreOwnershipLifecycle = .shared + ) throws -> StoreProcessOwnership? { + let directory = directory.standardizedFileURL.resolvingSymlinksInPath() + let pid = identity.pid + let owner = identity.token + guard pid > 0, UUID(uuidString: owner) != nil else { throw Failure.malformedMetadata } + return try lifecycle.withActive { + let queue = try makeQueue(in: directory) + var retained = false + defer { if !retained { lifecycle.closeOrDefer(queue) } } + let token = UUID().uuidString + let acquired = try queue.write { db in + try prepareSchema(db) + try lifecycle.drainPending(in: db, directory: directory) + let rows = try Owner.fetchAll( + db, sql: "SELECT token, processToken, pid, scope, exclusive FROM owner") + for row in rows { + let storedPID = row.pid + let storedToken = row.token + let storedOwner = row.processToken + let storedScope = row.scope + let storedExclusive = row.exclusive + guard storedPID > 0, storedPID <= Int64(Int32.max), + UUID(uuidString: storedToken) != nil, UUID(uuidString: storedOwner) != nil, + let otherScope = Scope(rawValue: storedScope), + storedExclusive == 0 || storedExclusive == 1 + else { throw Failure.malformedMetadata } + if probe(Int32(storedPID)) == .exited { + try db.execute( + sql: "DELETE FROM owner WHERE token = ?", arguments: [storedToken]) + continue + } + // Recovery is exclusive over both scopes. Blob work also + // checks recovery, including injected stores without a pin. + let recovering = scope == .generation && exclusive + let otherRecovering = otherScope == .generation && storedExclusive == 1 + if recovering || otherRecovering + || (scope == .blob && otherScope == .blob) + { return false } + } + try db.execute( + sql: "INSERT INTO owner VALUES (?, ?, ?, ?, ?)", + arguments: [token, owner, pid, scope.rawValue, exclusive ? 1 : 0]) + return true + } + lifecycle.didCommitPending(in: directory) + guard acquired else { return nil } + retained = true + return StoreProcessOwnership( + queue: queue, directory: directory, token: token, owner: owner, + lifecycle: lifecycle) + } + } + + func downgrade() throws { + mutex.lock() + defer { mutex.unlock() } + guard !released else { throw Failure.malformedMetadata } + try lifecycle.withActive { + try queue.write { db in + try db.execute( + sql: "UPDATE owner SET exclusive = 0 WHERE token = ? AND processToken = ?", + arguments: [token, owner]) + guard db.changesCount == 1 else { throw Failure.malformedMetadata } + } + } + } + + func release() { + mutex.lock() + defer { mutex.unlock() } + guard !released else { return } + released = true + // Only a finished operation enters pending release. A suspended or + // interrupted metadata write preserves the claim until a safe retry. + lifecycle.release( + token: token, owner: owner, in: directory, queue: queue) + } + + deinit { release() } + + static func processState(_ pid: Int32) -> ProcessState { + guard pid > 0 else { return .unverifiable } + // Signal zero sends no signal. ESRCH is the only documented proof of + // absence; sandbox/permission errors and PID reuse keep the claim. + if kill(pid, 0) == 0 { return .live } + return errno == ESRCH ? .exited : .unverifiable + } + + fileprivate static func makeQueue(in directory: URL) throws -> DatabaseQueue { + var configuration = Configuration() + configuration.busyMode = .timeout(0.1) + configuration.automaticMemoryManagement = false + configuration.journalMode = .wal + #if os(iOS) + configuration.observesSuspensionNotifications = true + #endif + let path = directory.appendingPathComponent(fileName).path + return try DatabaseQueue(path: path, configuration: configuration) + } + + private static func prepareSchema(_ db: Database) throws { + let version = try Int.fetchOne(db, sql: "PRAGMA user_version") ?? 0 + guard version == 0 || version == 1 else { throw Failure.malformedMetadata } + let schema = """ + CREATE TABLE owner ( + token TEXT PRIMARY KEY NOT NULL, + processToken TEXT NOT NULL, + pid INTEGER NOT NULL CHECK (pid > 0 AND pid <= 2147483647), + scope TEXT NOT NULL CHECK (scope IN ('generation', 'blob')), + exclusive INTEGER NOT NULL CHECK (exclusive IN (0, 1)) + ) + """ + let existing = try String.fetchOne( + db, sql: "SELECT sql FROM sqlite_master WHERE name = 'owner' AND type = 'table'") + if version == 0 { + let objects = try Int.fetchOne( + db, sql: "SELECT COUNT(*) FROM sqlite_master WHERE name NOT LIKE 'sqlite_%'") ?? 0 + guard existing == nil, objects == 0 else { throw Failure.malformedMetadata } + try db.execute(sql: schema) + try db.execute(sql: "PRAGMA user_version = 1") + } else { + // Never recreate a missing/versioned ledger or accept a different + // schema: that could erase knowledge of an active logical owner. + guard let existing, + existing.split(whereSeparator: \.isWhitespace) + == schema.split(whereSeparator: \.isWhitespace) + else { throw Failure.malformedMetadata } + } + } + +} + +/// Serializes only short coordinator transactions against the iOS suspension +/// boundary. suspend() cannot return with such a transaction still in flight; +/// opening a new observer after suspension is rejected too. Logical ownership +/// remains durable while the process is suspended, without holding a file lock. +final class StoreOwnershipLifecycle: @unchecked Sendable { + static let shared = StoreOwnershipLifecycle() + private struct Release: Hashable { var token: String; var owner: String } + private let mutex = NSLock() + private var suspended = false + private var pending: [URL: Set] = [:] + private var pendingQueues: [URL: [DatabaseQueue]] = [:] + private var deferredClose: [DatabaseQueue] = [] + + func withActive(_ body: () throws -> T) throws -> T { + mutex.lock() + defer { mutex.unlock() } + guard !suspended else { throw StoreProcessOwnership.Failure.suspended } + let closing = deferredClose + deferredClose = [] + for queue in closing { closeOrDefer(queue) } + return try body() + } + + func suspend() { + mutex.lock() + suspended = true + mutex.unlock() + } + + func resume() { + mutex.lock() + suspended = false + mutex.unlock() + } + + fileprivate func drainPending(in db: Database, directory: URL) throws { + // Caller already holds the lifecycle fence and a writer transaction. + // The fence prevents concurrent additions. Acknowledge this exact + // pending set only after the writer call has successfully committed. + for release in pending[directory] ?? [] { + try db.execute( + sql: "DELETE FROM owner WHERE token = ? AND processToken = ?", + arguments: [release.token, release.owner]) + } + } + + fileprivate func didCommitPending(in directory: URL) { + pending.removeValue(forKey: directory) + for queue in pendingQueues.removeValue(forKey: directory) ?? [] { + closeOrDefer(queue) + } + } + + fileprivate func closeOrDefer(_ queue: DatabaseQueue) { + // The caller holds the active fence. WAL checkpoint-on-close must + // finish here too, not in a later property deinitialization. + do { try queue.close() } catch { deferredClose.append(queue) } + } + + fileprivate func release( + token: String, owner: String, in directory: URL, queue: DatabaseQueue + ) { + mutex.lock() + defer { mutex.unlock() } + pending[directory, default: []].insert(Release(token: token, owner: owner)) + pendingQueues[directory, default: []].append(queue) + guard !suspended else { return } + flush(in: directory, queue: queue) + } + + private func flush(in directory: URL, queue: DatabaseQueue) { + do { + try queue.write { db in try drainPending(in: db, directory: directory) } + didCommitPending(in: directory) + } catch { + // No bytes are deleted and the exact known-finished claim is retried. + } + } + + func flushPending() { + mutex.lock() + defer { mutex.unlock() } + guard !suspended else { return } + let closing = deferredClose + deferredClose = [] + for queue in closing { closeOrDefer(queue) } + for directory in Array(pending.keys).prefix(128) { + if let queue = try? StoreProcessOwnership.makeQueue(in: directory) { + flush(in: directory, queue: queue) + closeOrDefer(queue) + } + } + } +} diff --git a/Packages/GanchoKit/Tests/GanchoKitTests/DatabaseSuspensionTests.swift b/Packages/GanchoKit/Tests/GanchoKitTests/DatabaseSuspensionTests.swift index 9f5c623c..3ff02653 100644 --- a/Packages/GanchoKit/Tests/GanchoKitTests/DatabaseSuspensionTests.swift +++ b/Packages/GanchoKit/Tests/GanchoKitTests/DatabaseSuspensionTests.swift @@ -5,11 +5,13 @@ import Testing @testable import GanchoKit /// The seam just bridges to GRDB's notifications; the actual lock-release -/// behavior is GRDB's, verified on device. Here we prove we post the right ones. -@Suite("DatabaseSuspension — GRDB notification seam") +/// behavior remains a physical-device qualification gate. Here we prove the +/// notification seam and restore lifecycle state after every test. +@Suite("DatabaseSuspension — GRDB notification seam", .serialized) struct DatabaseSuspensionTests { @Test("suspend() posts GRDB's suspend notification") func suspendPostsNotification() async { + defer { DatabaseSuspension.resume() } await confirmation { confirmed in let token = NotificationCenter.default.addObserver( forName: Database.suspendNotification, object: nil, queue: nil diff --git a/Packages/GanchoKit/Tests/GanchoKitTests/StoreGenerationRecoveryTests.swift b/Packages/GanchoKit/Tests/GanchoKitTests/StoreGenerationRecoveryTests.swift index 6628b3ce..45b48188 100644 --- a/Packages/GanchoKit/Tests/GanchoKitTests/StoreGenerationRecoveryTests.swift +++ b/Packages/GanchoKit/Tests/GanchoKitTests/StoreGenerationRecoveryTests.swift @@ -50,7 +50,7 @@ struct StoreGenerationRecoveryTests { } } - @Test("An active generation blocks recovery across independent file descriptors") + @Test("An active generation blocks recovery across independent coordinator connections") func activeGenerationCannotBeMoved() throws { let root = try fixture() defer { try? FileManager.default.removeItem(at: root) } @@ -66,6 +66,32 @@ struct StoreGenerationRecoveryTests { #expect(FileManager.default.fileExists(atPath: preserved.path)) } + @Test("Plaintext and fresh namespace initialization require exclusive ownership") + func conversionCannotMoveAnActivePool() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let fresh = try StoreGenerationRecovery.openLease( + in: root, checkingPlaintextConversion: true) + #expect(throws: StoreProcessOwnership.Failure.self) { + try StoreGenerationRecovery.openLease(in: root) + } + let header = Data("SQLite format 3\u{0}".utf8) + try header.write(to: root.appendingPathComponent("gancho.sqlite")) + try fresh.downgrade() + #expect(throws: StoreProcessOwnership.Failure.self) { + try StoreGenerationRecovery.openLease(in: root, checkingPlaintextConversion: true) + } + fresh.release() + let conversion = try StoreGenerationRecovery.openLease( + in: root, checkingPlaintextConversion: true) + defer { conversion.release() } + #expect(throws: StoreProcessOwnership.Failure.self) { + try StoreGenerationRecovery.openLease(in: root) + } + } + #if SQLITE_HAS_CODEC @Test("Fresh-key binary recapture is readable; old generation survives maintenance") func binaryRecovery() async throws { diff --git a/Packages/GanchoKit/Tests/GanchoKitTests/StoreProcessOwnershipTests.swift b/Packages/GanchoKit/Tests/GanchoKitTests/StoreProcessOwnershipTests.swift new file mode 100644 index 00000000..fef9b518 --- /dev/null +++ b/Packages/GanchoKit/Tests/GanchoKitTests/StoreProcessOwnershipTests.swift @@ -0,0 +1,185 @@ +import Foundation +import GRDB +import Testing + +@testable import GanchoKit + +@Suite("Durable ownership — suspension, identity, and crash boundaries") +struct StoreProcessOwnershipTests { + private func directory() throws -> URL { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory + } + + @Test("Idle ownership has no transaction lock and its coordinator is WAL") + func idleClaimIsLogical() throws { + let root = try directory() + defer { try? FileManager.default.removeItem(at: root) } + let heldClaim = try StoreProcessOwnership.acquire( + in: root, scope: .generation, exclusive: false) + let held = try #require(heldClaim) + defer { held.release() } + let queue = try DatabaseQueue(path: root.appendingPathComponent( + StoreProcessOwnership.fileName).path) + let mode = try queue.read { db in try String.fetchOne(db, sql: "PRAGMA journal_mode") } + #expect(mode == "wal") + // A completely independent writer can commit while the logical pin is + // retained. A retained SQLite write transaction would prevent it; + // removal of the independent flock protocol is a source-level check. + try queue.write { db in try db.execute(sql: "CREATE TABLE independent (id INTEGER)") } + let blocked = try StoreProcessOwnership.acquire( + in: root, scope: .generation, exclusive: true) + #expect(blocked == nil) + blocked?.release() + } + + @Test("Recovery excludes blob work and registration until it downgrades") + func unifiedConflictBoundary() throws { + let root = try directory() + defer { try? FileManager.default.removeItem(at: root) } + let heldClaim = try StoreProcessOwnership.acquire( + in: root, scope: .generation, exclusive: true) + let held = try #require(heldClaim) + defer { held.release() } + let blob = try StoreProcessOwnership.acquire(in: root, scope: .blob, exclusive: true) + let opener = try StoreProcessOwnership.acquire( + in: root, scope: .generation, exclusive: false) + #expect(blob == nil && opener == nil) + blob?.release() + opener?.release() + try held.downgrade() + let allowedClaim = try StoreProcessOwnership.acquire( + in: root, scope: .blob, exclusive: true) + let allowed = try #require(allowedClaim) + allowed.release() + } + + @Test("Suspended, unverifiable, and reused PIDs are retained; only proven exit reclaims") + func conservativeIdentity() throws { + for state in [StoreProcessOwnership.ProcessState.live, .unverifiable, .exited] { + let root = try directory() + defer { try? FileManager.default.removeItem(at: root) } + let heldClaim = try StoreProcessOwnership.acquire( + in: root, scope: .generation, exclusive: false, + identity: .init(pid: 42, token: UUID().uuidString), probe: { _ in .live }) + let held = try #require(heldClaim) + defer { held.release() } + let contender = try StoreProcessOwnership.acquire( + in: root, scope: .generation, exclusive: true, probe: { _ in state }) + #expect((contender != nil) == (state == .exited)) + contender?.release() + } + } + + @Test("A delayed old release cannot remove the next operation's token") + func exactReleaseIdentity() throws { + let root = try directory() + defer { try? FileManager.default.removeItem(at: root) } + let oldClaim = try StoreProcessOwnership.acquire( + in: root, scope: .blob, exclusive: true, + identity: .init(pid: 42, token: UUID().uuidString)) + let old = try #require(oldClaim) + let newerClaim = try StoreProcessOwnership.acquire( + in: root, scope: .blob, exclusive: true, probe: { _ in .exited }) + let newer = try #require(newerClaim) + defer { newer.release() } + old.release() + old.release() + let contender = try StoreProcessOwnership.acquire( + in: root, scope: .blob, exclusive: true, probe: { _ in .live }) + #expect(contender == nil) + contender?.release() + } + + @Test("Lifecycle rejects late observers and retries finished-operation releases") + func lifecycleStateMachine() throws { + let root = try directory() + defer { try? FileManager.default.removeItem(at: root) } + let lifecycle = StoreOwnershipLifecycle() + let heldClaim = try StoreProcessOwnership.acquire( + in: root, scope: .blob, exclusive: true, lifecycle: lifecycle) + let held = try #require(heldClaim) + let queue = try DatabaseQueue(path: root.appendingPathComponent( + StoreProcessOwnership.fileName).path) + lifecycle.suspend() + held.release() + let retained = try queue.read { db in + try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM owner") + } + #expect(retained == 1) + #expect(throws: StoreProcessOwnership.Failure.self) { + try StoreProcessOwnership.acquire( + in: root, scope: .blob, exclusive: true, lifecycle: lifecycle) + } + lifecycle.resume() + lifecycle.flushPending() + let cleared = try queue.read { db in + try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM owner") + } + #expect(cleared == 0) + let nextClaim = try StoreProcessOwnership.acquire( + in: root, scope: .blob, exclusive: true, lifecycle: lifecycle) + let next = try #require(nextClaim) + next.release() + } + + @Test("An interrupted release keeps its token until the next committed metadata write") + func retryFailedRelease() throws { + let root = try directory() + defer { try? FileManager.default.removeItem(at: root) } + let lifecycle = StoreOwnershipLifecycle() + let heldClaim = try StoreProcessOwnership.acquire( + in: root, scope: .blob, exclusive: true, lifecycle: lifecycle) + let held = try #require(heldClaim) + let competing = try DatabaseQueue(path: root.appendingPathComponent( + StoreProcessOwnership.fileName).path) + try competing.writeWithoutTransaction { db in + try db.execute(sql: "BEGIN IMMEDIATE") + defer { try? db.execute(sql: "ROLLBACK") } + held.release() + #expect(try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM owner") == 1) + } + let nextClaim = try StoreProcessOwnership.acquire( + in: root, scope: .blob, exclusive: true, lifecycle: lifecycle) + let next = try #require(nextClaim) + next.release() + #expect(try competing.read { + db in try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM owner") + } == 0) + } + + @Test("Missing or unknown versioned coordinator schema fails closed") + func damagedCoordinatorIsNotReset() throws { + for version in [1, 2] { + let root = try directory() + defer { try? FileManager.default.removeItem(at: root) } + let queue = try DatabaseQueue(path: root.appendingPathComponent( + StoreProcessOwnership.fileName).path) + try queue.write { db in try db.execute(sql: "PRAGMA user_version = \(version)") } + #expect(throws: StoreProcessOwnership.Failure.self) { + try StoreProcessOwnership.acquire(in: root, scope: .blob, exclusive: true) + } + #expect(try queue.read { db in + try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM sqlite_master WHERE name = 'owner'") + } == 0) + } + } + + @Test("Invalid process identities never become reclamation candidates") + func invalidIdentity() throws { + let root = try directory() + defer { try? FileManager.default.removeItem(at: root) } + #expect(throws: StoreProcessOwnership.Failure.self) { + try StoreProcessOwnership.acquire( + in: root, scope: .blob, exclusive: true, + identity: .init(pid: 0, token: UUID().uuidString)) + } + #expect(throws: StoreProcessOwnership.Failure.self) { + try StoreProcessOwnership.acquire( + in: root, scope: .blob, exclusive: true, + identity: .init(pid: 42, token: "not-a-process-identity")) + } + } +} diff --git a/docs/STORE_RECOVERY.md b/docs/STORE_RECOVERY.md new file mode 100644 index 00000000..2a8e6092 --- /dev/null +++ b/docs/STORE_RECOVERY.md @@ -0,0 +1,68 @@ +# Store recovery and process ownership + +The complete database, WAL/SHM, interrupted encrypted export, blobs and thumbnails +are preserved as one journaled, resumable unreadable generation. Fresh-key recovery +never reuses an old sealed-blob namespace. + +## Suspension-safe ownership + +The stable `.store-ownership.sqlite` sidecar contains only process IDs and random +process/operation UUIDs, never clip content, blob hashes, paths from clips, or keys. +It is outside the preserved blob/database generation. Its versioned metadata schema +uses WAL. Short synchronous writer transactions atomically acquire, downgrade, or +release logical claims; no new application-owned file lock or coordinator +transaction is held during pool lifetime, filesystem moves, blob work, or `await`. + +A shared generation registration is retained by the store and by the pool's +configuration closure. Exclusive recovery conflicts with all generation and blob +claims. Registration precedes inspecting the journal/header. Pending recovery or +plaintext/absent database conversion requires exclusive ownership; the journal is +rechecked after acquiring it. The claim remains exclusive through the namespace +change and pool initialization, then downgrades to a shared registration. A failed +or interrupted move keeps its journal; the next exclusive owner resumes it before +any new pool can open that generation. + +On iOS, `DatabaseSuspension.suspend()` fences and finishes coordinator metadata +transactions before posting GRDB suspension, and rejects new coordinators opened +after that boundary. Coordinator connections also observe GRDB suspension. Logical +claims remain committed while suspended, without keeping a kernel lock. Release +uses the exact operation and process UUID; a failed/suspended release is retained +for a bounded resume cleanup pass or the next operation at that directory. +Coordinator connection close/checkpoint is also fenced; suspended/busy releases +retain their queues until safe cleanup, rather than relying on deinitialization. Only +known-finished operations enter this retry set. Cancellation never releases an +operation before its protected transaction/work has finished. + +A positive, bounded PID is checked with `kill(pid, 0)`, which sends no signal. +Only `ESRCH` permits automatic reclamation: Apple's [iOS kill(2) manual](https://developer.apple.com/library/archive/documentation/System/Conceptual/ManPages_iPhoneOS/man2/kill.2.html) +defines that result as absence of the corresponding process. Successful checks, +permission/sandbox failures, unknown errors, malformed identities, and current +live PIDs reused after exit or reboot remain fail-closed. UUID mismatch, elapsed +time, missing heartbeats, and suspension are never evidence of death. Recovery +returns busy immediately; async blob ownership has a bounded wait. A stale claim +whose PID is reused or whose liveness cannot be established may withhold availability +until that PID is provably absent. No destructive timeout/forced-reclamation path +is provided. The user-facing caller must report busy/error, not silently reset. + +## Compatibility and qualification boundary + +The main schema and sealed data format are unchanged. Older binaries ignore the +sidecar and do not participate in this protocol. Absence of registrations does not +prove an old pool is closed. Ownership safety therefore requires a quiescent, +coordinated upgrade/restart of every participant (app, extensions, CLI, MCP and +maintenance tools). Mixed-version writers or recovery are not qualified; rollout +must stop older participants before enabling the new recovery/cleanup paths. The +optional raw-key rekey remains separately gated by its existing opt-in and +quiescent/device rollout requirements; this repair does not qualify that migration. + +Deterministic metadata/state tests prove atomic claims, scope conflicts, exact-token +release, exited/unverifiable/reused identity policy, deferred cleanup and WAL idle +writes. They do not prove iOS sandbox liveness behavior or physical-device jetsam +avoidance. iOS app/keyboard/share/widget suspension, expiration, restart and reboot +stress across real App Group processes remain release gates. See Apple's +[TN2408](https://developer.apple.com/library/archive/technotes/tn2408/_index.html) +and [DTS suspension discussion](https://developer.apple.com/forums/thread/655225). + +The global suspension-notification seam should run in an isolated/serial iOS test +invocation; suite-local serialization does not isolate it from other test suites. +The deterministic ownership tests use independent injected lifecycle objects. From 758cbbe5aa4e77340cea310f976722ab832f260a Mon Sep 17 00:00:00 2001 From: Johnny IV Young Ospino Date: Mon, 5 Oct 2026 18:14:13 -0500 Subject: [PATCH 06/14] fix(storage): scope memory management to iOS and format ownership --- .../Sources/GanchoKit/StoreProcessOwnership.swift | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Packages/GanchoKit/Sources/GanchoKit/StoreProcessOwnership.swift b/Packages/GanchoKit/Sources/GanchoKit/StoreProcessOwnership.swift index 82f32046..8149978b 100644 --- a/Packages/GanchoKit/Sources/GanchoKit/StoreProcessOwnership.swift +++ b/Packages/GanchoKit/Sources/GanchoKit/StoreProcessOwnership.swift @@ -139,9 +139,9 @@ final class StoreProcessOwnership: @unchecked Sendable { fileprivate static func makeQueue(in directory: URL) throws -> DatabaseQueue { var configuration = Configuration() configuration.busyMode = .timeout(0.1) - configuration.automaticMemoryManagement = false configuration.journalMode = .wal #if os(iOS) + configuration.automaticMemoryManagement = false configuration.observesSuspensionNotifications = true #endif let path = directory.appendingPathComponent(fileName).path @@ -186,7 +186,10 @@ final class StoreProcessOwnership: @unchecked Sendable { /// remains durable while the process is suspended, without holding a file lock. final class StoreOwnershipLifecycle: @unchecked Sendable { static let shared = StoreOwnershipLifecycle() - private struct Release: Hashable { var token: String; var owner: String } + private struct Release: Hashable { + var token: String + var owner: String + } private let mutex = NSLock() private var suspended = false private var pending: [URL: Set] = [:] From b3e05342f69dd364f4a5e03f94c915a85393eeb1 Mon Sep 17 00:00:00 2001 From: Johnny IV Young Ospino Date: Mon, 5 Oct 2026 18:42:19 -0500 Subject: [PATCH 07/14] style(storage): apply native formatter ownership layout --- .../GanchoKit/StoreGenerationRecovery.swift | 4 +-- .../GanchoKit/StoreProcessOwnership.swift | 8 +++-- .../StoreProcessOwnershipTests.swift | 29 ++++++++++--------- 3 files changed, 22 insertions(+), 19 deletions(-) diff --git a/Packages/GanchoKit/Sources/GanchoKit/StoreGenerationRecovery.swift b/Packages/GanchoKit/Sources/GanchoKit/StoreGenerationRecovery.swift index 8eb16735..f474acc4 100644 --- a/Packages/GanchoKit/Sources/GanchoKit/StoreGenerationRecovery.swift +++ b/Packages/GanchoKit/Sources/GanchoKit/StoreGenerationRecovery.swift @@ -7,9 +7,9 @@ final class StoreGenerationLease: @unchecked Sendable { let directory: URL init(in directory: URL, exclusive: Bool) throws { - guard let ownership = try StoreProcessOwnership.acquire( + let acquired = try StoreProcessOwnership.acquire( in: directory, scope: .generation, exclusive: exclusive) - else { throw StoreProcessOwnership.Failure.busy } + guard let ownership = acquired else { throw StoreProcessOwnership.Failure.busy } self.ownership = ownership self.directory = directory.standardizedFileURL.resolvingSymlinksInPath() } diff --git a/Packages/GanchoKit/Sources/GanchoKit/StoreProcessOwnership.swift b/Packages/GanchoKit/Sources/GanchoKit/StoreProcessOwnership.swift index 8149978b..7e173ecc 100644 --- a/Packages/GanchoKit/Sources/GanchoKit/StoreProcessOwnership.swift +++ b/Packages/GanchoKit/Sources/GanchoKit/StoreProcessOwnership.swift @@ -85,7 +85,9 @@ final class StoreProcessOwnership: @unchecked Sendable { let otherRecovering = otherScope == .generation && storedExclusive == 1 if recovering || otherRecovering || (scope == .blob && otherScope == .blob) - { return false } + { + return false + } } try db.execute( sql: "INSERT INTO owner VALUES (?, ?, ?, ?, ?)", @@ -163,8 +165,8 @@ final class StoreProcessOwnership: @unchecked Sendable { let existing = try String.fetchOne( db, sql: "SELECT sql FROM sqlite_master WHERE name = 'owner' AND type = 'table'") if version == 0 { - let objects = try Int.fetchOne( - db, sql: "SELECT COUNT(*) FROM sqlite_master WHERE name NOT LIKE 'sqlite_%'") ?? 0 + let query = "SELECT COUNT(*) FROM sqlite_master WHERE name NOT LIKE 'sqlite_%'" + let objects = try Int.fetchOne(db, sql: query) ?? 0 guard existing == nil, objects == 0 else { throw Failure.malformedMetadata } try db.execute(sql: schema) try db.execute(sql: "PRAGMA user_version = 1") diff --git a/Packages/GanchoKit/Tests/GanchoKitTests/StoreProcessOwnershipTests.swift b/Packages/GanchoKit/Tests/GanchoKitTests/StoreProcessOwnershipTests.swift index fef9b518..dbc3d36d 100644 --- a/Packages/GanchoKit/Tests/GanchoKitTests/StoreProcessOwnershipTests.swift +++ b/Packages/GanchoKit/Tests/GanchoKitTests/StoreProcessOwnershipTests.swift @@ -21,8 +21,8 @@ struct StoreProcessOwnershipTests { in: root, scope: .generation, exclusive: false) let held = try #require(heldClaim) defer { held.release() } - let queue = try DatabaseQueue(path: root.appendingPathComponent( - StoreProcessOwnership.fileName).path) + let coordinator = root.appendingPathComponent(StoreProcessOwnership.fileName) + let queue = try DatabaseQueue(path: coordinator.path) let mode = try queue.read { db in try String.fetchOne(db, sql: "PRAGMA journal_mode") } #expect(mode == "wal") // A completely independent writer can commit while the logical pin is @@ -101,8 +101,8 @@ struct StoreProcessOwnershipTests { let heldClaim = try StoreProcessOwnership.acquire( in: root, scope: .blob, exclusive: true, lifecycle: lifecycle) let held = try #require(heldClaim) - let queue = try DatabaseQueue(path: root.appendingPathComponent( - StoreProcessOwnership.fileName).path) + let coordinator = root.appendingPathComponent(StoreProcessOwnership.fileName) + let queue = try DatabaseQueue(path: coordinator.path) lifecycle.suspend() held.release() let retained = try queue.read { db in @@ -133,8 +133,8 @@ struct StoreProcessOwnershipTests { let heldClaim = try StoreProcessOwnership.acquire( in: root, scope: .blob, exclusive: true, lifecycle: lifecycle) let held = try #require(heldClaim) - let competing = try DatabaseQueue(path: root.appendingPathComponent( - StoreProcessOwnership.fileName).path) + let coordinator = root.appendingPathComponent(StoreProcessOwnership.fileName) + let competing = try DatabaseQueue(path: coordinator.path) try competing.writeWithoutTransaction { db in try db.execute(sql: "BEGIN IMMEDIATE") defer { try? db.execute(sql: "ROLLBACK") } @@ -145,9 +145,10 @@ struct StoreProcessOwnershipTests { in: root, scope: .blob, exclusive: true, lifecycle: lifecycle) let next = try #require(nextClaim) next.release() - #expect(try competing.read { - db in try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM owner") - } == 0) + let remaining = try competing.read { db in + try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM owner") + } + #expect(remaining == 0) } @Test("Missing or unknown versioned coordinator schema fails closed") @@ -155,15 +156,15 @@ struct StoreProcessOwnershipTests { for version in [1, 2] { let root = try directory() defer { try? FileManager.default.removeItem(at: root) } - let queue = try DatabaseQueue(path: root.appendingPathComponent( - StoreProcessOwnership.fileName).path) + let coordinator = root.appendingPathComponent(StoreProcessOwnership.fileName) + let queue = try DatabaseQueue(path: coordinator.path) try queue.write { db in try db.execute(sql: "PRAGMA user_version = \(version)") } #expect(throws: StoreProcessOwnership.Failure.self) { try StoreProcessOwnership.acquire(in: root, scope: .blob, exclusive: true) } - #expect(try queue.read { db in - try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM sqlite_master WHERE name = 'owner'") - } == 0) + let query = "SELECT COUNT(*) FROM sqlite_master WHERE name = 'owner'" + let objects = try queue.read { db in try Int.fetchOne(db, sql: query) } + #expect(objects == 0) } } From 9016f53cdf073fcefd918aaf3504b889e3b1ed01 Mon Sep 17 00:00:00 2001 From: Johnny IV Young Ospino Date: Mon, 5 Oct 2026 19:29:37 -0500 Subject: [PATCH 08/14] fix(storage): pin generations through actual SQLite connection lifetime --- .../GanchoKit/GRDBClipboardStore.swift | 11 +++- .../Sources/GanchoKit/RawKeyAdoption.swift | 7 ++- .../StoreGenerationConnectionPin.swift | 37 ++++++++++++ .../StoreGenerationConnectionPinTests.swift | 59 +++++++++++++++++++ docs/STORE_RECOVERY.md | 14 ++++- 5 files changed, 122 insertions(+), 6 deletions(-) create mode 100644 Packages/GanchoKit/Sources/GanchoKit/StoreGenerationConnectionPin.swift create mode 100644 Packages/GanchoKit/Tests/GanchoKitTests/StoreGenerationConnectionPinTests.swift diff --git a/Packages/GanchoKit/Sources/GanchoKit/GRDBClipboardStore.swift b/Packages/GanchoKit/Sources/GanchoKit/GRDBClipboardStore.swift index 6573b203..bcf552ec 100644 --- a/Packages/GanchoKit/Sources/GanchoKit/GRDBClipboardStore.swift +++ b/Packages/GanchoKit/Sources/GanchoKit/GRDBClipboardStore.swift @@ -80,9 +80,14 @@ public final class GRDBClipboardStore: ClipboardStore, ClipImporting { blobEncryptionKeyData = nil #endif - // The pool retains its generation lease even if callers retain the - // writer independently of this facade. - configuration.prepareDatabase { _ in withExtendedLifetime(generationLease) {} } + // Actual writer/reader/snapshot connections retain the generation. + // Closed GRDB configuration/watchdog objects must not over-pin it. + configuration.prepareDatabase { [weak generationLease] db in + guard let generationLease else { + throw StoreGenerationLease.ConnectionFailure.releasedGeneration + } + try generationLease.pin(to: db) + } let pool = try DatabasePool(path: dbPath, configuration: configuration) try GanchoDatabaseMigrator.make().migrate(pool) let blobStore = BlobStore( diff --git a/Packages/GanchoKit/Sources/GanchoKit/RawKeyAdoption.swift b/Packages/GanchoKit/Sources/GanchoKit/RawKeyAdoption.swift index d8adbb71..6f89117b 100644 --- a/Packages/GanchoKit/Sources/GanchoKit/RawKeyAdoption.swift +++ b/Packages/GanchoKit/Sources/GanchoKit/RawKeyAdoption.swift @@ -69,7 +69,12 @@ import GRDB configuration.prepareDatabase { db in try db.usePassphrase(sqlcipherKey) } - configuration.prepareDatabase { _ in withExtendedLifetime(generationLease) {} } + configuration.prepareDatabase { [weak generationLease] db in + guard let generationLease else { + throw StoreGenerationLease.ConnectionFailure.releasedGeneration + } + try generationLease.pin(to: db) + } let pool = try DatabasePool(path: dbPath, configuration: configuration) try GanchoDatabaseMigrator.make().migrate(pool) let blobStore = BlobStore( diff --git a/Packages/GanchoKit/Sources/GanchoKit/StoreGenerationConnectionPin.swift b/Packages/GanchoKit/Sources/GanchoKit/StoreGenerationConnectionPin.swift new file mode 100644 index 00000000..2ef05963 --- /dev/null +++ b/Packages/GanchoKit/Sources/GanchoKit/StoreGenerationConnectionPin.swift @@ -0,0 +1,37 @@ +import Foundation +import GRDB + +#if SQLITE_HAS_CODEC + import SQLCipher +#else + import SQLite3 +#endif + +/// Pin the actual SQLite connection, not GRDB's longer-lived configuration or +/// dispatch watchdog. SQLite drops this reference only after closing its pager, +/// including delayed close_v2 completion with outstanding statements/backups. +extension StoreGenerationLease { + enum ConnectionFailure: Error { case releasedGeneration, registrationFailed } + + func pin(to database: Database) throws { + guard let connection = database.sqliteConnection else { + throw ConnectionFailure.registrationFailed + } + // Per-connection private name prevents accidental replacement by other + // application functions. No persisted schema or query uses this function. + let name = "gancho_generation_" + UUID().uuidString.replacingOccurrences(of: "-", with: "") + let retained = Unmanaged.passRetained(self).toOpaque() + let result = sqlite3_create_function_v2( + connection, name, 0, SQLITE_UTF8 | SQLITE_DIRECTONLY, retained, + { context, _, _ in sqlite3_result_null(context) }, nil, nil, + { pointer in + guard let pointer else { return } + // Drop only this connection's reference. Never release shared + // generation ownership beneath sibling readers or snapshots. + Unmanaged.fromOpaque(pointer).release() + }) + // SQLite invokes xDestroy on registration failure too. It owns the + // retained pointer after the call; releasing it here would double-free. + guard result == SQLITE_OK else { throw ConnectionFailure.registrationFailed } + } +} diff --git a/Packages/GanchoKit/Tests/GanchoKitTests/StoreGenerationConnectionPinTests.swift b/Packages/GanchoKit/Tests/GanchoKitTests/StoreGenerationConnectionPinTests.swift new file mode 100644 index 00000000..a0e610d6 --- /dev/null +++ b/Packages/GanchoKit/Tests/GanchoKitTests/StoreGenerationConnectionPinTests.swift @@ -0,0 +1,59 @@ +import Foundation +import GRDB +import Testing + +@testable import GanchoKit + +@Suite("Generation pins follow actual SQLite connection lifetime") +struct StoreGenerationConnectionPinTests { + private func directory() -> URL { + FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + } + + @Test("Retained writer blocks recovery; closed writer/configuration does not over-pin") + func writerConnectionLifetime() throws { + let root = directory() + defer { try? FileManager.default.removeItem(at: root) } + var store: GRDBClipboardStore? = try GRDBClipboardStore(directory: root) + weak var facade = store + let writer = try #require(store?.writer) + let configuration = writer.configuration + store = nil + #expect(facade == nil) + #expect(throws: StoreProcessOwnership.Failure.self) { + try StoreGenerationRecovery.archive(in: root, suffix: "blocked") + } + try writer.close() + try withExtendedLifetime(writer) { + // Keep both writer and its configuration alive. The actual connection + // has closed, so configuration/watchdog retention must not hold a pin. + try StoreGenerationRecovery.archive(in: root, suffix: "closed") + let preserved = root.appendingPathComponent(".unreadable-closed/gancho.sqlite") + #expect(FileManager.default.fileExists(atPath: preserved.path)) + let forbidden = root.appendingPathComponent("unpinned.sqlite") + #expect(throws: StoreGenerationLease.ConnectionFailure.self) { + try DatabaseQueue(path: forbidden.path, configuration: configuration) + } + } + } + + @Test("Independent snapshot stays pinned after its originating pool closes") + func snapshotConnectionLifetime() throws { + let root = directory() + defer { try? FileManager.default.removeItem(at: root) } + var store: GRDBClipboardStore? = try GRDBClipboardStore(directory: root) + weak var facade = store + let pool = try #require(store?.writer as? DatabasePool) + let snapshot = try pool.makeSnapshot() + store = nil + #expect(facade == nil) + try pool.close() + #expect(throws: StoreProcessOwnership.Failure.self) { + try StoreGenerationRecovery.archive(in: root, suffix: "blocked") + } + try snapshot.close() + try StoreGenerationRecovery.archive(in: root, suffix: "snapshot-closed") + let preserved = root.appendingPathComponent(".unreadable-snapshot-closed/gancho.sqlite") + #expect(FileManager.default.fileExists(atPath: preserved.path)) + } +} diff --git a/docs/STORE_RECOVERY.md b/docs/STORE_RECOVERY.md index 2a8e6092..2913d914 100644 --- a/docs/STORE_RECOVERY.md +++ b/docs/STORE_RECOVERY.md @@ -13,8 +13,18 @@ uses WAL. Short synchronous writer transactions atomically acquire, downgrade, o release logical claims; no new application-owned file lock or coordinator transaction is held during pool lifetime, filesystem moves, blob work, or `await`. -A shared generation registration is retained by the store and by the pool's -configuration closure. Exclusive recovery conflicts with all generation and blob +A shared generation registration is retained by the store and each actual SQLite +writer/reader/snapshot connection. Configuration preparation captures it weakly +and fails closed if it is gone. A private per-connection SQL function owns one ARC +reference through SQLite's `sqlite3_create_function_v2` destructor; its scalar +callback returns NULL and no application query or persisted schema uses it. The +destructor drops only that connection's reference after SQLite closes its pager, +including delayed `close_v2` zombie completion. Configuration/watchdog objects may +survive without over-pinning a closed connection. Registration failure also invokes +the destructor, so there is no second release on the failure path. See the +[SQLite function lifetime contract](https://www.sqlite.org/c3ref/create_function.html) +and [close contract](https://www.sqlite.org/c3ref/close.html). SQLCipher builds use +SQLCipher C symbols, not system SQLite functions on SQLCipher pointers. Exclusive recovery conflicts with all generation and blob claims. Registration precedes inspecting the journal/header. Pending recovery or plaintext/absent database conversion requires exclusive ownership; the journal is rechecked after acquiring it. The claim remains exclusive through the namespace From e8b15447c6a48dcd2967bc9d8e831b0a1ad28664 Mon Sep 17 00:00:00 2001 From: Johnny IV Young Ospino Date: Mon, 5 Oct 2026 19:37:04 -0500 Subject: [PATCH 09/14] refactor(storage): isolate connection setup within strict type-size gate --- .../GanchoKit/GRDBClipboardStore.swift | 20 +++++++++---------- .../Sources/GanchoKit/RawKeyAdoption.swift | 7 +------ .../StoreGenerationConnectionPin.swift | 7 +++++++ 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/Packages/GanchoKit/Sources/GanchoKit/GRDBClipboardStore.swift b/Packages/GanchoKit/Sources/GanchoKit/GRDBClipboardStore.swift index bcf552ec..f163ab88 100644 --- a/Packages/GanchoKit/Sources/GanchoKit/GRDBClipboardStore.swift +++ b/Packages/GanchoKit/Sources/GanchoKit/GRDBClipboardStore.swift @@ -1,6 +1,14 @@ import Foundation import GRDB +extension GRDBClipboardStore { + /// Injectable writer for tests (`DatabaseQueue()` in-memory). + public convenience init(writer: any DatabaseWriter, blobs: BlobStore) { + self.init(writer: writer, blobs: blobs, generationLease: nil) + } + +} + // This class owns the database handle and core query/write surface. Canonical // migrations and row mappings live in focused sibling files. /// SQLite-backed source of truth for clip history (GRDB). @@ -82,12 +90,7 @@ public final class GRDBClipboardStore: ClipboardStore, ClipImporting { // Actual writer/reader/snapshot connections retain the generation. // Closed GRDB configuration/watchdog objects must not over-pin it. - configuration.prepareDatabase { [weak generationLease] db in - guard let generationLease else { - throw StoreGenerationLease.ConnectionFailure.releasedGeneration - } - try generationLease.pin(to: db) - } + generationLease.prepare(&configuration) let pool = try DatabasePool(path: dbPath, configuration: configuration) try GanchoDatabaseMigrator.make().migrate(pool) let blobStore = BlobStore( @@ -261,11 +264,6 @@ public final class GRDBClipboardStore: ClipboardStore, ClipImporting { } #endif - /// Injectable writer for tests (`DatabaseQueue()` in-memory). - public convenience init(writer: any DatabaseWriter, blobs: BlobStore) { - self.init(writer: writer, blobs: blobs, generationLease: nil) - } - init(writer: any DatabaseWriter, blobs: BlobStore, generationLease: StoreGenerationLease?) { self.writer = writer self.blobs = blobs diff --git a/Packages/GanchoKit/Sources/GanchoKit/RawKeyAdoption.swift b/Packages/GanchoKit/Sources/GanchoKit/RawKeyAdoption.swift index 6f89117b..99801a32 100644 --- a/Packages/GanchoKit/Sources/GanchoKit/RawKeyAdoption.swift +++ b/Packages/GanchoKit/Sources/GanchoKit/RawKeyAdoption.swift @@ -69,12 +69,7 @@ import GRDB configuration.prepareDatabase { db in try db.usePassphrase(sqlcipherKey) } - configuration.prepareDatabase { [weak generationLease] db in - guard let generationLease else { - throw StoreGenerationLease.ConnectionFailure.releasedGeneration - } - try generationLease.pin(to: db) - } + generationLease.prepare(&configuration) let pool = try DatabasePool(path: dbPath, configuration: configuration) try GanchoDatabaseMigrator.make().migrate(pool) let blobStore = BlobStore( diff --git a/Packages/GanchoKit/Sources/GanchoKit/StoreGenerationConnectionPin.swift b/Packages/GanchoKit/Sources/GanchoKit/StoreGenerationConnectionPin.swift index 2ef05963..f98a0d28 100644 --- a/Packages/GanchoKit/Sources/GanchoKit/StoreGenerationConnectionPin.swift +++ b/Packages/GanchoKit/Sources/GanchoKit/StoreGenerationConnectionPin.swift @@ -13,6 +13,13 @@ import GRDB extension StoreGenerationLease { enum ConnectionFailure: Error { case releasedGeneration, registrationFailed } + func prepare(_ configuration: inout Configuration) { + configuration.prepareDatabase { [weak self] db in + guard let self else { throw ConnectionFailure.releasedGeneration } + try pin(to: db) + } + } + func pin(to database: Database) throws { guard let connection = database.sqliteConnection else { throw ConnectionFailure.registrationFailed From 681ec09c42100fd33b5f5e7b1bcc218f14086c03 Mon Sep 17 00:00:00 2001 From: Johnny IV Young Ospino Date: Mon, 5 Oct 2026 19:48:38 -0500 Subject: [PATCH 10/14] test(storage): keep lifetime diagnostics warning-free across Swift versions --- .../StoreGenerationConnectionPinTests.swift | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/Packages/GanchoKit/Tests/GanchoKitTests/StoreGenerationConnectionPinTests.swift b/Packages/GanchoKit/Tests/GanchoKitTests/StoreGenerationConnectionPinTests.swift index a0e610d6..5da15fe0 100644 --- a/Packages/GanchoKit/Tests/GanchoKitTests/StoreGenerationConnectionPinTests.swift +++ b/Packages/GanchoKit/Tests/GanchoKitTests/StoreGenerationConnectionPinTests.swift @@ -4,6 +4,13 @@ import Testing @testable import GanchoKit +// A stored weak reference works on the package's Swift 6.2 minimum too; +// unlike a weak local, it does not trigger newer compiler mutability warnings. +private final class WeakGenerationFacade { + weak var value: GRDBClipboardStore? + init(_ value: GRDBClipboardStore?) { self.value = value } +} + @Suite("Generation pins follow actual SQLite connection lifetime") struct StoreGenerationConnectionPinTests { private func directory() -> URL { @@ -15,11 +22,11 @@ struct StoreGenerationConnectionPinTests { let root = directory() defer { try? FileManager.default.removeItem(at: root) } var store: GRDBClipboardStore? = try GRDBClipboardStore(directory: root) - weak var facade = store + let facade = WeakGenerationFacade(store) let writer = try #require(store?.writer) let configuration = writer.configuration store = nil - #expect(facade == nil) + #expect(facade.value == nil) #expect(throws: StoreProcessOwnership.Failure.self) { try StoreGenerationRecovery.archive(in: root, suffix: "blocked") } @@ -42,11 +49,11 @@ struct StoreGenerationConnectionPinTests { let root = directory() defer { try? FileManager.default.removeItem(at: root) } var store: GRDBClipboardStore? = try GRDBClipboardStore(directory: root) - weak var facade = store + let facade = WeakGenerationFacade(store) let pool = try #require(store?.writer as? DatabasePool) let snapshot = try pool.makeSnapshot() store = nil - #expect(facade == nil) + #expect(facade.value == nil) try pool.close() #expect(throws: StoreProcessOwnership.Failure.self) { try StoreGenerationRecovery.archive(in: root, suffix: "blocked") From dff833f5a9cab1f150b997d795012556f3f4a326 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 15:47:47 +0000 Subject: [PATCH 11/14] fix(storage): sync recovery moves and skip empty archives Create the .unreadable-* directory only after confirming a database is present, so a directory without one is left untouched. Flush the store directory once the journal and archive directory exist, and flush both directories after the moves and again after dropping the journal, so a crash can never leave renames on disk without the journal describing them. --- .../GanchoKit/StoreGenerationRecovery.swift | 29 ++++++++++++++++--- .../StoreGenerationRecoveryTests.swift | 13 +++++++++ 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/Packages/GanchoKit/Sources/GanchoKit/StoreGenerationRecovery.swift b/Packages/GanchoKit/Sources/GanchoKit/StoreGenerationRecovery.swift index f474acc4..6a8122ad 100644 --- a/Packages/GanchoKit/Sources/GanchoKit/StoreGenerationRecovery.swift +++ b/Packages/GanchoKit/Sources/GanchoKit/StoreGenerationRecovery.swift @@ -1,3 +1,4 @@ +import Darwin import Foundation /// An open database registers its filesystem generation durably. Recovery @@ -76,19 +77,22 @@ enum StoreGenerationRecovery { try resume(in: directory, afterMove: afterMove) return } - let archiveName = ".unreadable-\(suffix)" - let archive = directory.appendingPathComponent(archiveName, isDirectory: true) - try manager.createDirectory( - at: archive, withIntermediateDirectories: false, attributes: [.posixPermissions: 0o700]) let present = members.filter { manager.fileExists(atPath: directory.appendingPathComponent($0).path) } guard present.contains("gancho.sqlite") else { throw CocoaError(.fileReadNoSuchFile) } + let archiveName = ".unreadable-\(suffix)" + let archive = directory.appendingPathComponent(archiveName, isDirectory: true) + try manager.createDirectory( + at: archive, withIntermediateDirectories: false, attributes: [.posixPermissions: 0o700]) let journal = Journal(archive: archiveName, members: present) try JSONEncoder().encode(journal).write(to: journalURL, options: .atomic) let handle = try FileHandle(forWritingTo: journalURL) try handle.synchronize() try handle.close() + // The journal and the archive directory must be durable entries before + // the first member moves, or a crash could lose the record of the move. + try synchronizeDirectory(directory) try resume(in: directory, afterMove: afterMove) } @@ -120,6 +124,23 @@ enum StoreGenerationRecovery { try afterMove(name) } } + // Every rename must reach disk before the journal that describes it is + // dropped; afterwards the journal removal itself is made durable. + try synchronizeDirectory(archive) + try synchronizeDirectory(directory) try manager.removeItem(at: journalURL) + try synchronizeDirectory(directory) + } + + /// Flushes a directory's entries. `F_FULLFSYNC` asks the drive to commit + /// its cache too; volumes that do not support it fall back to `fsync`. + static func synchronizeDirectory(_ directory: URL) throws { + let descriptor = open(directory.path, O_RDONLY) + guard descriptor >= 0 else { throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) } + defer { close(descriptor) } + if fcntl(descriptor, F_FULLFSYNC) == 0 { return } + guard fsync(descriptor) == 0 else { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } } } diff --git a/Packages/GanchoKit/Tests/GanchoKitTests/StoreGenerationRecoveryTests.swift b/Packages/GanchoKit/Tests/GanchoKitTests/StoreGenerationRecoveryTests.swift index 45b48188..d839a890 100644 --- a/Packages/GanchoKit/Tests/GanchoKitTests/StoreGenerationRecoveryTests.swift +++ b/Packages/GanchoKit/Tests/GanchoKitTests/StoreGenerationRecoveryTests.swift @@ -129,4 +129,17 @@ struct StoreGenerationRecoveryTests { #expect(try await preserved.content(for: original.id) == content) } #endif + + @Test("Archiving a directory without a database leaves no empty archive behind") + func missingDatabaseLeavesNoArchive() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + #expect(throws: CocoaError.self) { + try StoreGenerationRecovery.archive(in: root, suffix: "empty") + } + let entries = try FileManager.default.contentsOfDirectory(atPath: root.path) + #expect(!entries.contains { $0.hasPrefix(".unreadable-") }) + #expect(!entries.contains(StoreGenerationRecovery.journalName)) + } } From db7ec0c76508d4bcb130f587e591a5ec41b71438 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 15:47:48 +0000 Subject: [PATCH 12/14] fix(storage): stamp ownership claims with boot session and start time A claim was reclaimed only when kill(pid, 0) reported ESRCH. PIDs restart at every boot, so a claimant killed while holding a claim could leave it attached to an unrelated live process and block recovery, conversion and blob work indefinitely. Claims now record kern.bootsessionuuid and the claimant's start time from sysctl(KERN_PROC_PID) in a new owner_identity table (ledger version 2; version-1 ledgers are migrated in place and their claims keep the PID-only rule). A claim from another boot is stale, and so is a PID this process can signal whose start time differs from the recorded one. Unknown or unreadable stamps stay fail-closed; malformed ones fail the ledger. --- .../GanchoKit/StoreProcessIdentity.swift | 164 ++++++++++++++++++ .../GanchoKit/StoreProcessOwnership.swift | 98 +++++------ .../StoreProcessOwnershipTests.swift | 116 ++++++++++++- docs/STORE_RECOVERY.md | 37 ++-- 4 files changed, 354 insertions(+), 61 deletions(-) create mode 100644 Packages/GanchoKit/Sources/GanchoKit/StoreProcessIdentity.swift diff --git a/Packages/GanchoKit/Sources/GanchoKit/StoreProcessIdentity.swift b/Packages/GanchoKit/Sources/GanchoKit/StoreProcessIdentity.swift new file mode 100644 index 00000000..5160e078 --- /dev/null +++ b/Packages/GanchoKit/Sources/GanchoKit/StoreProcessIdentity.swift @@ -0,0 +1,164 @@ +import Darwin +import Foundation +import GRDB + +/// Reuse-proof identity for ownership claims, and the ledger schema that +/// stores it. A PID alone cannot prove a claim stale: PIDs restart at every +/// boot and wrap within one, so a jetsam-killed claimant's PID can belong to an +/// unrelated live process. The boot session and process start time can. +extension StoreProcessOwnership { + /// How the current process judges another claim's liveness. + struct Liveness: Sendable { + /// This boot's `kern.bootsessionuuid`; empty when unavailable. + var bootSession: String + /// Start time of a live PID, microseconds since 1970; nil when unknown. + var startTime: @Sendable (Int32) -> Int64? + + static var system: Self { + Self(bootSession: currentBootSession, startTime: processStartTime) + } + } + + static let currentBootSession: String = readBootSession() + static let currentStartTime: Int64 = processStartTime(getpid()) ?? 0 + + static let ownerQuery = """ + SELECT o.token AS token, o.processToken AS processToken, o.pid AS pid, + o.scope AS scope, o.exclusive AS exclusive, + i.bootSession AS bootSession, i.startTime AS startTime + FROM owner o LEFT JOIN owner_identity i ON i.token = o.token + """ + + /// Fail-closed staleness. Only positive evidence removes a claim: + /// - a different boot session (claims never survive a reboot), or + /// - `ESRCH` for the PID, or + /// - a PID this process may signal whose start time differs from the one + /// recorded with the claim (the PID was reused). + /// Unknown stamps (version-1 claims, unavailable sysctls) and processes that + /// cannot be inspected keep their claim. + static func isStale( + _ claim: Identity, probe: (Int32) -> ProcessState, liveness: Liveness + ) -> Bool { + if !claim.bootSession.isEmpty, !liveness.bootSession.isEmpty, + claim.bootSession != liveness.bootSession + { + return true + } + switch probe(claim.pid) { + case .exited: return true + case .unverifiable: return false + case .live: + guard claim.startTime > 0, let observed = liveness.startTime(claim.pid) else { + return false + } + return observed != claim.startTime + } + } + + /// Identity rows only have meaning beside their claim; both are written in + /// one transaction, so an unmatched one is a released claim's remainder. + static func removeOrphanIdentities(in db: Database) throws { + try db.execute( + sql: "DELETE FROM owner_identity WHERE token NOT IN (SELECT token FROM owner)") + } + + static let ownerSchema = """ + CREATE TABLE owner ( + token TEXT PRIMARY KEY NOT NULL, + processToken TEXT NOT NULL, + pid INTEGER NOT NULL CHECK (pid > 0 AND pid <= 2147483647), + scope TEXT NOT NULL CHECK (scope IN ('generation', 'blob')), + exclusive INTEGER NOT NULL CHECK (exclusive IN (0, 1)) + ) + """ + + static let identitySchema = """ + CREATE TABLE owner_identity ( + token TEXT PRIMARY KEY NOT NULL, + bootSession TEXT NOT NULL, + startTime INTEGER NOT NULL CHECK (startTime >= 0) + ) + """ + + /// Version 1 held only `owner`. Version 2 adds `owner_identity`; version-1 + /// claims stay valid and are judged by their PID alone. A missing, unknown + /// or altered ledger is never recreated: that could erase knowledge of an + /// active logical owner. + static func prepareSchema(_ db: Database) throws { + let version = try Int.fetchOne(db, sql: "PRAGMA user_version") ?? 0 + let owner = try tableSQL("owner", in: db) + let identity = try tableSQL("owner_identity", in: db) + switch version { + case 0: + let query = "SELECT COUNT(*) FROM sqlite_master WHERE name NOT LIKE 'sqlite_%'" + let objects = try Int.fetchOne(db, sql: query) ?? 0 + guard owner == nil, identity == nil, objects == 0 else { + throw Failure.malformedMetadata + } + try db.execute(sql: ownerSchema) + try db.execute(sql: identitySchema) + try db.execute(sql: "PRAGMA user_version = 2") + case 1: + guard sameSchema(owner, ownerSchema), identity == nil else { + throw Failure.malformedMetadata + } + try db.execute(sql: identitySchema) + try db.execute(sql: "PRAGMA user_version = 2") + case 2: + guard sameSchema(owner, ownerSchema), sameSchema(identity, identitySchema) else { + throw Failure.malformedMetadata + } + default: + throw Failure.malformedMetadata + } + } + + private static func tableSQL(_ name: String, in db: Database) throws -> String? { + try String.fetchOne( + db, sql: "SELECT sql FROM sqlite_master WHERE name = ? AND type = 'table'", + arguments: [name]) + } + + private static func sameSchema(_ stored: String?, _ expected: String) -> Bool { + guard let stored else { return false } + return stored.split(whereSeparator: \.isWhitespace) + == expected.split(whereSeparator: \.isWhitespace) + } + + private static func readBootSession() -> String { + var size = 0 + guard sysctlbyname("kern.bootsessionuuid", nil, &size, nil, 0) == 0, size > 0 else { + return "" + } + var buffer = [UInt8](repeating: 0, count: size) + guard sysctlbyname("kern.bootsessionuuid", &buffer, &size, nil, 0) == 0 else { + return "" + } + let value = String(decoding: buffer.prefix { $0 != 0 }, as: UTF8.self) + return UUID(uuidString: value) == nil ? "" : value + } + + /// `KERN_PROC_PID` start time. Nil when the process is gone or the sandbox + /// withholds it, which callers treat as "unknown", never as "different". + static func processStartTime(_ pid: Int32) -> Int64? { + guard pid > 0 else { return nil } + var info = kinfo_proc() + var size = MemoryLayout.stride + var name: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, pid] + let status = name.withUnsafeMutableBufferPointer { mib in + sysctl(mib.baseAddress, u_int(mib.count), &info, &size, nil, 0) + } + guard status == 0, size >= MemoryLayout.size, info.kp_proc.p_pid == pid + else { return nil } + let start = info.kp_proc.p_un.__p_starttime + let micros = Int64(start.tv_sec) * 1_000_000 + Int64(start.tv_usec) + return micros > 0 ? micros : nil + } +} + +extension StoreProcessOwnership.Identity { + /// A stamp is either unknown (empty / 0) or well formed. + var hasValidStamp: Bool { + startTime >= 0 && (bootSession.isEmpty || UUID(uuidString: bootSession) != nil) + } +} diff --git a/Packages/GanchoKit/Sources/GanchoKit/StoreProcessOwnership.swift b/Packages/GanchoKit/Sources/GanchoKit/StoreProcessOwnership.swift index 7e173ecc..eac50d02 100644 --- a/Packages/GanchoKit/Sources/GanchoKit/StoreProcessOwnership.swift +++ b/Packages/GanchoKit/Sources/GanchoKit/StoreProcessOwnership.swift @@ -15,7 +15,15 @@ final class StoreProcessOwnership: @unchecked Sendable { struct Identity: Sendable { var pid: Int32 var token: String - static var current: Self { Self(pid: getpid(), token: processToken) } + /// `kern.bootsessionuuid` when the claim was made; empty when unknown. + var bootSession = "" + /// Process start in microseconds since 1970; 0 when unknown. + var startTime: Int64 = 0 + static var current: Self { + Self( + pid: getpid(), token: processToken, bootSession: currentBootSession, + startTime: currentStartTime) + } } private struct Owner: Decodable, FetchableRecord { var token: String @@ -23,6 +31,29 @@ final class StoreProcessOwnership: @unchecked Sendable { var pid: Int64 var scope: String var exclusive: Int + /// Nil for a claim migrated from the version-1 ledger. + var bootSession: String? + var startTime: Int64? + + struct Claim { + var stamp: Identity + var scope: Scope + var exclusive: Bool + } + + /// Any malformed field fails the whole ledger closed. + func validated() throws -> Claim { + guard pid > 0, pid <= Int64(Int32.max), UUID(uuidString: token) != nil, + UUID(uuidString: processToken) != nil, + let parsedScope = Scope(rawValue: self.scope), + exclusive == 0 || exclusive == 1 + else { throw Failure.malformedMetadata } + let stamp = Identity( + pid: Int32(pid), token: processToken, bootSession: bootSession ?? "", + startTime: startTime ?? 0) + guard stamp.hasValidStamp else { throw Failure.malformedMetadata } + return Claim(stamp: stamp, scope: parsedScope, exclusive: exclusive == 1) + } } private let queue: DatabaseQueue private let lifecycle: StoreOwnershipLifecycle @@ -47,12 +78,14 @@ final class StoreProcessOwnership: @unchecked Sendable { in directory: URL, scope: Scope, exclusive: Bool, identity: Identity = .current, probe: @Sendable (Int32) -> ProcessState = processState, + liveness: Liveness = .system, lifecycle: StoreOwnershipLifecycle = .shared ) throws -> StoreProcessOwnership? { let directory = directory.standardizedFileURL.resolvingSymlinksInPath() let pid = identity.pid let owner = identity.token - guard pid > 0, UUID(uuidString: owner) != nil else { throw Failure.malformedMetadata } + guard pid > 0, UUID(uuidString: owner) != nil, identity.hasValidStamp + else { throw Failure.malformedMetadata } return try lifecycle.withActive { let queue = try makeQueue(in: directory) var retained = false @@ -61,37 +94,31 @@ final class StoreProcessOwnership: @unchecked Sendable { let acquired = try queue.write { db in try prepareSchema(db) try lifecycle.drainPending(in: db, directory: directory) - let rows = try Owner.fetchAll( - db, sql: "SELECT token, processToken, pid, scope, exclusive FROM owner") + let rows = try Owner.fetchAll(db, sql: ownerQuery) for row in rows { - let storedPID = row.pid - let storedToken = row.token - let storedOwner = row.processToken - let storedScope = row.scope - let storedExclusive = row.exclusive - guard storedPID > 0, storedPID <= Int64(Int32.max), - UUID(uuidString: storedToken) != nil, UUID(uuidString: storedOwner) != nil, - let otherScope = Scope(rawValue: storedScope), - storedExclusive == 0 || storedExclusive == 1 - else { throw Failure.malformedMetadata } - if probe(Int32(storedPID)) == .exited { + let other = try row.validated() + if isStale(other.stamp, probe: probe, liveness: liveness) { try db.execute( - sql: "DELETE FROM owner WHERE token = ?", arguments: [storedToken]) + sql: "DELETE FROM owner WHERE token = ?", arguments: [row.token]) continue } // Recovery is exclusive over both scopes. Blob work also // checks recovery, including injected stores without a pin. let recovering = scope == .generation && exclusive - let otherRecovering = otherScope == .generation && storedExclusive == 1 + let otherRecovering = other.scope == .generation && other.exclusive if recovering || otherRecovering - || (scope == .blob && otherScope == .blob) + || (scope == .blob && other.scope == .blob) { return false } } + try removeOrphanIdentities(in: db) try db.execute( sql: "INSERT INTO owner VALUES (?, ?, ?, ?, ?)", arguments: [token, owner, pid, scope.rawValue, exclusive ? 1 : 0]) + try db.execute( + sql: "INSERT INTO owner_identity VALUES (?, ?, ?)", + arguments: [token, identity.bootSession, identity.startTime]) return true } lifecycle.didCommitPending(in: directory) @@ -133,7 +160,8 @@ final class StoreProcessOwnership: @unchecked Sendable { static func processState(_ pid: Int32) -> ProcessState { guard pid > 0 else { return .unverifiable } // Signal zero sends no signal. ESRCH is the only documented proof of - // absence; sandbox/permission errors and PID reuse keep the claim. + // absence; sandbox/permission errors keep the claim. PID reuse is + // judged separately by the boot session and process start time. if kill(pid, 0) == 0 { return .live } return errno == ESRCH ? .exited : .unverifiable } @@ -149,37 +177,6 @@ final class StoreProcessOwnership: @unchecked Sendable { let path = directory.appendingPathComponent(fileName).path return try DatabaseQueue(path: path, configuration: configuration) } - - private static func prepareSchema(_ db: Database) throws { - let version = try Int.fetchOne(db, sql: "PRAGMA user_version") ?? 0 - guard version == 0 || version == 1 else { throw Failure.malformedMetadata } - let schema = """ - CREATE TABLE owner ( - token TEXT PRIMARY KEY NOT NULL, - processToken TEXT NOT NULL, - pid INTEGER NOT NULL CHECK (pid > 0 AND pid <= 2147483647), - scope TEXT NOT NULL CHECK (scope IN ('generation', 'blob')), - exclusive INTEGER NOT NULL CHECK (exclusive IN (0, 1)) - ) - """ - let existing = try String.fetchOne( - db, sql: "SELECT sql FROM sqlite_master WHERE name = 'owner' AND type = 'table'") - if version == 0 { - let query = "SELECT COUNT(*) FROM sqlite_master WHERE name NOT LIKE 'sqlite_%'" - let objects = try Int.fetchOne(db, sql: query) ?? 0 - guard existing == nil, objects == 0 else { throw Failure.malformedMetadata } - try db.execute(sql: schema) - try db.execute(sql: "PRAGMA user_version = 1") - } else { - // Never recreate a missing/versioned ledger or accept a different - // schema: that could erase knowledge of an active logical owner. - guard let existing, - existing.split(whereSeparator: \.isWhitespace) - == schema.split(whereSeparator: \.isWhitespace) - else { throw Failure.malformedMetadata } - } - } - } /// Serializes only short coordinator transactions against the iOS suspension @@ -229,6 +226,7 @@ final class StoreOwnershipLifecycle: @unchecked Sendable { sql: "DELETE FROM owner WHERE token = ? AND processToken = ?", arguments: [release.token, release.owner]) } + try StoreProcessOwnership.removeOrphanIdentities(in: db) } fileprivate func didCommitPending(in directory: URL) { diff --git a/Packages/GanchoKit/Tests/GanchoKitTests/StoreProcessOwnershipTests.swift b/Packages/GanchoKit/Tests/GanchoKitTests/StoreProcessOwnershipTests.swift index dbc3d36d..7156beab 100644 --- a/Packages/GanchoKit/Tests/GanchoKitTests/StoreProcessOwnershipTests.swift +++ b/Packages/GanchoKit/Tests/GanchoKitTests/StoreProcessOwnershipTests.swift @@ -153,7 +153,7 @@ struct StoreProcessOwnershipTests { @Test("Missing or unknown versioned coordinator schema fails closed") func damagedCoordinatorIsNotReset() throws { - for version in [1, 2] { + for version in [1, 2, 3] { let root = try directory() defer { try? FileManager.default.removeItem(at: root) } let coordinator = root.appendingPathComponent(StoreProcessOwnership.fileName) @@ -183,4 +183,118 @@ struct StoreProcessOwnershipTests { identity: .init(pid: 42, token: "not-a-process-identity")) } } + + @Test("A claim from an earlier boot is stale even when its PID looks alive") + func earlierBootClaimIsReclaimed() throws { + let bootA = UUID().uuidString + for (currentBoot, reclaimed) in [(UUID().uuidString, true), (bootA, false), ("", false)] { + let root = try directory() + defer { try? FileManager.default.removeItem(at: root) } + let heldClaim = try StoreProcessOwnership.acquire( + in: root, scope: .blob, exclusive: true, + identity: .init(pid: 42, token: UUID().uuidString, bootSession: bootA)) + let held = try #require(heldClaim) + defer { held.release() } + let contender = try StoreProcessOwnership.acquire( + in: root, scope: .blob, exclusive: true, probe: { _ in .live }, + liveness: .init(bootSession: currentBoot, startTime: { _ in nil })) + #expect((contender != nil) == reclaimed) + contender?.release() + } + } + + @Test("A reused PID is detected by start time only when the process can be inspected") + func reusedPIDIsReclaimed() throws { + let boot = UUID().uuidString + let cases: [(StoreProcessOwnership.ProcessState, Int64?, Bool)] = [ + (.live, 2_000, true), // same PID, different process + (.live, 1_000, false), // the claimant itself + (.live, nil, false), // start time withheld + (.unverifiable, 2_000, false) // cannot signal it: never inferred + ] + for (state, observed, reclaimed) in cases { + let root = try directory() + defer { try? FileManager.default.removeItem(at: root) } + let heldClaim = try StoreProcessOwnership.acquire( + in: root, scope: .generation, exclusive: false, + identity: .init( + pid: 42, token: UUID().uuidString, bootSession: boot, startTime: 1_000)) + let held = try #require(heldClaim) + defer { held.release() } + let contender = try StoreProcessOwnership.acquire( + in: root, scope: .generation, exclusive: true, probe: { _ in state }, + liveness: .init(bootSession: boot, startTime: { _ in observed })) + #expect((contender != nil) == reclaimed) + contender?.release() + } + } + + @Test("A version-1 ledger migrates in place and keeps its claims") + func versionOneLedgerMigrates() throws { + let root = try directory() + defer { try? FileManager.default.removeItem(at: root) } + let coordinator = root.appendingPathComponent(StoreProcessOwnership.fileName) + let queue = try DatabaseQueue(path: coordinator.path) + try queue.write { db in + try db.execute(sql: StoreProcessOwnership.ownerSchema) + try db.execute( + sql: "INSERT INTO owner VALUES (?, ?, 42, 'generation', 0)", + arguments: [UUID().uuidString, UUID().uuidString]) + try db.execute(sql: "PRAGMA user_version = 1") + } + // The legacy claim has no identity stamp, so only its PID judges it. + let blocked = try StoreProcessOwnership.acquire( + in: root, scope: .generation, exclusive: true, probe: { _ in .live }, + liveness: .init(bootSession: UUID().uuidString, startTime: { _ in 7 })) + #expect(blocked == nil) + blocked?.release() + let (version, identityTables) = try queue.read { db in + ( + try Int.fetchOne(db, sql: "PRAGMA user_version"), + try Int.fetchOne( + db, sql: "SELECT COUNT(*) FROM sqlite_master WHERE name = 'owner_identity'") + ) + } + #expect(version == 2 && identityTables == 1) + let reclaimedClaim = try StoreProcessOwnership.acquire( + in: root, scope: .generation, exclusive: true, probe: { _ in .exited }) + let reclaimed = try #require(reclaimedClaim) + reclaimed.release() + let leftovers = try queue.read { db in + try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM owner_identity") + } + #expect(leftovers == 0) + } + + @Test("Malformed identity stamps are rejected on write and on read") + func malformedStamp() throws { + let root = try directory() + defer { try? FileManager.default.removeItem(at: root) } + #expect(throws: StoreProcessOwnership.Failure.self) { + try StoreProcessOwnership.acquire( + in: root, scope: .blob, exclusive: true, + identity: .init(pid: 42, token: UUID().uuidString, bootSession: "not-a-boot")) + } + let heldClaim = try StoreProcessOwnership.acquire( + in: root, scope: .generation, exclusive: false) + let held = try #require(heldClaim) + defer { held.release() } + let coordinator = root.appendingPathComponent(StoreProcessOwnership.fileName) + let queue = try DatabaseQueue(path: coordinator.path) + try queue.write { db in + try db.execute(sql: "UPDATE owner_identity SET bootSession = 'garbage'") + } + #expect(throws: StoreProcessOwnership.Failure.self) { + try StoreProcessOwnership.acquire(in: root, scope: .blob, exclusive: true) + } + } + + @Test("This process stamps its claims with the boot session and its start time") + func currentIdentityIsStamped() { + let current = StoreProcessOwnership.Identity.current + #expect(current.hasValidStamp) + #expect(UUID(uuidString: current.bootSession) != nil) + #expect(current.startTime > 0) + #expect(StoreProcessOwnership.processStartTime(getpid()) == current.startTime) + } } diff --git a/docs/STORE_RECOVERY.md b/docs/STORE_RECOVERY.md index 2913d914..de25c7e8 100644 --- a/docs/STORE_RECOVERY.md +++ b/docs/STORE_RECOVERY.md @@ -43,16 +43,33 @@ retain their queues until safe cleanup, rather than relying on deinitialization. known-finished operations enter this retry set. Cancellation never releases an operation before its protected transaction/work has finished. -A positive, bounded PID is checked with `kill(pid, 0)`, which sends no signal. -Only `ESRCH` permits automatic reclamation: Apple's [iOS kill(2) manual](https://developer.apple.com/library/archive/documentation/System/Conceptual/ManPages_iPhoneOS/man2/kill.2.html) -defines that result as absence of the corresponding process. Successful checks, -permission/sandbox failures, unknown errors, malformed identities, and current -live PIDs reused after exit or reboot remain fail-closed. UUID mismatch, elapsed -time, missing heartbeats, and suspension are never evidence of death. Recovery -returns busy immediately; async blob ownership has a bounded wait. A stale claim -whose PID is reused or whose liveness cannot be established may withhold availability -until that PID is provably absent. No destructive timeout/forced-reclamation path -is provided. The user-facing caller must report busy/error, not silently reset. +Every claim is stamped with the kernel boot session (`kern.bootsessionuuid`) and, +where `sysctl(KERN_PROC_PID)` reports it, the claimant's process start time. A +claim is reclaimed only on positive evidence: + +- its boot session differs from the current one (claims never survive a reboot, + and PIDs restart at boot, so this covers the most common reuse case); +- `kill(pid, 0)` reports `ESRCH`, which Apple's [iOS kill(2) manual](https://developer.apple.com/library/archive/documentation/System/Conceptual/ManPages_iPhoneOS/man2/kill.2.html) + defines as absence of the process; or +- the PID can be signalled (`kill` succeeds) and its current start time differs + from the recorded one, i.e. the PID now belongs to another process. + +Unknown stamps (an unavailable sysctl, or a claim migrated from the version-1 +ledger), permission/sandbox failures, unknown errors and processes whose start +time cannot be read remain fail-closed; malformed stamps fail the ledger closed. +UUID mismatch, elapsed time, missing heartbeats and suspension are never evidence +of death. Recovery returns busy immediately; async blob ownership has a bounded +wait. Within one boot, a claim whose PID was reused by a process this one may not +signal (another user's, or another sandboxed app's on iOS) still withholds +availability until that PID is provably absent. No destructive +timeout/forced-reclamation path is provided. The user-facing caller must report +busy/error, not silently reset. + +The ledger is versioned with `user_version`. Version 2 adds the +`owner_identity` table beside `owner`; a version-1 ledger is migrated in place by +creating that table, and its existing claims keep their PID-only judgement. A +missing table, an unknown version or an altered schema still fails closed and is +never recreated. ## Compatibility and qualification boundary From 27d057ea7405970b78397d100925fa56bbf58c45 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 18:30:56 +0000 Subject: [PATCH 13/14] style(storage): satisfy SwiftLint in process ownership identity Flatten the ledger claim type to one nesting level and use the failable String(bytes:encoding:) initializer for the boot session UUID. --- .../Sources/GanchoKit/StoreProcessIdentity.swift | 4 +++- .../GanchoKit/StoreProcessOwnership.swift | 16 ++++++++-------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/Packages/GanchoKit/Sources/GanchoKit/StoreProcessIdentity.swift b/Packages/GanchoKit/Sources/GanchoKit/StoreProcessIdentity.swift index 5160e078..86f905a3 100644 --- a/Packages/GanchoKit/Sources/GanchoKit/StoreProcessIdentity.swift +++ b/Packages/GanchoKit/Sources/GanchoKit/StoreProcessIdentity.swift @@ -134,7 +134,9 @@ extension StoreProcessOwnership { guard sysctlbyname("kern.bootsessionuuid", &buffer, &size, nil, 0) == 0 else { return "" } - let value = String(decoding: buffer.prefix { $0 != 0 }, as: UTF8.self) + guard let value = String(bytes: buffer.prefix { $0 != 0 }, encoding: .utf8) else { + return "" + } return UUID(uuidString: value) == nil ? "" : value } diff --git a/Packages/GanchoKit/Sources/GanchoKit/StoreProcessOwnership.swift b/Packages/GanchoKit/Sources/GanchoKit/StoreProcessOwnership.swift index eac50d02..bfc8f45b 100644 --- a/Packages/GanchoKit/Sources/GanchoKit/StoreProcessOwnership.swift +++ b/Packages/GanchoKit/Sources/GanchoKit/StoreProcessOwnership.swift @@ -25,6 +25,12 @@ final class StoreProcessOwnership: @unchecked Sendable { startTime: currentStartTime) } } + /// A validated ledger row. + private struct OwnerClaim { + var stamp: Identity + var scope: Scope + var exclusive: Bool + } private struct Owner: Decodable, FetchableRecord { var token: String var processToken: String @@ -35,14 +41,8 @@ final class StoreProcessOwnership: @unchecked Sendable { var bootSession: String? var startTime: Int64? - struct Claim { - var stamp: Identity - var scope: Scope - var exclusive: Bool - } - /// Any malformed field fails the whole ledger closed. - func validated() throws -> Claim { + func validated() throws -> OwnerClaim { guard pid > 0, pid <= Int64(Int32.max), UUID(uuidString: token) != nil, UUID(uuidString: processToken) != nil, let parsedScope = Scope(rawValue: self.scope), @@ -52,7 +52,7 @@ final class StoreProcessOwnership: @unchecked Sendable { pid: Int32(pid), token: processToken, bootSession: bootSession ?? "", startTime: startTime ?? 0) guard stamp.hasValidStamp else { throw Failure.malformedMetadata } - return Claim(stamp: stamp, scope: parsedScope, exclusive: exclusive == 1) + return OwnerClaim(stamp: stamp, scope: parsedScope, exclusive: exclusive == 1) } } private let queue: DatabaseQueue From 5211f28bd082fa117ff44d193a756715c538c81c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 04:17:40 +0000 Subject: [PATCH 14/14] fix(storage): sync the recovery journal before naming it and skip shared downgrades - Write the recovery journal under a staged name, fsync it, then rename it into place, so a crash can never publish an empty journal that fails every later open closed - Remember whether an ownership claim is exclusive and make downgrade a no-op for a shared claim, so an ordinary open no longer writes to the ownership ledger and cannot fail on a busy or suspended ledger --- .../Sources/GanchoKit/StoreGenerationRecovery.swift | 8 ++++++-- .../Sources/GanchoKit/StoreProcessOwnership.swift | 10 ++++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/Packages/GanchoKit/Sources/GanchoKit/StoreGenerationRecovery.swift b/Packages/GanchoKit/Sources/GanchoKit/StoreGenerationRecovery.swift index 6a8122ad..1d5ce088 100644 --- a/Packages/GanchoKit/Sources/GanchoKit/StoreGenerationRecovery.swift +++ b/Packages/GanchoKit/Sources/GanchoKit/StoreGenerationRecovery.swift @@ -86,10 +86,14 @@ enum StoreGenerationRecovery { try manager.createDirectory( at: archive, withIntermediateDirectories: false, attributes: [.posixPermissions: 0o700]) let journal = Journal(archive: archiveName, members: present) - try JSONEncoder().encode(journal).write(to: journalURL, options: .atomic) - let handle = try FileHandle(forWritingTo: journalURL) + // The journal's bytes must be durable before its name is: a published + // but empty journal would fail every later open closed. + let staged = directory.appendingPathComponent(journalName + ".staged") + try JSONEncoder().encode(journal).write(to: staged) + let handle = try FileHandle(forWritingTo: staged) try handle.synchronize() try handle.close() + try AtomicFileReplace.publish(staged: staged, as: journalURL) // The journal and the archive directory must be durable entries before // the first member moves, or a crash could lose the record of the move. try synchronizeDirectory(directory) diff --git a/Packages/GanchoKit/Sources/GanchoKit/StoreProcessOwnership.swift b/Packages/GanchoKit/Sources/GanchoKit/StoreProcessOwnership.swift index bfc8f45b..c38f85f9 100644 --- a/Packages/GanchoKit/Sources/GanchoKit/StoreProcessOwnership.swift +++ b/Packages/GanchoKit/Sources/GanchoKit/StoreProcessOwnership.swift @@ -62,16 +62,18 @@ final class StoreProcessOwnership: @unchecked Sendable { private let owner: String private let mutex = NSLock() private var released = false + private var exclusive: Bool private init( queue: DatabaseQueue, directory: URL, token: String, owner: String, - lifecycle: StoreOwnershipLifecycle + exclusive: Bool, lifecycle: StoreOwnershipLifecycle ) { self.queue = queue self.lifecycle = lifecycle self.directory = directory self.token = token self.owner = owner + self.exclusive = exclusive } static func acquire( @@ -126,7 +128,7 @@ final class StoreProcessOwnership: @unchecked Sendable { retained = true return StoreProcessOwnership( queue: queue, directory: directory, token: token, owner: owner, - lifecycle: lifecycle) + exclusive: exclusive, lifecycle: lifecycle) } } @@ -134,6 +136,9 @@ final class StoreProcessOwnership: @unchecked Sendable { mutex.lock() defer { mutex.unlock() } guard !released else { throw Failure.malformedMetadata } + // A shared claim has nothing to downgrade. Skipping the write keeps a + // steady-state open from failing on a busy or suspended ledger. + guard exclusive else { return } try lifecycle.withActive { try queue.write { db in try db.execute( @@ -142,6 +147,7 @@ final class StoreProcessOwnership: @unchecked Sendable { guard db.changesCount == 1 else { throw Failure.malformedMetadata } } } + exclusive = false } func release() {