Skip to content

Fix EXC_BAD_ACCESS when ZiplineCache is closed during an in-flight query - #1831

Open
jgbirk wants to merge 1 commit into
cashapp:trunkfrom
jgbirk:fix/zipline-cache-close-race
Open

Fix EXC_BAD_ACCESS when ZiplineCache is closed during an in-flight query#1831
jgbirk wants to merge 1 commit into
cashapp:trunkfrom
jgbirk:fix/zipline-cache-close-race

Conversation

@jgbirk

@jgbirk jgbirk commented Jul 22, 2026

Copy link
Copy Markdown

Crash Info

Field Value
Error Class EXC_BAD_ACCESS
Message Attempted to dereference garbage pointer 0x400000000.
Platform ios
Severity error

Fixing the crash at its source. The crash was observed in the Cash iOS app,
but every frame is in the Zipline library, so the fix belongs here in
cashapp/zipline.

Root Cause

ZiplineCache.close() closed the SQLite SqlDriver without any synchronization
against in-flight database operations. All cache database access is serialized on a
single-threaded cache dispatcher, but close() is invoked directly from the host
thread and bypasses that serialization. On iOS this raced during sign-out: the
Treehouse/Zipline cache was closed while a code load was still executing a query on a
background worker thread (FsCachingFetcher.fetchZiplineCache.getOrPut
readFilesQueries.GetQuery.execute). driver.close() finalized/freed the native
SQLite prepared statements while another thread was mid-query, so sqlite3_reset
dereferenced freed memory → EXC_BAD_ACCESS. The @Volatile closed flag did not
prevent this: it is a time-of-check/time-of-use race — a thread can pass the
if (closed) … guard and enter executeAsOneOrNull() just before close() sets the
flag and tears down the driver.

Fix

Added a single reentrant lock (kotlinx.coroutines.internal.SynchronizedObject) that
guards all access to driver/database, including close(). Because close()
now acquires the same lock as every database operation, it waits for any in-flight
operation to finish before closing the driver, so the native SQLite objects are never
freed from under an executing query. The lock is reentrant, so operations that call
into other guarded operations (e.g. pinManifestwrite, getPinnedManifest
read) don't deadlock. The suspending getOrPut still runs its download() outside
the lock (only its discrete read/write database steps are guarded), so downloads
are not serialized and no coroutine blocks across a suspension point.

Files Changed

  • zipline-loader/src/commonMain/kotlin/app/cash/zipline/loader/ZiplineCache.kt — guard close() and every database-touching method with a reentrant lock.
  • zipline-loader/src/commonTest/kotlin/app/cash/zipline/loader/internal/cache/ZiplineCacheConcurrencyTest.kt — regression test: close() must wait for an in-flight query instead of tearing down the driver under it.

Stacktrace (from crash reports)

_sqlite3_reset (/usr/lib/libsqlite3.dylib)
co.touchlab.sqliter.interop.ActualSqliteStatement#resetStatement
app.cash.sqldelight.driver.native.NativeSqliteDriver#accessConnection
app.cash.sqldelight.driver.native.ConnectionWrapper#executeQuery
app.cash.zipline.loader.internal.cache.FilesQueries.GetQuery.execute
app.cash.sqldelight.ExecutableQuery#executeAsOneOrNull
app.cash.zipline.loader.ZiplineCache.$getOrPutCOROUTINE$0.invokeSuspend
app.cash.zipline.loader.internal.fetcher.FsCachingFetcher.FsCachingFetcher$fetch$2 …
kotlinx.coroutines.MultiWorkerDispatcher$workerRunLoop$1 …  (background worker thread)

Breadcrumbs immediately before the crash show a sign-out/cleanup sequence
(EntitySyncer … clearData, SyncEntity Sync Wipe reason:"LOGOUT",
… became invalidated as part of sign out cleanup), consistent with the cache being
closed on the host thread while a load was still in flight.

Testing

  • Added ZiplineCacheConcurrencyTest.closeWaitsForInFlightDatabaseOperation: a background thread holds a query "in flight" inside an instrumented SqlDriver while the main thread calls close(). It asserts the driver is never closed while a query is executing. This fails before the fix (close tears the driver down mid-query) and passes after. Could not be executed in the fix environment (no Gradle distribution download / no Android SDK), so it was verified by construction and code review, not a live run.
  • No public API change (private field + unchanged method signatures), so binary-compatibility-validator .api files are unaffected.

Review Guidance

  • Key invariant: driver.close() must never run concurrently with any database.*
    access. Verify every method that touches driver/database acquires lock
    (close, read, write, unpin, getPinnedManifest, pinManifest,
    unpinManifest, updateManifestFreshAt, prune, countFiles, countPins,
    initialize); the private helpers (read(metadata), openForWrite, setReady,
    getOrNull, getOrPutManifest, createPinIfNotExists, deleteDirtyFiles) are only
    ever reached from a guarded caller.
  • getOrPut intentionally is not wrapped as a whole — only its read/write steps
    are guarded — so the network download() doesn't hold the lock.
  • Regression signal: any new driver/database access added outside synchronized(lock)
    reopens the race.
  • Uses kotlinx.coroutines.internal.SynchronizedObject (already an api dependency via
    kotlinx-coroutines-core) behind @OptIn(InternalCoroutinesApi::class); it is
    reentrant on both JVM and native. If you'd prefer to avoid the internal coroutines API,
    swap in an atomicfu reentrantLock() — the guarding structure is identical.

🤖 Generated with Claude Code

ZiplineCache.close() closed the SQLite driver without synchronizing
against in-flight database operations. On iOS this raced during
sign-out: the Treehouse/Zipline cache was closed on the host thread
while a code load was still reading from the cache on a background
worker, so sqlite3_reset dereferenced a freed statement
(EXC_BAD_ACCESS, "dereference garbage pointer").

Guard all driver/database access and close() with a single reentrant
lock so close() waits for any in-flight operation to finish before it
tears down the driver.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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