| index 551636e..4a36ee7 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/MessageStore.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/MessageStore.swift |
| @@ -37,6 +37,10 @@ public actor MessageStore { |
| } |
| |
| private var db: OpaquePointer? |
| + // The suspension gate (see suspend()): while down, every op no-ops |
| + // instead of lazily reopening — a reopen while backgrounded is the |
| + // 0xdead10cc kill the close exists to prevent. |
| + private var suspended = false |
| |
| // The App Group container, so the notification extension can ingest a |
| // pushed message into the SAME store the app reads (still on-device, |
| @@ -59,7 +63,12 @@ public actor MessageStore { |
| if let group = fm.containerURL(forSecurityApplicationGroupIdentifier: Self.appGroupID) { |
| let dir = group.appendingPathComponent("lchat", isDirectory: true) |
| try? fm.createDirectory(at: dir, withIntermediateDirectories: true) |
| - if fm.fileExists(atPath: dir.path) { |
| + // Existence isn't proof either: an unentitled process (a dev test |
| + // runner, say) can SEE another group's container on macOS 15+ but |
| + // every write inside is denied — sqlite reports SQLITE_AUTH and |
| + // the dead handle reads as "search finds nothing" again. Probe |
| + // with a real write before trusting the path. |
| + if fm.fileExists(atPath: dir.path), canWrite(in: dir) { |
| // One-time migration from the pre-group location: move the db |
| // (and its WAL sidecars) so existing history survives the path |
| // change. |
| @@ -80,7 +89,17 @@ public actor MessageStore { |
| return fallback |
| } |
| |
| + private func canWrite(in dir: URL) -> Bool { |
| + let probe = dir.appendingPathComponent(".writable") |
| + guard FileManager.default.createFile(atPath: probe.path, contents: nil) else { |
| + return false |
| + } |
| + try? FileManager.default.removeItem(at: probe) |
| + return true |
| + } |
| + |
| private func open() -> OpaquePointer? { |
| + if suspended { return nil } |
| if let db { return db } |
| let dir = storeDir() |
| try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) |
| @@ -107,10 +126,7 @@ public actor MessageStore { |
| ) |
| """) |
| exec(handle, "CREATE INDEX IF NOT EXISTS idx_messages_at ON messages(instance, thread, at, id)") |
| - exec(handle, """ |
| - CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts |
| - USING fts5(text, instance UNINDEXED, thread UNINDEXED, msgid UNINDEXED) |
| - """) |
| + migrateSearchIndex(handle) |
| // Where each thread's full-history backfill has reached (#584): the |
| // oldest fetched id, and whether the walk hit the beginning. Additive, |
| // like every migration here. |
| @@ -138,21 +154,57 @@ public actor MessageStore { |
| sqlite3_exec(handle, sql, nil, nil, nil) |
| } |
| |
| - /// Close the connection so nothing spans suspension. Holding a file lock |
| - /// in a shared (app-group) container while suspended is the 0xdead10cc |
| - /// RunningBoard kill — four of them on a daily-driver phone 26–28 Jul |
| - /// 2026: the app suspended mid-write (or mid-3s busy_timeout wait on the |
| - /// NSE's write lock) and the system executed it. Called when the iOS app |
| - /// backgrounds, when a Background App Refresh pass ends, and after the |
| - /// NSE's per-push ingest; every op reopens lazily via open(), so this is |
| - /// pure lock hygiene — actor serialization means no in-flight statement |
| - /// can be interrupted (statements are prepared and finalized per call). |
| + /// The FTS table, keyed by the messages row's rowid. The original shape |
| + /// carried instance/thread/msgid as UNINDEXED fts5 columns and deleted by |
| + /// matching them — which fts5 can only answer with a scan of the whole |
| + /// index, per message, per fold. That cost grew with the store until a |
| + /// page write could straddle backgrounding (the Aug 2026 0xdead10cc |
| + /// recurrence — see suspend()). user_version 1 re-keys the table by |
| + /// rowid so index upkeep is an O(log n) lookup, rebuilding it once from |
| + /// the messages we already hold. |
| + private func migrateSearchIndex(_ handle: OpaquePointer) { |
| + var version: Int32 = 0 |
| + var stmt: OpaquePointer? |
| + if sqlite3_prepare_v2(handle, "PRAGMA user_version", -1, &stmt, nil) == SQLITE_OK, |
| + sqlite3_step(stmt) == SQLITE_ROW { |
| + version = sqlite3_column_int(stmt, 0) |
| + } |
| + sqlite3_finalize(stmt) |
| + guard version < 1 else { return } |
| + exec(handle, "DROP TABLE IF EXISTS messages_fts") |
| + exec(handle, "CREATE VIRTUAL TABLE messages_fts USING fts5(text)") |
| + exec(handle, """ |
| + INSERT INTO messages_fts(rowid, text) |
| + SELECT rowid, json_extract(payload, '$.text') FROM messages |
| + WHERE json_extract(payload, '$.text') IS NOT NULL |
| + AND json_extract(payload, '$.text') <> '' |
| + """) |
| + exec(handle, "PRAGMA user_version=1") |
| + } |
| + |
| + /// Close the connection AND gate reopens until resume(). Holding a file |
| + /// lock in a shared (app-group) container while suspended is the |
| + /// 0xdead10cc RunningBoard kill. Round one (26–28 Jul 2026): the app |
| + /// suspended mid-write (or mid-3s busy_timeout wait on the NSE's write |
| + /// lock); closing on background fixed those. Round two (2–5 Aug 2026): |
| + /// a detached page-fold upsert landing AFTER the close simply reopened |
| + /// via open() and died holding the lock mid-sqlite3_step — hence the |
| + /// gate. Every legitimate window brackets itself with resume(): the iOS |
| + /// app on scenePhase .active, Background App Refresh at the start of |
| + /// its pass, the NSE at the start of each ingest. Anything outside a |
| + /// bracket no-ops, which is safe — the store is a cache, and the next |
| + /// fold rewrites whatever a dropped straggler would have written. |
| public func suspend() { |
| + suspended = true |
| guard let db else { return } |
| sqlite3_close(db) |
| self.db = nil |
| } |
| |
| + /// Lift the suspension gate; the connection itself reopens lazily on |
| + /// the next op. |
| + public func resume() { suspended = false } |
| + |
| // ---- ingest ------------------------------------------------------------- |
| |
| /// Upsert a decrypted batch (history page or a single live frame). Existing |
| @@ -243,23 +295,35 @@ public actor MessageStore { |
| |
| public func delete(instance: String, thread: String, id: Int64) { |
| guard let handle = open() else { return } |
| - run(handle, "DELETE FROM messages WHERE instance=? AND thread=? AND id=?") { |
| + // FTS row first — its key is the messages row's rowid. |
| + run(handle, """ |
| + DELETE FROM messages_fts WHERE rowid= |
| + (SELECT rowid FROM messages WHERE instance=? AND thread=? AND id=?) |
| + """) { |
| bind($0, 1, instance); bind($0, 2, thread); sqlite3_bind_int64($0, 3, id) |
| } |
| - run(handle, "DELETE FROM messages_fts WHERE instance=? AND thread=? AND msgid=?") { |
| + run(handle, "DELETE FROM messages WHERE instance=? AND thread=? AND id=?") { |
| bind($0, 1, instance); bind($0, 2, thread); sqlite3_bind_int64($0, 3, id) |
| } |
| } |
| |
| + /// Upkeep is keyed by the messages row's rowid (see migrateSearchIndex) |
| + /// — always called with the messages row already upserted. |
| private func indexText(_ handle: OpaquePointer, instance: String, thread: String, |
| id: Int64, text: String) { |
| - run(handle, "DELETE FROM messages_fts WHERE instance=? AND thread=? AND msgid=?") { |
| + run(handle, """ |
| + DELETE FROM messages_fts WHERE rowid= |
| + (SELECT rowid FROM messages WHERE instance=? AND thread=? AND id=?) |
| + """) { |
| bind($0, 1, instance); bind($0, 2, thread); sqlite3_bind_int64($0, 3, id) |
| } |
| guard !text.isEmpty else { return } |
| - run(handle, "INSERT INTO messages_fts(text, instance, thread, msgid) VALUES(?,?,?,?)") { |
| - bind($0, 1, text); bind($0, 2, instance); bind($0, 3, thread) |
| - sqlite3_bind_int64($0, 4, id) |
| + run(handle, """ |
| + INSERT INTO messages_fts(rowid, text) VALUES( |
| + (SELECT rowid FROM messages WHERE instance=? AND thread=? AND id=?), ?) |
| + """) { |
| + bind($0, 1, instance); bind($0, 2, thread); sqlite3_bind_int64($0, 3, id) |
| + bind($0, 4, text) |
| } |
| } |
| |
| @@ -307,17 +371,16 @@ public actor MessageStore { |
| guard let handle = open(), !query.isEmpty else { return [] } |
| let sql = """ |
| SELECT m.thread, m.id, m.at, m.edited, m.reactions, m.payload |
| - FROM messages_fts f JOIN messages m |
| - ON m.instance=f.instance AND m.thread=f.thread AND m.id=f.msgid |
| - WHERE f.instance=? AND messages_fts MATCH ? |
| + FROM messages_fts f JOIN messages m ON m.rowid=f.rowid |
| + WHERE messages_fts MATCH ? AND m.instance=? |
| ORDER BY m.id DESC LIMIT \(limit) |
| """ |
| var out: [(String, StoredMessage)] = [] |
| var stmt: OpaquePointer? |
| guard sqlite3_prepare_v2(handle, sql, -1, &stmt, nil) == SQLITE_OK else { return [] } |
| defer { sqlite3_finalize(stmt) } |
| - bind(stmt, 1, instance) |
| - bind(stmt, 2, query) |
| + bind(stmt, 1, query) |
| + bind(stmt, 2, instance) |
| while sqlite3_step(stmt) == SQLITE_ROW { |
| guard let thread = sqlite3_column_text(stmt, 0).map({ String(cString: $0) }) else { continue } |
| if let m = readRow(stmt, offset: 1) { out.append((thread, m)) } |
| @@ -328,8 +391,12 @@ public actor MessageStore { |
| /// Wipe an instance's rows (sign-out). |
| public func clear(instance: String) { |
| guard let handle = open() else { return } |
| + // FTS rows first — keyed by the messages rowids about to go. |
| + run(handle, """ |
| + DELETE FROM messages_fts WHERE rowid IN |
| + (SELECT rowid FROM messages WHERE instance=?) |
| + """) { bind($0, 1, instance) } |
| run(handle, "DELETE FROM messages WHERE instance=?") { bind($0, 1, instance) } |
| - run(handle, "DELETE FROM messages_fts WHERE instance=?") { bind($0, 1, instance) } |
| } |
| |
| // ---- plumbing ----------------------------------------------------------- |