eleven / ocho Public

ios: gate the message store across suspension + O(log n) FTS upkeep (#1429)

Round two of the 0xdead10cc RunningBoard kills (4 on Paul's phone
2–5 Aug, all mid-sqlite3_step in MessageStore.indexText via
ChatFeedModel.foldPageDetached's detached upsert):

- MessageStore.suspend() now gates the store: open() refuses while
  suspended, so a detached fold write landing after the background
  close can't reopen the db and die holding the app-group lock.
  resume() lifts the gate at every legitimate window: scenePhase
  .active, the start of a Background App Refresh pass, each NSE ingest.
- The app's background close now holds a beginBackgroundTask assertion
  until suspend() lands (it queues behind any in-flight store write).
- messages_fts re-keyed by the messages rowid (user_version 1, rebuilt
  once from stored rows): the old delete-by-UNINDEXED-columns was a
  full FTS scan per message per fold, whose cost grew with the store
  until page writes straddled backgrounding.
- storeDir() probes writability before trusting the group container:
  macOS TCC lets an unentitled test runner see the dir but denies
  writes (SQLITE_AUTH) — the dev flavor of #1121's dead handle.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell pushed by codex agent, operated by paul@keymail.dev 481721deac9a5076ab0b3f200d0894d32a1d85bc parent 543cb88
5 files changed, +169 −35
  • ios/LChat/BackgroundRefresh.swift +3 −0
  • ios/LChat/LChatApp.swift +14 −3
  • ios/LChatCore/Sources/LChatCore/MessageStore.swift +93 −26
  • ios/LChatCore/Tests/LChatCoreTests/MessageStoreTests.swift +56 −6
  • ios/LChatNSE/NotificationService.swift +3 −0
diff --git a/ios/LChat/BackgroundRefresh.swift b/ios/LChat/BackgroundRefresh.swift
index cad8156..da86438 100644
--- a/ios/LChat/BackgroundRefresh.swift
+++ b/ios/LChat/BackgroundRefresh.swift
@@ -40,6 +40,9 @@ enum BackgroundRefresh {
// can race; BGTaskScheduler wants exactly one completion call.
let done = OneShot()
let work = Task {
+ // The app's background close gated the store (MessageStore.suspend);
+ // this pass is a legitimate write window, so open the bracket first.
+ await MessageStore.shared.resume()
await BackgroundSync.run(instances: InstancesStore.load())
// Release the shared store's connection BEFORE declaring done —
// the process suspends any moment after, and a held app-group
diff --git a/ios/LChat/LChatApp.swift b/ios/LChat/LChatApp.swift
index fe8fc7c..2ad08a9 100644
--- a/ios/LChat/LChatApp.swift
+++ b/ios/LChat/LChatApp.swift
@@ -475,14 +475,25 @@ struct RootView: View {
// Foregrounding also pulls the vault: a star/archive made on the
// home page (or another device) lands here without a re-sign-in.
if phase == .active { pullHome() }
+ // …and lifts the store's suspension gate from the last background.
+ if phase == .active { Task { await MessageStore.shared.resume() } }
// Leaving for the background arms the next Background App Refresh
// wake, so chats keep syncing into the local store while we sleep.
if phase == .background, !app.instances.isEmpty { BackgroundRefresh.schedule() }
// …and releases the shared store's sqlite connection: a lock on an
// app-group file held across suspension is the 0xdead10cc kill
- // (MessageStore.suspend). Queued behind any in-flight op; the next
- // op reopens lazily.
- if phase == .background { Task { await MessageStore.shared.suspend() } }
+ // (MessageStore.suspend). suspend() queues behind any in-flight
+ // store write on the actor, so hold a background-task assertion
+ // until the close lands — without it iOS can suspend the process
+ // mid-statement with the lock held (the Aug 2026 recurrence was a
+ // page-fold write straddling exactly this moment).
+ if phase == .background {
+ let assertion = UIApplication.shared.beginBackgroundTask(withName: "store-suspend")
+ Task {
+ await MessageStore.shared.suspend()
+ if assertion != .invalid { UIApplication.shared.endBackgroundTask(assertion) }
+ }
+ }
}
// A push landing while we're open (see PushManager.inboxPing) refreshes live.
.onChange(of: push.inboxPing) { _, _ in refreshInbox(when: true) }
diff --git a/ios/LChatCore/Sources/LChatCore/MessageStore.swift b/ios/LChatCore/Sources/LChatCore/MessageStore.swift
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 -----------------------------------------------------------
diff --git a/ios/LChatCore/Tests/LChatCoreTests/MessageStoreTests.swift b/ios/LChatCore/Tests/LChatCoreTests/MessageStoreTests.swift
index f338c09..62dc87f 100644
--- a/ios/LChatCore/Tests/LChatCoreTests/MessageStoreTests.swift
+++ b/ios/LChatCore/Tests/LChatCoreTests/MessageStoreTests.swift
@@ -32,9 +32,12 @@ final class MessageStoreTests: XCTestCase {
await store.clear(instance: instance)
}
- // suspend() (the 0xdead10cc lock-hygiene close) must be transparent:
- // everything written before it reads back after, via the lazy reopen.
- func testSuspendReopensLazily() async {
+ // suspend() (the 0xdead10cc lock-hygiene close) gates the store until
+ // resume(): a straggler write while suspended must no-op instead of
+ // reopening the db (round two of the kill — a detached page-fold upsert
+ // landing after the background close), and resume() restores everything
+ // written before the gate went down.
+ func testSuspendGatesUntilResume() async {
let store = MessageStore.shared
let instance = "test-\(UUID().uuidString)"
var p = MessagePayload()
@@ -43,15 +46,62 @@ final class MessageStoreTests: XCTestCase {
.init(id: 1, at: 100, edited: false, reactions: [], payload: p)
])
await store.suspend()
- let page = await store.page(instance: instance, thread: "t1")
+ // Reads and writes both no-op while the gate is down.
+ var page = await store.page(instance: instance, thread: "t1")
+ XCTAssertTrue(page.isEmpty)
+ p.text = "straggler while suspended"
+ await store.upsert(instance: instance, thread: "t1", [
+ .init(id: 2, at: 200, edited: false, reactions: [], payload: p)
+ ])
+ await store.resume()
+ page = await store.page(instance: instance, thread: "t1")
+ XCTAssertEqual(page.map(\.id), [1])
XCTAssertEqual(page.first?.payload.text, "written before the suspend")
- // And a fresh write after the close still lands.
+ // And a fresh write after the resume lands as before.
p.text = "written after"
await store.upsert(instance: instance, thread: "t1", [
- .init(id: 2, at: 200, edited: false, reactions: [], payload: p)
+ .init(id: 3, at: 300, edited: false, reactions: [], payload: p)
])
let again = await store.page(instance: instance, thread: "t1")
XCTAssertEqual(again.count, 2)
await store.clear(instance: instance)
}
+
+ // The FTS index is keyed by the messages rowid: re-upserting (an edit)
+ // must leave exactly one hit carrying the new text, and the old text
+ // must stop matching.
+ func testEditReindexesSearch() async {
+ let store = MessageStore.shared
+ let instance = "test-\(UUID().uuidString)"
+ var p = MessagePayload()
+ p.text = "the original phrasing"
+ await store.upsert(instance: instance, thread: "t1", [
+ .init(id: 1, at: 100, edited: false, reactions: [], payload: p)
+ ])
+ p.text = "the rewritten phrasing"
+ await store.upsert(instance: instance, thread: "t1", [
+ .init(id: 1, at: 100, edited: true, reactions: [], payload: p)
+ ])
+ let hits = await store.search(instance: instance, query: "rewritten")
+ XCTAssertEqual(hits.count, 1)
+ XCTAssertEqual(hits.first?.message.payload.text, "the rewritten phrasing")
+ let stale = await store.search(instance: instance, query: "original")
+ XCTAssertTrue(stale.isEmpty)
+ await store.clear(instance: instance)
+ }
+
+ // clear() must take the FTS rows with the messages — a hit surviving a
+ // sign-out would leak another instance's text into search.
+ func testClearDropsSearchIndex() async {
+ let store = MessageStore.shared
+ let instance = "test-\(UUID().uuidString)"
+ var p = MessagePayload()
+ p.text = "ephemeral zanzibar content"
+ await store.upsert(instance: instance, thread: "t1", [
+ .init(id: 1, at: 100, edited: false, reactions: [], payload: p)
+ ])
+ await store.clear(instance: instance)
+ let hits = await store.search(instance: instance, query: "zanzibar")
+ XCTAssertTrue(hits.isEmpty)
+ }
}
diff --git a/ios/LChatNSE/NotificationService.swift b/ios/LChatNSE/NotificationService.swift
index b376d64..83da5b8 100644
--- a/ios/LChatNSE/NotificationService.swift
+++ b/ios/LChatNSE/NotificationService.swift
@@ -148,6 +148,9 @@ final class NotificationService: UNNotificationServiceExtension {
}
let final = deliver
Task {
+ // A reused NSE process is still gated from the previous push's
+ // close (MessageStore.suspend) — every ingest opens its own bracket.
+ await MessageStore.shared.resume()
await MessageStore.shared.upsert(
instance: entry.origin, thread: thread,
[.init(id: id, at: at, edited: false, reactions: [], payload: stored)])