eleven / messenger Public

ios: release the shared store's sqlite lock across suspension — the 0xdead10cc kills (#1369)

Paul's phone collected four RUNNINGBOARD 0xdead10cc terminations 26-28 Jul
(system crash logs + MetricKit payloads, one thread parked in fcntl inside
sqlite at kill time): the app — and the NSE, which shares the app-group
messages.db — was suspended holding a file lock on the shared container,
and the system executed it. It's also why notification taps kept hitting
the cold-launch path: the app was dead by the time a banner arrived.

- MessageStore.suspend(): close the connection (statements are per-call
  prepare/finalize, actor-serialized — nothing in flight to interrupt);
  every op lazily reopens via open(). Pinned by a round-trip test.
- iOS app: suspend on scenePhase .background, queued behind in-flight ops.
- BackgroundRefresh: suspend before setTaskCompleted on both the success
  and expiration paths — the process can freeze the moment the task ends.
- NSE: suspend after the per-push ingest, before shipping the banner —
  the extension suspends as soon as the content is handed over, and its
  held write lock is what the app's 3s busy_timeout was blocking on.

Mac unscoped: macOS has no suspension and no 0xdead10cc.


Claude-Session: https://claude.ai/code/session_01KSHZzDxuWsLEnoVv4XhSB1
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 13d27eb2242fb62239c8a35532b2046e1cf0d9be parent 7f85b9d
5 files changed, +57 −1
  • ios/LChat/BackgroundRefresh.swift +8 −1
  • ios/LChat/LChatApp.swift +5 −0
  • ios/LChatCore/Sources/LChatCore/MessageStore.swift +15 −0
  • ios/LChatCore/Tests/LChatCoreTests/MessageStoreTests.swift +23 −0
  • ios/LChatNSE/NotificationService.swift +6 −0
diff --git a/ios/LChat/BackgroundRefresh.swift b/ios/LChat/BackgroundRefresh.swift
index 121c0ac..cad8156 100644
--- a/ios/LChat/BackgroundRefresh.swift
+++ b/ios/LChat/BackgroundRefresh.swift
@@ -41,11 +41,18 @@ enum BackgroundRefresh {
let done = OneShot()
let work = Task {
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
+ // file lock at suspension is the 0xdead10cc kill.
+ await MessageStore.shared.suspend()
if done.claim() { task.setTaskCompleted(success: true) }
}
task.expirationHandler = {
work.cancel()
- if done.claim() { task.setTaskCompleted(success: false) }
+ Task {
+ await MessageStore.shared.suspend()
+ if done.claim() { task.setTaskCompleted(success: false) }
+ }
}
}
diff --git a/ios/LChat/LChatApp.swift b/ios/LChat/LChatApp.swift
index 0c09738..f9b33b3 100644
--- a/ios/LChat/LChatApp.swift
+++ b/ios/LChat/LChatApp.swift
@@ -463,6 +463,11 @@ struct RootView: View {
// 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() } }
}
// 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 973bc71..551636e 100644
--- a/ios/LChatCore/Sources/LChatCore/MessageStore.swift
+++ b/ios/LChatCore/Sources/LChatCore/MessageStore.swift
@@ -138,6 +138,21 @@ 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).
+ public func suspend() {
+ guard let db else { return }
+ sqlite3_close(db)
+ self.db = nil
+ }
+
// ---- ingest -------------------------------------------------------------
/// Upsert a decrypted batch (history page or a single live frame). Existing
diff --git a/ios/LChatCore/Tests/LChatCoreTests/MessageStoreTests.swift b/ios/LChatCore/Tests/LChatCoreTests/MessageStoreTests.swift
index 0bde104..f338c09 100644
--- a/ios/LChatCore/Tests/LChatCoreTests/MessageStoreTests.swift
+++ b/ios/LChatCore/Tests/LChatCoreTests/MessageStoreTests.swift
@@ -31,4 +31,27 @@ final class MessageStoreTests: XCTestCase {
XCTAssertTrue(page.isEmpty)
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 {
+ let store = MessageStore.shared
+ let instance = "test-\(UUID().uuidString)"
+ var p = MessagePayload()
+ p.text = "written before the suspend"
+ await store.upsert(instance: instance, thread: "t1", [
+ .init(id: 1, at: 100, edited: false, reactions: [], payload: p)
+ ])
+ await store.suspend()
+ let page = await store.page(instance: instance, thread: "t1")
+ XCTAssertEqual(page.first?.payload.text, "written before the suspend")
+ // And a fresh write after the close still lands.
+ p.text = "written after"
+ await store.upsert(instance: instance, thread: "t1", [
+ .init(id: 2, at: 200, edited: false, reactions: [], payload: p)
+ ])
+ let again = await store.page(instance: instance, thread: "t1")
+ XCTAssertEqual(again.count, 2)
+ await store.clear(instance: instance)
+ }
}
diff --git a/ios/LChatNSE/NotificationService.swift b/ios/LChatNSE/NotificationService.swift
index 9be4467..b376d64 100644
--- a/ios/LChatNSE/NotificationService.swift
+++ b/ios/LChatNSE/NotificationService.swift
@@ -151,6 +151,12 @@ final class NotificationService: UNNotificationServiceExtension {
await MessageStore.shared.upsert(
instance: entry.origin, thread: thread,
[.init(id: id, at: at, edited: false, reactions: [], payload: stored)])
+ // Drop the connection before shipping: this process suspends the
+ // moment the banner is handed over, and an app-group sqlite lock
+ // held across suspension is the 0xdead10cc kill — for the NSE
+ // itself, and (via the 3s busy_timeout the app then waits out)
+ // for the suspending app blocked behind it.
+ await MessageStore.shared.suspend()
ship(final)
}
}