Skip to content

fix(ios): release database file locks before suspension instead of closing every session - #2082

Merged
datlechin merged 1 commit into
mainfrom
fix/ios-suspension-file-lock
Aug 11, 2026
Merged

fix(ios): release database file locks before suspension instead of closing every session#2082
datlechin merged 1 commit into
mainfrom
fix/ios-suspension-file-lock

Conversation

@datlechin

Copy link
Copy Markdown
Member

Fixes a RUNNINGBOARD 0xdead10cc SIGKILL in TablePro Mobile, reproduced in three TestFlight crash reports (1.0 build 19, iOS 27.0, iPhone 14,5).

Root cause

Xcode names the crash point SwiftUI: specialized UnsafeMutablePointer<>.withMemoryRebound, but that is just the main thread parked in UIApplicationMain's runloop. The real signal is the termination reason: the app was killed for holding a file lock across suspension. Every report has a thread doing exactly that:

duckdb_close -> DatabaseInstance::~DatabaseInstance -> ResetDatabases
  -> AttachedDatabase::Close -> DuckCatalog::~DuckCatalog
  -> RowGroup::~RowGroup -> BlockHandle::~BlockHandle -> FileBuffer::~FileBuffer -> madvise
DuckDBActor.close()               DuckDBDriver.swift:217
DuckDBDriver.disconnect()         DuckDBDriver.swift:44
ConnectionManager.disconnectAll() ConnectionManager.swift:80

Two defects compounded.

Wrong policy. scenePhase == .background ran disconnectAll(), closing every session. For the six network drivers that is pure cost: iOS drops sockets at suspension anyway, the resume path already pings and reconnects, and closing a live session deliberately destroys server-side state (temp tables, search_path, SET variables, open transactions). For DuckDB it meant a multi-second duckdb_close() holding the database file lock. Unlike SQLite, which the picker copies into the app container, DuckDB keeps a security-scoped bookmark and opens the user's file in place, so that file can live in iCloud Drive or another Files provider.

Wrong mechanics. No background task assertion existed anywhere in the repo. The work started at .background, which is the point Apple's documentation explicitly warns against because the assertion is granted asynchronously, and it ran a blocking C call on the Swift cooperative thread pool.

The background disconnect landed in dbaeea3d2, when every iOS driver was a network socket where close is instant. DuckDB arrived later in c01361f45 and invalidated that premise.

The change

Backgrounding is no longer "close everything". It is "release the OS resources that block suspension", which in practice is DuckDB's file lock and nothing else. This also matches every comparable client: no database client on any platform drops connections on backgrounding, and Postico shipped this exact behaviour in 1.0 and removed it by 1.2.

  • DatabaseDriver gains holdsSuspensionBlockingResource, defaulted false in the protocol extension. Only DuckDBDriver overrides it, and only when file-backed.
  • ConnectionManager gains hasSuspensionBlockingResources and releaseSuspensionBlockingResources(), which releases just those sessions and does so concurrently. disconnectAll() is removed; it had one caller.
  • ConnectionManager.disconnect(_:) tracks in-flight teardowns, so connect() for the same id awaits one instead of racing it. Previously a resumed app could start a second duckdb_open_ext on the same file while the first close was still running.
  • DuckDBActor runs on its own DispatchSerialQueue executor (SE-0392), taking duckdb_close and every blocking duckdb_query off the cooperative pool. Task.detached would not have done this.
  • New BackgroundReleaseCoordinator owns the UIApplication assertion: taken at .inactive, consumed at .background, released on .active. Splitting prepare from release is the point, since the grant is asynchronous and .inactive is where Apple's Preparing your UI to run in the background puts "close any open files". Doing no work at .inactive avoids tearing down connections on every notification banner.

Behaviour change

Remote connections now survive an app switch instead of being dropped and rebuilt. Returning to the app no longer reconnects and reloads everything.

Tests

  • ConnectionManagerTests: release touches only blocking sessions, releases run concurrently (proved by a rendezvous, not by timing), an in-flight teardown still counts as a blocking resource, and connect() waits for an in-flight teardown.
  • BackgroundReleaseCoordinatorTests: no assertion when nothing needs releasing, assertion taken once and ended on cancel, release ends it, release takes one when preparation was skipped, expiry does not double-end, and an in-flight release keeps the assertion alive across a foreground bounce.
  • DuckDBDriverSuspensionTests: in-memory opts out, file-backed opts in.

Each load-bearing test was checked against the pre-fix code and confirmed to fail there.

Check Result
TableProCore suite 181 tests, 0 failures
iOS test target (simulator) TEST SUCCEEDED, 0 failures
SwiftLint --strict on changed app sources clean

Verifying by hand

0xdead10cc terminations do not occur in the Simulator or on device with the debugger attached, so this needs a detached device build: open a file-backed DuckDB connection from Files, background the app, and confirm no crash report appears.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@datlechin
datlechin merged commit 490c2bb into main Aug 11, 2026
5 checks passed
@datlechin
datlechin deleted the fix/ios-suspension-file-lock branch August 11, 2026 07:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant