Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
458d475
fix(storage): preserve isolated recovery generations and resume inter…
johnny4young Oct 5, 2026
3124bde
fix(storage): preserve interrupted migration stages with recovery gen…
johnny4young Oct 5, 2026
0fe22f5
style(storage): keep recovery fixtures within formatter width
johnny4young Oct 5, 2026
9a5ead6
style(storage): resolve native recovery formatter diagnostics
johnny4young Oct 5, 2026
d9f7ab8
fix(storage): keep durable ownership safe across iOS suspension
johnny4young Oct 5, 2026
758cbbe
fix(storage): scope memory management to iOS and format ownership
johnny4young Oct 5, 2026
b3e0534
style(storage): apply native formatter ownership layout
johnny4young Oct 5, 2026
9016f53
fix(storage): pin generations through actual SQLite connection lifetime
johnny4young Oct 6, 2026
e8b1544
refactor(storage): isolate connection setup within strict type-size gate
johnny4young Oct 6, 2026
681ec09
test(storage): keep lifetime diagnostics warning-free across Swift ve…
johnny4young Oct 6, 2026
dff833f
fix(storage): sync recovery moves and skip empty archives
claude Oct 6, 2026
db7ec0c
fix(storage): stamp ownership claims with boot session and start time
claude Oct 6, 2026
27d057e
style(storage): satisfy SwiftLint in process ownership identity
claude Oct 6, 2026
f448991
Merge remote-tracking branch 'origin/main' into fix/preserve-recovery…
claude Oct 8, 2026
5211f28
fix(storage): sync the recovery journal before naming it and skip sha…
claude Oct 8, 2026
1cf1146
Merge remote-tracking branch 'origin/main' into fix/preserve-recovery…
claude Oct 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions Packages/GanchoKit/Sources/GanchoKit/DatabaseSuspension.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,19 @@ 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)
}

/// Resume when the process returns to the foreground (`.active`). Suspended
/// 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
}
}
40 changes: 23 additions & 17 deletions Packages/GanchoKit/Sources/GanchoKit/GRDBClipboardStore.swift
Original file line number Diff line number Diff line change
@@ -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).
Expand All @@ -19,6 +27,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 }
Expand All @@ -33,6 +43,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, checkingPlaintextConversion: passphrase != nil)
let dbPath = directory.appendingPathComponent("gancho.sqlite").path

var configuration = Configuration()
Expand Down Expand Up @@ -76,15 +88,19 @@ public final class GRDBClipboardStore: ClipboardStore, ClipImporting {
blobEncryptionKeyData = nil
#endif

// Actual writer/reader/snapshot connections retain the generation.
// Closed GRDB configuration/watchdog objects must not over-pin it.
generationLease.prepare(&configuration)
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()
try generationLease.downgrade()
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
Expand Down Expand Up @@ -156,20 +172,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(
Expand Down Expand Up @@ -258,10 +264,10 @@ public final class GRDBClipboardStore: ClipboardStore, ClipImporting {
}
#endif

/// Injectable writer for tests (`DatabaseQueue()` in-memory).
public init(writer: any DatabaseWriter, blobs: BlobStore) {
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
Expand Down
9 changes: 7 additions & 2 deletions Packages/GanchoKit/Sources/GanchoKit/RawKeyAdoption.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ import GRDB
) throws -> GRDBClipboardStore {
try FileManager.default.createDirectory(
at: directory, withIntermediateDirectories: true)
let generationLease = try StoreGenerationRecovery.openLease(
in: directory, checkingPlaintextConversion: true)
let dbPath = directory.appendingPathComponent("gancho.sqlite").path

var configuration = Configuration()
Expand All @@ -67,15 +69,18 @@ import GRDB
configuration.prepareDatabase { db in
try db.usePassphrase(sqlcipherKey)
}
generationLease.prepare(&configuration)
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()
try generationLease.downgrade()
let store = GRDBClipboardStore(
writer: pool, blobs: blobStore, generationLease: generationLease)
return store
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
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 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
}
// 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<StoreGenerationLease>.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 }
}
}
150 changes: 150 additions & 0 deletions Packages/GanchoKit/Sources/GanchoKit/StoreGenerationRecovery.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import Darwin
import Foundation

/// 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 let ownership: StoreProcessOwnership
let directory: URL

init(in directory: URL, exclusive: Bool) throws {
let acquired = try StoreProcessOwnership.acquire(
in: directory, scope: .generation, exclusive: exclusive)
guard let ownership = acquired else { throw StoreProcessOwnership.Failure.busy }
self.ownership = ownership
self.directory = directory.standardizedFileURL.resolvingSymlinksInPath()
}

func downgrade() throws { try ownership.downgrade() }
func release() { ownership.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",
"gancho.sqlite.encrypting", "blobs"
]

private struct Journal: Codable {
var archive: String
var members: [String]
}

static func openLease(
in directory: URL, checkingPlaintextConversion: Bool = false
) throws -> StoreGenerationLease {
var lease = try StoreGenerationLease(in: directory, exclusive: false)
let journalURL = directory.appendingPathComponent(journalName)
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 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(
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 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)
// 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)
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)
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)
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)
}
}
// 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)
}
}
}
Loading
Loading