Fix EXC_BAD_ACCESS when ZiplineCache is closed during an in-flight query - #1831
Open
jgbirk wants to merge 1 commit into
Open
Fix EXC_BAD_ACCESS when ZiplineCache is closed during an in-flight query#1831jgbirk wants to merge 1 commit into
jgbirk wants to merge 1 commit into
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Crash Info
EXC_BAD_ACCESSRoot Cause
ZiplineCache.close()closed the SQLiteSqlDriverwithout any synchronizationagainst in-flight database operations. All cache database access is serialized on a
single-threaded cache dispatcher, but
close()is invoked directly from the hostthread 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.fetch→ZiplineCache.getOrPut→read→FilesQueries.GetQuery.execute).driver.close()finalized/freed the nativeSQLite prepared statements while another thread was mid-query, so
sqlite3_resetdereferenced freed memory →
EXC_BAD_ACCESS. The@Volatile closedflag did notprevent this: it is a time-of-check/time-of-use race — a thread can pass the
if (closed) …guard and enterexecuteAsOneOrNull()just beforeclose()sets theflag and tears down the driver.
Fix
Added a single reentrant lock (
kotlinx.coroutines.internal.SynchronizedObject) thatguards all access to
driver/database, includingclose(). Becauseclose()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.
pinManifest→write,getPinnedManifest→read) don't deadlock. The suspendinggetOrPutstill runs itsdownload()outsidethe lock (only its discrete
read/writedatabase steps are guarded), so downloadsare not serialized and no coroutine blocks across a suspension point.
Files Changed
zipline-loader/src/commonMain/kotlin/app/cash/zipline/loader/ZiplineCache.kt— guardclose()and every database-touching method with a reentrantlock.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)
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 beingclosed on the host thread while a load was still in flight.
Testing
ZiplineCacheConcurrencyTest.closeWaitsForInFlightDatabaseOperation: a background thread holds a query "in flight" inside an instrumentedSqlDriverwhile the main thread callsclose(). 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.binary-compatibility-validator.apifiles are unaffected.Review Guidance
driver.close()must never run concurrently with anydatabase.*access. Verify every method that touches
driver/databaseacquireslock(
close,read,write,unpin,getPinnedManifest,pinManifest,unpinManifest,updateManifestFreshAt,prune,countFiles,countPins,initialize); the private helpers (read(metadata),openForWrite,setReady,getOrNull,getOrPutManifest,createPinIfNotExists,deleteDirtyFiles) are onlyever reached from a guarded caller.
getOrPutintentionally is not wrapped as a whole — only itsread/writestepsare guarded — so the network
download()doesn't hold the lock.driver/databaseaccess added outsidesynchronized(lock)reopens the race.
kotlinx.coroutines.internal.SynchronizedObject(already anapidependency viakotlinx-coroutines-core) behind@OptIn(InternalCoroutinesApi::class); it isreentrant on both JVM and native. If you'd prefer to avoid the internal coroutines API,
swap in an
atomicfureentrantLock()— the guarding structure is identical.🤖 Generated with Claude Code