Skip to content
Closed
698 changes: 698 additions & 0 deletions .claude/plans/2026-09-09-bugsnag-6a4fde3-plan.md

Large diffs are not rendered by default.

49 changes: 49 additions & 0 deletions .claude/plans/2026-09-09-bugsnag-6a4fde3.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Bugsnag triage: Failed to persist conversation state [replace-feed]

**id:** `6a4fde3…` · **URL:** https://app.bugsnag.com/1000710770-ontario-inc/flipcash-ios/errors/6a4fde33e96556123eb1f0ec
**Triaged:** 2026-09-09 · **Status:** open · production
**Last 7d:** 14 events · 3 users · last seen 2026-09-09
**App version:** 2026.8.5 (local: 2026.9.1) · **Introduced in:** 1.13.0 (448)
**Experts consulted:** /simplify (concurrency, testing and SwiftUI reviewed directly — those skills aren't installed here)

## Root cause

Four live `Database` instances wrote to one file: each `completeLogin` builds a `SessionContainer` (SessionAuthenticator.swift:389) that constructs a `Database` (SessionAuthenticator.swift:309, the only non-test site), each with its own writer `Connection` on the same path (Database.swift:34, :123). SQLite.swift serializes only *within* a `Connection` (Database.swift:16), so those are four real WAL writers.

`replaceConversationFeed` opens a **deferred** transaction (Database+Conversations.swift:230; SQLite.swift's default, Connection.swift:366) that reads (:239) before writing (:242). A rival commit between snapshot and write fails the upgrade with `SQLITE_BUSY` immediately, bypassing the busy handler — hence a throw ~6 s after the containers spun up.

The timeout would not have helped: `busyTimeout` is seconds (Connection.swift:417), so `2000` (Database.swift:36, commented "2 sec") arms ~33 minutes. Separate bug.

Why five logins fire on one cold launch is **unverified**: SessionAuthenticator.swift:158–173 reads as linear and should stop on first success.

## Evidence

- nserror — `location=ConversationController.swift:persist(operation:_:):736`, exact against tag `flipcash-2026.8.5` (HEAD drifted to :747)
- log `08:06:39–44 account-service` — "Logging in owner=DTAr…mLLW" ×5, five distinct `intentId`s
- log `08:06:44–45 rates-controller` — "Rehydrated cached rates" ×4, `durationMs` 4.57/0.99/1.26/5.53: four real bootstraps, not repeated logging
- `RatesController`/`WalletConnection` are built only in `createSessionContainer` (SessionAuthenticator.swift:235, :276) — it ran four times
- log `08:06:50 ERROR` — "database is locked (code: 5) operation=replace-feed"; breadcrumbs show a cold launch, no user interaction
- LogStore.swift:34–38 — release logs at `.info`, so `initializeState called` and `completeLogin` (both `debug`) never reach the report

## Proposed direction

Key one `Database` per owner in a store on `Container` (beside `accountManager`, Container.swift:16–18) and have `initializeDatabase` (SessionAuthenticator.swift:295) return the cached instance, so repeat logins share one writer. Add no explicit `close()`: SQLite.swift's `Connection` closes only in `deinit` (Connection.swift:144–145), and `HistoryController.sync()` holds the database in a `Task` with no `[weak self]` (HistoryController.swift:105–106), so a manual close would race in-flight work. Let ARC reclaim it. Separately, set `busyTimeout = 2` and mark read-then-write transactions `.immediate` so they take the write lock up front. The five logins are a separate defect; promote those two `debug` lines to `info` to capture the attempt count.

## Verification

`FlipcashTests/Regressions/Regression_6a4fde33e96556123eb1f0ec.swift`, built on `Database.makeTemp()` (FlipcashTests/TestSupport/Database+TestSupport.swift:14). Racing two writers is a false-green risk, so force it: hold `BEGIN IMMEDIATE` open on one connection, drop the other's `busyTimeout` to `0.05`, run the feed write, expect `SQLITE_BUSY`. Post-fix, assert identity rather than absence of an error — two `completeLogin` calls for one owner return the same `Database` (`===`).

## Risk

One shared `Database` means a stale container's in-flight writes land on the live store. That exposes the existing missing-teardown defect rather than adding one: `completeLogin` still never stops the previous container, only `logout()` does (SessionAuthenticator.swift:440–448).

## Expert input

- **/simplify**: the `completeLogin` guard sat at the wrong altitude — ownership, not a conditional, is the fix.
- **concurrency**: `Connection` has no `close()`, only `deinit` — a keyed per-owner cache with ARC teardown avoids use-after-close.
- **testing**: force contention explicitly rather than racing writers; assert `Database` identity per owner post-fix.
- **SwiftUI**: `Database` is never `@Observable` or environment-injected, so moving it above `SessionContainer` is SwiftUI-invisible.

## Next step

If actioned: run `superpowers:writing-plans` against this file to expand into an implementation plan, and load `karpathy-guidelines` before writing code. The fix must land with that regression test, observed failing on unfixed code first — conventions and false-green traps in `references/regression-tests.md`.
2 changes: 2 additions & 0 deletions Flipcash/Core/Container.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ class Container {
let betaFlags: BetaFlags
let preferences: Preferences
let notificationController: NotificationController
let databaseStore: DatabaseStore

@ObservationIgnored lazy var sessionAuthenticator = SessionAuthenticator(container: self)
@ObservationIgnored lazy var deepLinkController = DeepLinkController(sessionAuthenticator: sessionAuthenticator)
Expand All @@ -38,6 +39,7 @@ class Container {
self.betaFlags = BetaFlags.shared
self.preferences = Preferences()
self.notificationController = NotificationController()
self.databaseStore = DatabaseStore()

_ = sessionAuthenticator
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,10 @@ nonisolated extension Database {
let c = ConversationTable()
let m = ConversationMemberTable()
let ids = conversations.map(\.id.data)
try writer.transaction {
// IMMEDIATE: this transaction reads before it writes. A DEFERRED one that reads first fails
// the write with SQLITE_BUSY at once when another writer holds the lock, without consulting
// the busy handler.
try writer.transaction(.immediate) {
// Delete only the same-type conversations that dropped out of this feed, then upsert the
// rest. `writeConversation` upserts the row (leaving `catchupCursor` untouched on conflict)
// and replaces that conversation's members, so a surviving conversation keeps its event-log
Expand Down Expand Up @@ -281,7 +284,8 @@ nonisolated extension Database {
/// until it does).
func persistMessages(_ messages: [ConversationMessage], cursor: UInt64, conversationID: ConversationID) throws {
let c = ConversationTable()
try writer.transaction {
// IMMEDIATE: reads the current cursor before updating it (see replaceConversationFeed).
try writer.transaction(.immediate) {
for message in messages {
try writeMessage(message, conversationId: conversationID.data)
}
Expand Down
7 changes: 4 additions & 3 deletions Flipcash/Core/Controllers/Database/Database.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,13 @@ nonisolated class Database: @unchecked Sendable {

self.writer = try Connection(url.path)

writer.busyTimeout = 2000 // 2 sec
writer.busyTimeout = 2 // seconds
try writer.run("PRAGMA journal_mode = WAL;")
try writer.run("PRAGMA cache_size = 10000;")
try writer.run("PRAGMA foreign_keys = ON;")

self.reader = try Connection(url.path, readonly: true)
reader.busyTimeout = 2000 // 2 Sec
reader.busyTimeout = 2 // seconds

try createTablesIfNeeded()
}
Expand All @@ -53,7 +53,8 @@ nonisolated class Database: @unchecked Sendable {
func transaction(silent: Bool = false, _ block: (Database) throws -> Void) rethrows {
do {
let startChangeCount = writer.totalChanges
try writer.transaction { [unowned self] in
// IMMEDIATE: callers read and write inside the block; see replaceConversationFeed.
try writer.transaction(.immediate) { [unowned self] in
try block(self)
}
let endChangeCount = writer.totalChanges
Expand Down
49 changes: 49 additions & 0 deletions Flipcash/Core/Controllers/Database/DatabaseStore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
//
// DatabaseStore.swift
// Flipcash
//

import Foundation
import FlipcashCore

private let logger = Logger(label: "flipcash.database-store")

/// Opens one `Database` per owner and returns that same instance on every later request,
/// so repeat logins in one process share a single SQLite writer instead of contending for the file.
final class DatabaseStore {

private var databases: [PublicKey: Database] = [:]

/// The owner's `Database`, opened (and rebuilt if its on-disk version is outdated) on first use.
func database(for owner: PublicKey) throws -> Database {
if let database = databases[owner] {
return database
}

try createApplicationSupportIfNeeded()

// Currently we don't do migrations so every time
// the user version is outdated, we'll rebuild the
// database during sync.
let userVersion = (try? Database.userVersion(owner: owner)) ?? 0
let currentVersion = try InfoPlist.value(for: "SQLiteVersion").integer()
if currentVersion > userVersion {
try Database.deleteStore(owner: owner)
logger.error("Outdated user version, deleted database.")
try Database.setUserVersion(version: currentVersion, owner: owner)
}

let database = try Database(url: .dataStore(owner: owner))
databases[owner] = database
return database
}

private func createApplicationSupportIfNeeded() throws {
if !FileManager.default.fileExists(atPath: URL.applicationSupportDirectory.path) {
try FileManager.default.createDirectory(
at: .applicationSupportDirectory,
withIntermediateDirectories: false
)
}
}
}
34 changes: 3 additions & 31 deletions Flipcash/Core/Session/SessionAuthenticator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ final class SessionAuthenticator {
}

private func initializeState(count: Int = 0, didAuthenticate: @escaping (UserAccount) -> Void, didFindRecentAccount: @escaping (KeyAccount) -> Void) {
logger.debug("initializeState called")
logger.info("initializeState called", metadata: ["count": "\(count)"])

let userAccount = accountManager.fetchCurrentUserAccount()
if let userAccount = userAccount {
Expand Down Expand Up @@ -223,7 +223,7 @@ final class SessionAuthenticator {
let owner = initializedAccount.owner
let ownerPublicKey = owner.authority.keyPair.publicKey

let database = try! initializeDatabase(owner: ownerPublicKey)
let database = try! container.databaseStore.database(for: ownerPublicKey)

let historyController = HistoryController(
container: container,
Expand Down Expand Up @@ -290,34 +290,6 @@ final class SessionAuthenticator {
)
}

// MARK: - Database -

private func initializeDatabase(owner: PublicKey) throws -> Database {
try createApplicationSupportIfNeeded()

// Currently we don't do migrations so every time
// the user version is outdated, we'll rebuild the
// database during sync.
let userVersion = (try? Database.userVersion(owner: owner)) ?? 0
let currentVersion = try InfoPlist.value(for: "SQLiteVersion").integer()
if currentVersion > userVersion {
try Database.deleteStore(owner: owner)
logger.error("Outdated user version, deleted database.")
try Database.setUserVersion(version: currentVersion, owner: owner)
}

return try Database(url: .dataStore(owner: owner))
}

private func createApplicationSupportIfNeeded() throws {
if !FileManager.default.fileExists(atPath: URL.applicationSupportDirectory.path) {
try FileManager.default.createDirectory(
at: .applicationSupportDirectory,
withIntermediateDirectories: false
)
}
}

// MARK: - Login -

func initialize(using mnemonic: MnemonicPhrase, isRegistration: Bool) async throws -> InitializedAccount {
Expand Down Expand Up @@ -384,7 +356,7 @@ final class SessionAuthenticator {
}

func completeLogin(with initializedAccount: InitializedAccount) {
logger.debug("completeLogin", metadata: ["owner": "\(initializedAccount.keyAccount.ownerPublicKey)"])
logger.info("completeLogin", metadata: ["owner": "\(initializedAccount.keyAccount.ownerPublicKey)"])

let session = createSessionContainer(
container: container,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
//
// Regression_6a4fde33e96556123eb1f0ec.swift
// FlipcashTests
//
// "Failed to persist conversation state [replace-feed]" — database is locked (code: 5).
// One cold launch ran completeLogin five times for the same owner; each built a
// SessionContainer with its own Database, so four writer Connections shared one
// SQLite file. replaceConversationFeed ran a DEFERRED transaction that read before
// it wrote: once a rival writer held the lock, the snapshot upgrade returned
// SQLITE_BUSY immediately, bypassing the busy handler.
//
// Fix: Container.databaseStore hands out one Database per owner, so repeat logins
// share a single writer; read-then-write transactions take the write lock up front
// with BEGIN IMMEDIATE; busyTimeout is 2 seconds rather than 2000.
//

import Foundation
import Testing
import FlipcashCore
@testable import Flipcash

@MainActor
@Suite("Regression: 6a4fde3 – replace-feed fails SQLITE_BUSY against a rival writer", .bug("6a4fde33e96556123eb1f0ec"))
struct Regression_6a4fde3 {

private func conversation(_ byte: UInt8) -> Conversation {
Conversation(id: .test(byte), members: [], lastMessage: nil, lastActivity: Date(timeIntervalSince1970: 100))
}

@Test("replace-feed waits for a rival writer to commit instead of failing the snapshot upgrade")
func replaceFeed_rivalWriterHoldsLock_waitsThenCommits() async throws {
let (database, url) = try Database.makeTemp()
defer { Database.removeTemp(at: url) }
// Seed a row so the second feed has something to delete — the read-then-write path.
try database.replaceConversationFeed([conversation(1)], type: .contactDm)

// A second Database on the same file is exactly what each extra SessionContainer opened.
let rival = try Database(url: url)
try rival.writer.run("BEGIN IMMEDIATE TRANSACTION")
let release = Task.detached {
try await Task.delay(milliseconds: 200)
try rival.writer.run("COMMIT TRANSACTION")
}

try database.replaceConversationFeed([conversation(2)], type: .contactDm)
try await release.value

let ids = try database.getConversations().map(\.id)
#expect(ids == [.test(2)])
}

@Test("busy timeout is armed in seconds, not milliseconds")
func busyTimeout_isTwoSeconds() throws {
let (database, url) = try Database.makeTemp()
defer { Database.removeTemp(at: url) }
#expect(database.writer.busyTimeout == 2)
#expect(database.reader.busyTimeout == 2)
}

@Test("the store hands the same Database back for the same owner")
func databaseStore_sameOwnerTwice_returnsOneInstance() throws {
try withThrowawayOwner { owner in
let store = DatabaseStore()
let first = try store.database(for: owner)
let second = try store.database(for: owner)
#expect(first === second)
}
}

@Test("the store keeps different owners apart")
func databaseStore_twoOwners_returnsDistinctInstances() throws {
try withThrowawayOwner { alice in
try withThrowawayOwner { bob in
let store = DatabaseStore()
let alicesDatabase = try store.database(for: alice)
let bobsDatabase = try store.database(for: bob)
#expect(alicesDatabase !== bobsDatabase)
}
}
}

private func withThrowawayOwner(_ body: (PublicKey) throws -> Void) throws {
let owner = KeyPair.generate()!.publicKey
defer {
try? Database.deleteStore(owner: owner)
try? FileManager.default.removeItem(at: .versionFile(owner: owner))
}
try body(owner)
}
}
Loading