Skip to content

fix: prevent SQLite crash from concurrent database access in iOS (Sentry YND+YNE, 36K users)#9610

Merged
pavelzeman merged 2 commits into
mainfrom
fix/sentry-ios-sqlite-crash-ynd-yne
Mar 20, 2026
Merged

fix: prevent SQLite crash from concurrent database access in iOS (Sentry YND+YNE, 36K users)#9610
pavelzeman merged 2 commits into
mainfrom
fix/sentry-ios-sqlite-crash-ynd-yne

Conversation

@pavelzeman

@pavelzeman pavelzeman commented Mar 20, 2026

Copy link
Copy Markdown
Contributor

Summary

Fix fatal crashes in the iOS Gekidou (native Swift) database layer caused by concurrent SQLite access between the main app and the NotificationService extension.

Impact: ~46,000 crash events affecting 36,000+ unique users across iOS.

Root Cause

SQLite.swift 0.15.4 uses try! in FailableIterator.next() (Statement.swift:218). Since Swift's IteratorProtocol.next() cannot throw, the library force-unwraps with try! — meaning any SQLite error during row iteration crashes the app instantly.

The trigger is SQLITE_BUSY ("database is locked", error code 5): the main app and NotificationService extension share a database via the app group container and access it concurrently. With no busy timeout configured, SQLite immediately returns SQLITE_BUSY instead of retrying, and the try! makes it fatal.

Two compounding problems:

  1. No busyTimeout — Gekidou never configured Connection.busyTimeout. SQLite defaults to 0ms (no retry).
  2. try! in iteration — Even though Gekidou uses try? on db.prepare(), that only catches errors during statement preparation. Row-by-row iteration calls statement.next()try! failableNext()crash.
Crash Flow
db.prepare(query)          ← try? catches prep errors ✅
  → AnySequence<Row>
    → for row in sequence   ← calls statement.next()
      → try! failableNext() ← SQLITE_BUSY → CRASH 💥
Fix (Two-Part)

Part 1: busyTimeout (prevents the error)

  • New openConnection() helper sets busyTimeout = 5.0 on every SQLite connection
  • All Connection(path) calls now go through this helper
  • SQLite retries internally for up to 5 seconds before returning SQLITE_BUSY
  • Lock contention between app and extension is typically milliseconds → resolved transparently

Part 2: Safe iteration (makes errors recoverable)

  • Replace db.prepare(query) iteration (which uses try!) with db.prepareRowIterator(query) + failableNext() (which throws catchable errors)
  • Replace db.prepare(string).scalar() with db.scalar(string) (avoids Statement iteration entirely)
Affected Call Sites
File Method Risk Notes
Database.swift getOnlyServerUrl 🔴 Crash for result in try db.prepare(query)
Database.swift getAllActiveDatabases 🔴 Crash try db.prepare(query).map { }
Database.swift getAllActiveServerUrls 🔴 Crash try db.prepare(query).map { }
Database+Users.swift getUserLastPictureAt 🔴 Crash YNE crash site.prepare(query).map
Database+Users.swift queryUsers(byIds:) 🔴 Crash YND crash sitefor user in users + user[idCol] (also try!)
Database+Users.swift queryUsers(byUsernames:) 🔴 Crash Same pattern as byIds
Database+Thread.swift handleThreads 🔴 Crash try? db.prepare(...) + .map
Database+Team.swift queryAllMyTeamIds 🔴 Crash try? db.prepare(...) + .compactMap
Database+Channels.swift serverHasChannels ⚠️ Medium db.prepare(string).scalar()
Database+Mentions.swift getChannelMentions ⚠️ Medium db.prepare(string).scalar()
Database+Mentions.swift getThreadMentions ⚠️ Medium db.prepare(string).scalar()
Database+System.swift getDeviceToken ⚠️ Medium Connection(path) without busyTimeout
Tests

7 new tests in DatabaseSafeIterationTests.swift:

  • testOpenConnectionSetsBusyTimeout — verifies busyTimeout > 0
  • testSafeIterationReturnsCorrectResults — prepareRowIterator + failableNext works
  • testSafeIterationWithFilter — filtered queries work correctly
  • testArrayFromRowIterator — Array(RowIterator) bulk pattern works
  • testFailableNextThrowsInsteadOfCrashing — errors are catchable, not fatal
  • testBusyTimeoutHandlesConcurrentAccess — concurrent read/write with WAL mode
  • testScalarQueryDoesNotUseDangerousIteration — scalar queries bypass try! path

Ticket Link

Sentry YND: https://mattermost-mr.sentry.io/issues/7117724547/ (26,135 events, 20,685 users)
Sentry YNE: https://mattermost-mr.sentry.io/issues/7118237218/ (20,450 events, 15,324 users)

Release Note

Fixed a crash in the iOS app caused by concurrent database access between the main app and push notification extension.

@mm-cloud-bot mm-cloud-bot added kind/bug Categorizes issue or PR as related to a bug. release-note labels Mar 20, 2026
@github-actions

github-actions Bot commented Mar 20, 2026

Copy link
Copy Markdown

Coverage Comparison Report

Generated on March 20, 2026 at 20:15:03 UTC

+-----------------+------------+------------+-----------+
| Metric          | Main       | This PR    | Diff      |
+-----------------+------------+------------+-----------+
| Lines           |     85.15% |     85.15% |     0.00% |
| Statements      |     85.01% |     85.01% |     0.00% |
| Branches        |     72.05% |     72.05% |     0.00% |
| Functions       |     83.99% |     83.99% |     0.00% |
+-----------------+------------+------------+-----------+
| Total           |     81.55% |     81.55% |     0.00% |
+-----------------+------------+------------+-----------+

@pavelzeman

Copy link
Copy Markdown
Contributor Author

Note: This PR should be rebased after #9616 (flaky ChecklistItem test fix) is merged. The cherry-picked commit 2642c30 on this branch can then be dropped since it'll already be in main.

@pavelzeman
pavelzeman marked this pull request as ready for review March 20, 2026 15:32
@coderabbitai

coderabbitai Bot commented Mar 20, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

This pull request refactors SQLite database query execution patterns across the iOS storage layer. Changes introduce a new openConnection() helper that sets a busy timeout, replace unsafe prepared statement iteration with safer prepareRowIterator() and failableNext() patterns, simplify scalar query execution, and add comprehensive tests validating the new iteration and error-handling patterns.

Changes

Cohort / File(s) Summary
Connection Management
ios/Gekidou/Sources/Gekidou/Storage/Database.swift
Added openConnection() helper with configurable busyTimeout (5.0 seconds); updated getOnlyServerUrl(), getServerUrlForServer(_:), getAllActiveDatabases(), getAllActiveServerUrls(), getCurrentServerDatabase(), and getDatabaseForServer() to use openConnection() instead of direct Connection() construction; refactored query iteration from db.prepare() loops to prepareRowIterator() with failableNext().
Scalar Query Simplification
ios/Gekidou/Sources/Gekidou/Storage/Database+Channels.swift, ios/Gekidou/Sources/Gekidou/Storage/Database+Mentions.swift
Changed aggregate query execution from db.prepare(stmtString).scalar() to db.scalar(stmtString) directly while preserving error suppression and optional casting behavior.
System Database Connection
ios/Gekidou/Sources/Gekidou/Storage/Database+System.swift
Updated getDeviceToken() to obtain SQLite connection via openConnection(DEFAULT_DB_PATH) instead of direct Connection(DEFAULT_DB_PATH) construction.
Safe Row Iteration Refactoring
ios/Gekidou/Sources/Gekidou/Storage/Database+Team.swift, ios/Gekidou/Sources/Gekidou/Storage/Database+Thread.swift, ios/Gekidou/Sources/Gekidou/Storage/Database+Users.swift
Replaced try?-based optional query handling and unsafe prepared statement iteration with explicit do/catch blocks, prepareRowIterator(), and failableNext() loops; updated error handling to log failures with contextual information (server URL, thrown errors); changed control flow to propagate iteration errors rather than silently suppressing them.
Test Suite
ios/Gekidou/Tests/GekidouTests/DatabaseSafeIterationTests.swift
Added 240 lines of comprehensive XCTest coverage validating safe iteration patterns using prepareRowIterator() and failableNext(), SQLite lock/busy behavior with configurable busyTimeout, concurrency scenarios, and scalar query execution; tests create temporary databases per test case and verify proper error handling and result accuracy.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 30.77% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main change: preventing SQLite crashes from concurrent database access in iOS, with relevant Sentry issue references and impact scope.
Description check ✅ Passed The description is comprehensive and directly related to the changeset, providing root cause analysis, fix details, affected methods, test coverage, and release notes.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/sentry-ios-sqlite-crash-ynd-yne

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
ios/Gekidou/Tests/GekidouTests/DatabaseSafeIterationTests.swift (1)

178-217: The test relies on timing-dependent behaviour.

The 0.1-second delay before COMMIT combined with the 5-second busyTimeout should generally work, but this pattern can be flaky under heavy CI load. Consider adding a small sleep before the reader starts, or using a synchronisation primitive (e.g., DispatchSemaphore) to ensure the lock is held when the reader begins.

That said, the current implementation is reasonable for demonstrating the busyTimeout retry mechanism and should pass reliably in most environments.

💡 Optional: More deterministic synchronisation
         // Hold an exclusive lock briefly, then release from another thread
         try writer.execute("BEGIN EXCLUSIVE TRANSACTION")
         try writer.execute("INSERT INTO data VALUES (2, 'world')")
 
+        let lockHeld = DispatchSemaphore(value: 0)
+        let readerStarted = DispatchSemaphore(value: 0)
+
         // Release the lock after a short delay so the reader's busyTimeout can retry
         DispatchQueue.global().asyncAfter(deadline: .now() + 0.1) {
+            _ = readerStarted.wait(timeout: .now() + 1.0)
             try? writer.execute("COMMIT")
+            lockHeld.signal()
         }
 
+        readerStarted.signal()
         // Reader should eventually succeed thanks to busyTimeout retry
         let iterator = try reader.prepareRowIterator(table)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ios/Gekidou/Tests/GekidouTests/DatabaseSafeIterationTests.swift` around lines
178 - 217, The test testBusyTimeoutHandlesConcurrentAccess is timing-dependent;
replace the fragile DispatchQueue global asyncAfter delay with a synchronization
primitive so the reader starts while the writer still holds the exclusive lock:
acquire a DispatchSemaphore (or DispatchGroup) before beginning the reader, have
the writer perform BEGIN EXCLUSIVE TRANSACTION and then signal the semaphore
only after the lock is held, start the reader after waiting on the semaphore so
it attempts to read while locked, and finally have the writer COMMIT to release
the lock; update references to writer, reader, and the COMMIT sequence in the
test to use this synchronization to make retries deterministic.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@ios/Gekidou/Tests/GekidouTests/DatabaseSafeIterationTests.swift`:
- Around line 178-217: The test testBusyTimeoutHandlesConcurrentAccess is
timing-dependent; replace the fragile DispatchQueue global asyncAfter delay with
a synchronization primitive so the reader starts while the writer still holds
the exclusive lock: acquire a DispatchSemaphore (or DispatchGroup) before
beginning the reader, have the writer perform BEGIN EXCLUSIVE TRANSACTION and
then signal the semaphore only after the lock is held, start the reader after
waiting on the semaphore so it attempts to read while locked, and finally have
the writer COMMIT to release the lock; update references to writer, reader, and
the COMMIT sequence in the test to use this synchronization to make retries
deterministic.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 362c7468-dd78-4cf3-8327-401a3236cd47

📥 Commits

Reviewing files that changed from the base of the PR and between 9fe946b and 2b2f180.

📒 Files selected for processing (9)
  • app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item.test.tsx
  • ios/Gekidou/Sources/Gekidou/Storage/Database+Channels.swift
  • ios/Gekidou/Sources/Gekidou/Storage/Database+Mentions.swift
  • ios/Gekidou/Sources/Gekidou/Storage/Database+System.swift
  • ios/Gekidou/Sources/Gekidou/Storage/Database+Team.swift
  • ios/Gekidou/Sources/Gekidou/Storage/Database+Thread.swift
  • ios/Gekidou/Sources/Gekidou/Storage/Database+Users.swift
  • ios/Gekidou/Sources/Gekidou/Storage/Database.swift
  • ios/Gekidou/Tests/GekidouTests/DatabaseSafeIterationTests.swift

@larkox larkox left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like the changes from this PR got into #9611 (review) . Probably you want to remove them from that one.

pavelzeman and others added 2 commits March 20, 2026 20:03
Replace all unsafe db.prepare() iteration patterns with safe
prepareRowIterator + failableNext() in the Gekidou Storage layer.

SQLite.swift 0.15.4 uses `try!` in FailableIterator.next() (Statement.swift:218),
which means any SQLite error during row iteration crashes the app instantly.
This is triggered when the main app and NotificationService extension access
the shared database concurrently, causing SQLITE_BUSY ("database is locked").

Two-part fix:
1. Add busyTimeout (5s) to all SQLite connections via new openConnection()
   helper, so transient locks are retried instead of failing immediately.
2. Replace db.prepare(query) iteration with prepareRowIterator() +
   failableNext(), which returns throwable errors instead of crashing
   via try!. Also replace db.prepare(string).scalar() with db.scalar().

Files changed:
- Database.swift: Add openConnection() helper, fix 4 methods
- Database+Users.swift: Fix queryUsers(byIds:), queryUsers(byUsernames:),
  getUserLastPictureAt (YND/YNE crash sites)
- Database+Thread.swift: Fix handleThreads team ID iteration
- Database+Team.swift: Fix queryAllMyTeamIds
- Database+Channels.swift: Fix serverHasChannels scalar query
- Database+Mentions.swift: Fix getChannelMentions, getThreadMentions
- Database+System.swift: Fix getDeviceToken connection creation

Sentry: https://mattermost-mr.sentry.io/issues/7117724547/ (YND, 20K users)
Sentry: https://mattermost-mr.sentry.io/issues/7118237218/ (YNE, 15K users)

Co-authored-by: Claude <claude@anthropic.com>
- Replace Array(iterator).map with explicit failableNext() loops in
  Database.swift and Database+Users.swift. Array(iterator) still goes
  through SQLite.swift's non-throwing next() which uses try! internally,
  defeating the purpose of the safe iteration fix.
- Remove unsafe sqlite3_close_v2() test that violated Connection
  ownership (double-free risk). Replace with proper lock contention
  test using DELETE journal mode + EXCLUSIVE lock.
- Fix concurrent access test: WAL mode allows readers during writes
  without exercising busyTimeout. Switched to DELETE journal mode
  with EXCLUSIVE lock + async release to actually test the retry path.

Co-authored-by: Claude <claude@anthropic.com>
@pavelzeman
pavelzeman force-pushed the fix/sentry-ios-sqlite-crash-ynd-yne branch from 2b2f180 to d4ba59f Compare March 20, 2026 20:03
@pavelzeman

Copy link
Copy Markdown
Contributor Author

Looks like the changes from this PR got into #9611 (review) . Probably you want to remove them from that one.

Yes, you're right. Now that the other fix (flaky test) is merged, I'm rebasing here to clean that up and will merge this one.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
ios/Gekidou/Tests/GekidouTests/DatabaseSafeIterationTests.swift (1)

196-214: Potential test flakiness due to timing-dependent assertions.

The test relies on the async commit occurring within the reader's busyTimeout window, but has no explicit synchronization to ensure the commit completes before assertions. While the 5.0s busyTimeout vs 0.1s delay provides a reasonable margin, this could be flaky under heavy CI load.

Consider using an XCTestExpectation with a semaphore or barrier to ensure the commit completes deterministically before asserting the read result.

♻️ Suggested improvement for deterministic test
         // Hold an exclusive lock briefly, then release from another thread
         try writer.execute("BEGIN EXCLUSIVE TRANSACTION")
         try writer.execute("INSERT INTO data VALUES (2, 'world')")
 
+        let commitExpectation = expectation(description: "Writer committed")
+        
         // Release the lock after a short delay so the reader's busyTimeout can retry
         DispatchQueue.global().asyncAfter(deadline: .now() + 0.1) {
             try? writer.execute("COMMIT")
+            commitExpectation.fulfill()
         }
 
         // Reader should eventually succeed thanks to busyTimeout retry
         let iterator = try reader.prepareRowIterator(table)
         var values = [String]()
         while let row = try iterator.failableNext() {
             values.append(try row.get(valueCol))
         }
+        
+        // Ensure commit completed
+        wait(for: [commitExpectation], timeout: 5.0)
 
         // Reader should see data (either pre-commit or post-commit state)
         XCTAssertFalse(values.isEmpty,
             "Reader with busyTimeout should eventually read data after lock is released")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ios/Gekidou/Tests/GekidouTests/DatabaseSafeIterationTests.swift` around lines
196 - 214, The test is timing-dependent: the async commit started with
DispatchQueue.global().asyncAfter may not complete before the reader's
assertion; replace the ad-hoc delay with explicit synchronization (e.g., an
XCTestExpectation or DispatchSemaphore) so the commit from
writer.execute("COMMIT") is signaled when done, then wait for that signal (with
a short timeout) before iterating/ asserting; update the block that releases the
lock (currently using DispatchQueue.global().asyncAfter) to signal the
expectation/semaphore after writer.execute("COMMIT") and wait in the test prior
to calling reader.prepareRowIterator / while let row = try
iterator.failableNext() to make the assertion deterministic while still honoring
reader busyTimeout.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@ios/Gekidou/Tests/GekidouTests/DatabaseSafeIterationTests.swift`:
- Around line 196-214: The test is timing-dependent: the async commit started
with DispatchQueue.global().asyncAfter may not complete before the reader's
assertion; replace the ad-hoc delay with explicit synchronization (e.g., an
XCTestExpectation or DispatchSemaphore) so the commit from
writer.execute("COMMIT") is signaled when done, then wait for that signal (with
a short timeout) before iterating/ asserting; update the block that releases the
lock (currently using DispatchQueue.global().asyncAfter) to signal the
expectation/semaphore after writer.execute("COMMIT") and wait in the test prior
to calling reader.prepareRowIterator / while let row = try
iterator.failableNext() to make the assertion deterministic while still honoring
reader busyTimeout.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 7828bbf6-dc3e-431c-a033-585daa33d5fa

📥 Commits

Reviewing files that changed from the base of the PR and between 2b2f180 and d4ba59f.

📒 Files selected for processing (8)
  • ios/Gekidou/Sources/Gekidou/Storage/Database+Channels.swift
  • ios/Gekidou/Sources/Gekidou/Storage/Database+Mentions.swift
  • ios/Gekidou/Sources/Gekidou/Storage/Database+System.swift
  • ios/Gekidou/Sources/Gekidou/Storage/Database+Team.swift
  • ios/Gekidou/Sources/Gekidou/Storage/Database+Thread.swift
  • ios/Gekidou/Sources/Gekidou/Storage/Database+Users.swift
  • ios/Gekidou/Sources/Gekidou/Storage/Database.swift
  • ios/Gekidou/Tests/GekidouTests/DatabaseSafeIterationTests.swift
✅ Files skipped from review due to trivial changes (3)
  • ios/Gekidou/Sources/Gekidou/Storage/Database+Channels.swift
  • ios/Gekidou/Sources/Gekidou/Storage/Database+Mentions.swift
  • ios/Gekidou/Sources/Gekidou/Storage/Database+System.swift
🚧 Files skipped from review as they are similar to previous changes (3)
  • ios/Gekidou/Sources/Gekidou/Storage/Database+Thread.swift
  • ios/Gekidou/Sources/Gekidou/Storage/Database+Users.swift
  • ios/Gekidou/Sources/Gekidou/Storage/Database.swift

@pavelzeman
pavelzeman merged commit 4123906 into main Mar 20, 2026
15 checks passed
@pavelzeman
pavelzeman deleted the fix/sentry-ios-sqlite-crash-ynd-yne branch March 20, 2026 20:17
@amyblais amyblais added this to the v2.40.0 milestone Mar 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

kind/bug Categorizes issue or PR as related to a bug. release-note

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants