Repository navigation
openStore() blocks forever without error when a new isolate re-opens the store after the previous isolate died (process kept alive, Store.isOpen() returns false) #834
Description
Is there an existing issue?
- I have searched existing issues
Build info
- objectbox version: 5.3.2 (latest stable, with objectbox_flutter_libs 5.3.2)
- Flutter/Dart version: Flutter 3.41.9 stable
- Build OS: macOS 15
- Deployment OS or device: Android 16, physical device, release build
Summary
openStore() blocks forever, with no error, when main() is re-run by a new FlutterEngine/isolate in a process whose previous main isolate died without explicitly closing the Store. At that point Store.isOpen(dbPath) already returns false, so the Store.attach() workaround from #436 does not help — the code correctly takes the openStore() path and then never returns. The UI thread stays blocked until the process is ANR-killed by Android; the freshly restarted process then opens the very same directory in ~7 ms.
This is different from the well-known 10001 Cannot open store: another store is still open using the same path cases (#436, #387, #699): there the old store is still registered and open fails fast with an error. Here the registry already says "not open", yet the underlying env/lock apparently hasn't been released, and obx_store_open seems to wait for it indefinitely.
Steps to reproduce
Hard to reproduce in a minimal project on an emulator (emulators kill the process together with the Activity), but the conditions are well understood. Our app is a wearable companion app:
- Android app whose process is kept alive by a foreground service (BLE device companion). The process in our logs had been alive for 1.5+ days.
main()opens the Store with the guard recommended in [Flutter]openStore()is not working across multiple FlutterEngines #436:
Store.isOpen(path) ? Store.attach(model, path) : await openStore(directory: path).- The Store is never explicitly closed; closing relies on the
NativeFinalizer(store_close)attached by the library. The app has several activequery.watch()subscriptions (see also [Flutter] query.watch() never seems to close the underlying query #448 — watch never closes the underlying query). - Put the app in the background and let Android reclaim the Activity: the Flutter engine and the main isolate are destroyed, the process stays alive (foreground service).
- Tap the app icon: the same process creates a new FlutterEngine and re-runs
main(). Store.isOpen(dbPath)returnsfalse→openStore()is called → it never returns.
Expected behavior
Either the open succeeds (possibly after briefly waiting for the closing store), or it fails fast with an error (like the 10001 error in the registered-store case). Anything but an indefinite, silent block of the Dart/UI thread.
Actual behavior
openStore() blocks forever. No exception, no log output from the database library. Native (non-Dart) threads of the process keep running normally, which rules out the process being frozen — only the Dart thread inside the synchronous obx_store_open FFI call is stuck. After ~38 s Android kills the process (ANR); the automatically restarted process opens the same directory in 7 ms, which shows the stale lock/env lived (and died) with the old process.
Code
Code
class DBManager {
Store? _store;
Future<void> init({String? directory}) async {
if (_store != null) return;
final docsDir = await getApplicationDocumentsDirectory();
final dbPath = p.join(docsDir.path, 'my-db');
if (Store.isOpen(dbPath)) {
// Workaround from #436 — not taken in this case, isOpen() returns false
_store = Store.attach(getObjectBoxModel(), dbPath);
} else {
// Blocks forever here
_store = await openStore(directory: dbPath, maxDBSizeInKB: 262144);
}
}
}Logs, stack traces
Timestamps from our production (release build) file log. Instrumentation lines around the store open are custom [Startup] markers.
Logs
# Old process (pid 32078), alive for >1.5 days, kept alive by a foreground service.
# Its Flutter engine + main isolate had been destroyed earlier while backgrounded
# (Dart-side log lines stopped ~25 min before this; native threads still logging).
# User taps the app icon -> same process, new FlutterEngine, main() re-runs:
11:45:42.914 [Startup] LogUtils initialized, debug=false
11:45:42.915 [Startup] SPManager initialized
11:45:42.915 [Startup] DBManager.init() begin
11:45:42.917 [Startup] DB path resolved <- getApplicationDocumentsDirectory() returned
11:45:42.917 [Startup] DB store opening <- Store.isOpen(dbPath) == false, calling openStore()
# ... openStore() never returns. For the next 38 seconds the process keeps logging
# from native BLE threads, but nothing more from Dart. No exception anywhere.
11:46:20.8 <process killed by the system (ANR)>
# Android immediately restarts the process (pid 20435):
11:46:21.752 [Startup] DBManager.init() begin
11:46:21.752 [Startup] DB path resolved
11:46:21.752 [Startup] DB store opening <- same branch, fresh process
11:46:21.756 [Startup] DBManager initialized (+7ms, total=21ms)Analysis / questions
Our best reading of the sequence:
- When the old main isolate died, the
NativeFinalizer(store_close)ranobx_store_close. The store had livequery.watch()streams / queries whose own native resources are also only cleaned up by finalizers, with no ordering guarantee relative to the store finalizer, andobx_store_closedocuments that it waits for transactions to finish. - That close apparently got far enough to remove the path from the "open stores" registry (
Store.isOpen()→ false) but not far enough to release the underlying env/lock. - The new isolate's
obx_store_openthen waits on that never-to-be-released env — a deadlock that only process death resolves.
objectbox-java#625 (2018) describes the same skeleton on Android/Java — Activity destroyed, process kept alive by the OEM, re-creating the BoxStore gets stuck — and was closed as not reproducible on emulators. A process kept alive by a foreground service is exactly the precondition emulators don't reproduce; with Android 16's aggressive background Activity reclamation this combination is getting more common, not less.
Questions:
- Does
obx_store_openwait (indefinitely?) for a concurrently-closing env of the same path? Could it fail with an error or a bounded timeout instead? - Is there a recommended way to make the isolate-shutdown close robust when watched queries are still alive?
Our app-side mitigation is to explicitly cancel watchers and Store.close() on AppLifecycleListener.onDetach, but detached is not guaranteed to run, so this narrows the window without closing it.
Activity
greenrobot-team commentedon Aug 31, 2026
Thanks for this detailed report! I shared this with the development team.
ericccg commentedon Aug 31, 2026
Experience the same issue with app with BLE and foreground service on multiple Android 16 Samsung devices.
For the Android 15 and lower devices, haven't experience it.
greenrobot commentedon Aug 31, 2026
Thanks for the detailed report and the careful analysis — this is a well-described scenario. We looked into it on the wrapper side and tried to reproduce the mechanism you describe. Here is what we found.
What the Dart wrapper does when an isolate dies without closing the Store
Storeattaches aNativeFinalizerforobx_store_close. Dart guarantees it runs at isolate-group shutdown, and destroying aFlutterEngineis exactly that. Note that it calls the rawobx_store_closeonly — none of the Dart-side cleanup inStore.close()runs (boxes are not closed,onCloselisteners don't fire, observers are not closed).Query(andPropertyQuery,Admin) also have native finalizers (obx_query_closeetc.). The order relative to the store finalizer is unspecified.store.watch<T>(),store.entityChangesand thereforequery.watch()have no finalizer. If the isolate dies with live subscriptions,obx_observer_closeis never called. You are right about that part (it's related to [Flutter] query.watch() never seems to close the underlying query #448).Store.isOpen()is a direct call toobx_store_is_open(path)in the native library; there is no Dart-side registry involved across engines.What we tested
A Dart isolate opens a Store, sets up some state, never closes the Store, and is then either killed (
Isolate.kill, which is what an engine destroy amounts to) or exits viaIsolate.exit(). The main isolate then checksStore.isOpen()and opens thesame directory.
isOpen()afterwardsfalsestore.watch<T>()/entityChangessubscriptionsfalsequery.watch(triggerImmediately: true)subscriptionsfalseQueryobjectsfalseputAsync()+ 50putQueued()falsefalserunAsync()worker isolate still holding an attached storetrueEvery variant reopened immediately, in both kill and exit modes, and no ObjectBox threads were left behind (verified with a debugger). So with the current library we could not produce a state where
isOpen()isfalseandobx_store_openblocks —regardless of live watchers, unclosed queries or pending async writes. (This was on Linux x64; the native core is the same on Android, but we can't rule out a platform-specific difference yet.)
Notes on the proposed sequence
NativeFinalizerclose got stuck waiting for transactions/queries" — this doesn't fit your own log.FlutterEngine.destroy()runs on the Android main thread and blocks until the UI isolate has shut down; the native finalizers runsynchronously as part of that shutdown. If
obx_store_closehad hung there,Activity.onDestroy()would never have returned andmain()could not have been re-run in that process 25 minutes later. So the earlier close either completed, or thefinalizer never ran at all.
obx_store_openwaits on a not-yet-released env" — we could not find any state that produces this. Whether the core could block on a same-path store in a closing state is something we'll check with the core team, but we need a concrete triggerfirst.
System.runFinalization()call insideverifyNotAlreadyOpen(), not in the native open.What we'd need from you
The log shows
openStore()being called and nothing after it, but it doesn't show where the thread actually is. The following would help a lot:/data/anr/…from a bugreport, or the ANR stack from Play Console). The native frames of the blocked thread answer the question directly — whether it's inobx_store_open, in LMDB locking, or somewhere else entirely (e.g.engine/plugin setup on the main thread).
objectbox,objectbox_flutter_libs, Flutter SDK. Also whether the app runs with merged UI/platform threads (default on recent Flutter versions) — a blocked Dart FFI call only produces an ANR if Dart runs on the main thread.android:processattribute), and does it run any Dart code or touch ObjectBox itself (e.g. via a background engine / background service plugin)? A second process or engine touching the samedirectory would give exactly
isOpen() == falseplus a blocked open, sinceisOpen()only knows about the current process.Store.debugLogs = trueanddebugFlags: DebugFlags.logTransactionsWrite | DebugFlags.logTransactionsReadpassed toopenStore(). The native library then logs to logcat (tagBox) whether the open was entered and how far the earlierclose got.
Mitigations for now
Store.close()onAppLifecycleListener.onDetachis the right thing to do, as you already did. Also make sure theStoreinstance stays strongly referenced for the lifetime of the isolate, so the finalizerdoesn't race with garbage collection.
Store.isOpen(path) ? Store.attach(...) : openStore(...)guard; it covers the case where an engine/isolate is still alive.On our side, independent of this issue, we noted two wrapper-level gaps that are worth improving: observers should be closed when the store is finalized (they currently leak native observers on isolate death), and the finalizer path should run the same
cleanup as an explicit
Store.close()(e.g.putQueued()async handles are currently not closed by the finalizer). Neither showed any effect on reopening in our tests, but they should be tightened.weinierfei commentedon Sep 1, 2026
Thanks for the thorough analysis and for testing all those scenarios — much appreciated. Answers to your questions, plus what we've set up to capture the next occurrence.
2. Versions / threading
objectbox: 5.3.2,objectbox_flutter_libs: 5.3.2(both latest stable)3. Process / foreground-service topology
android:process), is pure Kotlin (LifecycleService), creates no FlutterEngine and never touches ObjectBox files.:remote) — pure native, no access to our documents directory.workmanager, so there is a second FlutterEngine in the same process for periodic background tasks (cloud sync / weather). Its Dart entrypoint runs the same DB init:Store.isOpen(path) ? Store.attach(...) : openStore(...). The three days of collected app logs around the incident show no background-task activity, but the background engine logs to a separate file that wasn't collected, so we can't fully rule out background-engine activity near the incident. (If a background engine had been holding an attached store at that moment,isOpen()should have returnedtrue, which contradicts the observedfalse— mentioned for completeness, as it is the only other thing in this process that ever opens this store.)Storeinstance is held by a singleton for the whole isolate lifetime — no GC/finalizer race on our side.1. + 4. ANR trace & debug logs
Your point about engine destroy blocking on the Android main thread is fair: since
main()re-ran in the same process ~2.5–25 minutes after the old isolate died, the finalizer close either completed or never ran — it cannot have been stuck inside engine destroy. So the "close stuck in the finalizer" part of our hypothesis is wrong. What remains observed is only:Store.isOpen() == false,openStore()never returning, and native (non-Dart) threads of the same process running normally throughout the 38-second hang, after which the process was killed and a fresh process opened the same directory in 7 ms.We've added a diagnostics flag to the app (
Store.debugLogs = true+debugFlags: DebugFlags.logTransactionsRead | DebugFlags.logTransactionsWriteon open). Test devices are running this build now; when the hang reproduces we'll capture:adb bugreport— ANR traces with the native frames of the blocked threadBoxand report back here.
ericccg commentedon Sep 1, 2026
From the stack trace,
It's possible that BLE & foreground service keeps the process alive past previous app session removal.
Although app activity is destroyed and the isolate is torn down as well when kill the app, objectbox's store is owned by the process, not by the isolate. When relaunch the app, it reuses the same process, main() runs in a fresh isolate, and openStore() hits 10001 error.
I have tried run close upon detach, it does not work on our end of devices, so I tinker around and maybe found a workaround? Instead of trying to close the store upon on the way out; attach to the exact leaked one on the way in. Some thing like this.
greenrobot commentedon Sep 1, 2026
Thanks for the answers — the second FlutterEngine (workmanager) was the missing piece. Following that thread, we found a mechanism that reproduces the exact state of the original report (
Store.isOpen()false while the native store is not released), andit comes with a very concrete thing to look for in your logs. But first, since the last comment describes a different state, let's separate the two.
Two different states in this thread
Store.isOpen(path)openStore()true10001 another store is still open using the same pathfalse@ericccg: your case is A. The store is not "owned by the process" as such — the wrapper closes it via a native finalizer when the isolate that opened it shuts down — but the underlying store stays open as long as any handle to it exists, and those
can outlive the engine: isolates spawned with
Isolate.spawnare not terminated when the engine's root isolate shuts down (we verified this; they keep running as orphans with their attached store handle), and a second engine (workmanager, backgroundservice) may hold one too. One concrete way we found: a
query.stream()whose consuming isolate dies before the stream completed leaves a worker isolate waiting forever, holding a store handle — we'll fix that the worker exits when its parent is gone.So
Store.isOpen(path) ? Store.attach(...) : openStore(...)is the right pattern for state A (it has been the recommended one since #436), and attaching to the still-open store is safe. It would help to know which ObjectBox APIs your app uses(
query.stream(),*Async()calls,putQueued) and whether there is a second engine, so we can pin down what keeps the handle alive on your devices.The original report is state B — the guard is already in place there,
isOpen()returnsfalse, and the open itself never returns. The rest of this comment is about state B.A state where
isOpen()is false but the store is not releasedWe can produce it on Linux with the current library:
Isolate.kill()(or anything else that ends an isolate through the VM's unwind path). The unwind skipscatch/finally, so the wrapper never closes the transaction — itstays "active" in the native store.
Boxin logcat, no debug flags needed):
Store.isOpen(path)returns false.obx_store_open()from the other engine then goes ahead against a store that is still alive in the same process. On Linux x64 this open succeeds (LMDB re-initialises its lock region). Android uses different locking primitives (no robust mutexes),and whether this same step blocks there is exactly what we are checking with the core team now — the ANR trace would show it directly.
If the close waits on a worker/pool thread (e.g. inside a worker isolate's finalizer), nothing in the app notices: the main thread is free, native threads keep running, and only the next
openStore()is affected. That matches your log — and a freshlystarted process sees none of this, which matches the 7 ms reopen.
For completeness: a destroyed FlutterEngine alone does not produce this. Orphaned workers (see above) keep their attached store handle, so
isOpen()staystrueuntil they finish — that is state A, not B. Something has to actually terminate anisolate mid-transaction.
What to check on your side (@weinierfei)
Box, forCannot close store yet. If this is what hit you, the line was repeating every 10 s in the old process for the ~25 minutes before the tap. Any occurrence — including from the workmanager engine's runs — is what we need,with the timestamp of the first one.
Isolate.kill()(orIsolate.exit()from inside code that touches the store)? Think worker isolates for BLE parsing that get terminated on lifecycle changes, isolate-pool packages, etc. Doesanything use
Isolate.runOnPlatformThread(platform isolates are shut down together with the engine)?putManyAsync/runInTransactionAsync/runAsync,putQueued,query.stream(), …?Until then: keep the
isOpen ? attach : openStoreguard, and avoid terminating isolates (Isolate.kill) while they may be inside ObjectBox calls — let them finish andIsolate.exit()afterstore.close().ericccg commentedon Sep 2, 2026
@greenrobot Thanks for clearing things up.
Our App with v5.0.4 & Flutter 3.35.7
For API, we used none of the ones you mentioned above, only sync calls:
put,putMany,getAll,removeMany,removeAll,box.query(...).build()with explicitquery.close().As for second engine, we only have single FlutterEngine, no workmanager. The BLE foreground service we used is
flutter_foreground_taskin the default process withForegroundTaskEventAction.nothing()— no Dart callback, never touches the store.Our own
Isolate.spawnworker is the case you described. Several run at once (one per DB). Close command comes from repo/providerdisposemethod, which never runs on swipe-away. The process survives on the foreground service, so the workers stay parked with attached handles —isOpen()istrueandopenStore()gives me 10001. That also explains why close-on-detach didn't work for us: the handle keeping the store alive belongs to a different, already-orphaned isolate, so closing the root isolate's store can't help.On my earlier Android 16 comment — probably not an OS difference. On ≤15 our process gets killed on swipe-away so the orphans die with it; on 16 it survives and carry into the relaunch.
Is there a recommended way for a spawned worker holding an attached Store to detect its parent isolate is gone?
weinierfei commentedon Sep 2, 2026
Thanks — the "TX still active → close waits forever → path deregistered → isOpen() false" mechanism is the first thing that fits every observation we have (main thread free, native threads running, 7 ms reopen in a fresh process). Answers to your four points, after an audit of the app and its dependencies:
1. logcat
Box/Cannot close store yetNot available retroactively: the logs we collected are the app's own xlog files, native logcat was not captured. The diagnostics build is on test devices now. In addition, since the string is now known, we'll add a startup self-check to the app: before
openStore(), runlogcat -d -s Boxin-process and copy anyCannot close store yetlines (with timestamps) into our app log — so devices without adb access (end users) can report it through the normal log upload.2.
Isolate.kill()/Isolate.exit()/ platform isolatesIsolate.kill,Isolate.exit,Isolate.spawnorIsolate.runOnPlatformThread. The only isolate we create ourselves is oneIsolate.run()for image resizing (no ObjectBox access).Isolate.kill/exit:dio(itscompute),image(isolate executor),media_kit— none of them touch ObjectBox.query.findAsync()(yourrunAsyncworker) from one UI page;query.watch()in ~19 DAOs.query.stream()appears in two DAO methods, but both have no callers (dead code), so no stream worker is ever spawned.3. Background task (workmanager engine) — ObjectBox APIs
Store.isOpen(path) ? attach : openStore, then uses only synchronous APIs (box.put/putMany,query.find,store.runInTransaction). No*Async, noputQueued, nowatch/stream. When the task returns, workmanager destroys that engine.So in this process an engine is destroyed roughly hourly (background engine) plus whenever the Activity is recycled (main engine), but we cannot find any code path that terminates an isolate mid-transaction ourselves.
4. ANR trace
Still waiting for a reproduction. One thing worth knowing for anyone else trying to capture it: in the original incident the process exit reason (ApplicationExitInfo) was
USER_REQUESTED / [FORCE STOP] due to SwipeUpClean— the system never declared an ANR, because the stuck window was the launch screen with no input dispatched to it. So there is no trace in/data/anrafterwards; a bugreport has to be taken while the hang is in progress. We've instructed testers accordingly.A question back
Given the above, the only in-process candidates for a mid-transaction termination we can identify are FlutterEngine teardowns with an ObjectBox worker (
findAsync/runAsync) still in flight — main engine on Activity recycling, background engine after each run. You verified orphan behaviour on the standalone Dart VM; could you check the same on an actual FlutterEngine? If Flutter's engine teardown ends child isolates through the VM unwind path rather than leaving them as orphans, afindAsyncworker inside its read transaction at that moment would produce exactly the state you described.@greenrobot
greenrobot commentedon Sep 2, 2026
Thanks to both of you — the new details show that these are two different states, and they settle one of them completely and narrow the other.
@ericccg — state A (
isOpen()true,openStore()fails with 10001)Your reading is right, and it matches what we verified on our side: a FlutterEngine teardown shuts down only the engine's root isolate. Isolates spawned with
Isolate.spawnareterminated neither by Flutter nor by the Dart VM — they keep running as orphans, together with their attached store handles. Your
dispose()never runs on swipe-away, so the parkedworkers keep the store open. On Android 16 the process survives the swipe-away thanks to the foreground service, so the relaunch meets that state; on ≤ 15 the process died with the
Activity. So no OS-level difference in ObjectBox, just process lifetime.
Attaching in that situation (
Store.isOpen(path) ? Store.attach(...) : openStore(...)) is safe: the store is healthy, it is just owned by isolates you can't reach anymore.To let a worker notice that its parent is gone, use an exit listener: pass the spawning isolate's control port to the worker, register a listener on it and close the attached store
when the message arrives. An engine teardown is a normal isolate shutdown, so the listener fires:
We verified this: the worker's handle is closed within milliseconds of the parent's exit and
Store.isOpen()turns false (the message is handled by the worker's event loop, so aworker in the middle of a transaction finishes it first). We'll use the same mechanism for our own
Query.stream()worker, which currently waits for its spawning isolate forever, anddocument it for
Store.attach().@weinierfei — state B (
isOpen()false,openStore()blocks)To your question back: no, engine teardown does not end child isolates through the VM's unwind path. Flutter shuts down the root isolate only (platform isolates aside), the VM merely
waits for spawns that are still in progress, and @ericccg's workers surviving on a real device confirm it. A
findAsync()worker in flight at teardown therefore becomes an orphan,finishes its read transaction, closes its handle and exits normally — it cannot leave a transaction behind.
With your inventory that leaves no code path we know of that keeps a write transaction active without something terminating an isolate — and only a write transaction makes the close
wait unboundedly (read transactions only affect a bounded wait). We also checked the two remaining candidates your list suggests:
query.watch()observers of a dead isolate justpost to a closed port (non-blocking, cannot stall a commit), and the order in which Dart runs the finalizers at isolate shutdown (store before queries) is harmless. So the mechanism
still fits every observation you have, but we cannot name the trigger in your app. The data you are collecting will decide:
Box:Cannot close store yet: TX #… is still active in thread …— the startup self-check that copies these lines into your app log is a great idea.SwipeUpCleandetail, good to know there is no ANR trace to collect afterwards.Store.debugLogs = truethe wrapper printsOpening store (C lib …) … isOpen=…immediately before the native open call, so the diagnostics build will also show whether thethread is inside
obx_store_open()at all, or stuck before it.What we changed meanwhile
Core (next release): closing a store no longer waits forever for a transaction that only the closing thread itself could finish — it aborts that transaction with an error log instead
— and a bounded close no longer ends in undefined behavior. That removes at least one way to arrive at "close waits forever, path already deregistered": a transaction leaked by a
terminated isolate whose finalizer closes the store on the very same thread. Whether that is what hit your app, only the logs above can tell.
Query.stream(): copy results within the read transaction
Transaction: close transactions still open when an isolate shuts down
Observers: close native observers when an isolate shuts down
Store.attach(): document lifetime of attached stores in worker isolates
greenrobot-team commentedon Oct 12, 2026
The latest prerelease
6.0.0-preview.4has various fixes to no longer prevent an isolate from exiting and the store from closing, see the release notes for details.