Skip to content

feat: implement dispose() for deterministic native resource release - #198

Open
jslok wants to merge 2 commits into
margelo:mainfrom
jslok:feat/deterministic-dispose
Open

jslok wants to merge 2 commits into
margelo:mainfrom
jslok:feat/deterministic-dispose

Conversation

@jslok

@jslok jslok commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Stacked on #197 (its commit is the first one here); review the second commit, or merge #197 first and this rebases cleanly.

HybridTfliteModel inherits Nitro's no-op dispose(), so model.dispose() from JS frees nothing until GC drops the last reference. Worklet runtimes may not GC for a long time, especially while backgrounded, so large models and their GPU delegate contexts stay resident with no way to release them. In our app, releasing scanner models on background through a working dispose() frees ~86 MB.

dispose() now resets the interpreter shared_ptr under a lifecycle mutex. The model is the interpreter's only owner, so that runs TfLiteInterpreterDelete and then frees the delegates immediately; the model-bytes and cached output-buffer references are dropped too. The mutex serializes it against an in-flight inference on another thread (freeing the interpreter mid-TfLiteInterpreterInvoke is a native crash), run() re-checks on the async thread, and post-dispose calls throw TFLite: Model was disposed! as a catchable JS error. Idempotent; the destructor stays defaulted.

We run the pre-#205 form of this as a patch-package fix in production (models disposed on background with an active frame processor racing it, reloaded on foreground) without crashes. The rebased version is compile-checked against the NDK; iOS not rebuilt yet.

Two related delegate-lifecycle fixes in createModel:

1. Skip null delegates: delegate factories can legitimately return nullptr
   (e.g. TfLiteCoreMlDelegateCreate on devices without a Neural Engine when
   enabled_devices is ANE-only). Registering that nullptr with
   TfLiteInterpreterOptionsAddDelegate crashes/corrupts the interpreter.
   Now a null delegate is skipped so the model falls back to CPU, and
   getDelegates() reports only the delegates that were actually registered.

2. Free delegates: TFLite's C API does not transfer delegate ownership to
   the interpreter - the caller must delete delegates itself after the
   interpreter is destroyed. They were never freed, so every model
   destruction leaked the delegate's compiled kernels / driver contexts
   (GPU: TfLiteGpuDelegateV2Delete, NNAPI: TfLiteNnapiDelegateDelete,
   CoreML: TfLiteCoreMlDelegateDelete). Each delegate is now held in a
   unique_ptr with its own delete function; ownership moves into the
   interpreter's shared_ptr deleter, so delegates are freed right after
   TfLiteInterpreterDelete (they must outlive the interpreter) and on
   every failure path in createModel via normal unwinding.
HybridTfliteModel inherits Nitro's default no-op dispose(), so JS calling
model.dispose() frees nothing - the interpreter, delegates and model buffer
only go away when GC drops the last reference. Worklet runtimes (frame
processors) may not GC for a long time, especially while the app is
backgrounded, so multi-hundred-MB models and their GPU contexts stay
resident with no way to release them deterministically.

This implements a real dispose():

- Resets the interpreter shared_ptr immediately. The model is its only
  owner, so the interpreter deleter runs right away: TfLiteInterpreterDelete,
  then the delegates. Our references to the model bytes and the cached
  output buffers are dropped too.
- Thread-safe via a lifecycle mutex: dispose() blocks until an in-flight
  inference on another thread completes - freeing the interpreter under a
  running TfLiteInterpreterInvoke would be a native crash. The mutex is
  uncontended in normal operation (~ns per lock vs ~ms per inference).
- run() re-checks disposal on the async thread, since dispose() may land
  between the caller-thread input copy and the async invoke.
- All post-dispose calls (runSync/run/getInputs/getOutputs) throw a
  catchable JS error ('TFLite: Model was disposed!') instead of crashing.
- Idempotent; the destructor stays defaulted since a disposed model holds
  nothing.
@jslok
jslok force-pushed the feat/deterministic-dispose branch from b1ca7cc to bc2c9d4 Compare September 14, 2026 05:46
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