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>
5 files changed,
+57
−1
ios/LChat/BackgroundRefresh.swift+8 −1ios/LChat/LChatApp.swift+5 −0ios/LChatCore/Sources/LChatCore/MessageStore.swift+15 −0ios/LChatCore/Tests/LChatCoreTests/MessageStoreTests.swift+23 −0ios/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) |
| } |
| } |