Skip to content

Java: optional non-blocking finalizers via a native destruction queue#180

Open
amit-nayar wants to merge 2 commits into
cross-language-cpp:mainfrom
amit-nayar:java-non-blocking-finalizers
Open

Java: optional non-blocking finalizers via a native destruction queue#180
amit-nayar wants to merge 2 commits into
cross-language-cpp:mainfrom
amit-nayar:java-non-blocking-finalizers

Conversation

@amit-nayar

Copy link
Copy Markdown

Problem

A generated CppProxy.finalize() destroys the native object inline on the GC finalizer thread:

protected void finalize() throws java.lang.Throwable {
    _djinni_private_destroy();
    super.finalize();
}

If the C++ destructor can block — in our case a page object whose destructor acquires a rendering mutex that a long-running parse holds for tens of seconds — two things happen on Android:

  1. FinalizerWatchdogDaemon enforces a deadline (10 s by default) per finalize() and kills the process with java.util.concurrent.TimeoutException: …CppProxy.finalize() timed out — a well-known fatal crash class in Play Console. We hit this in production-bound code.
  2. While blocked, the finalizer thread stalls every other pending finalization in the app.

Change

New opt-in flag --java-non-blocking-finalizers <true/false> (default false — generated output is byte-identical unless enabled). When enabled:

  • Generated finalizers enqueue the destruction instead of destroying inline:

    protected void finalize() throws java.lang.Throwable {
        DjinniNativeDestructionQueue.enqueue(this::_djinni_private_destroy);
        super.finalize();
    }
  • A DjinniNativeDestructionQueue support class is emitted once per generation run (via createFileOnce) into the Java output package: an unbounded queue drained by a single daemon thread that may block freely; one failed destruction never kills the queue.

Notes on correctness:

  • Enqueueing this::_djinni_private_destroy deliberately resurrects the proxy until the queue has destroyed it — legal per the JLS (an object's finalizer runs at most once) — so the native reference stays valid until nativeDestroy has run.
  • The existing AtomicBoolean destroyed guard is unchanged; explicit _djinni_private_destroy() callers keep synchronous, deterministic destruction.
  • No JNI signature changes; C++/ObjC outputs are untouched.
  • This is also a step toward JEP 421 (finalization deprecated for removal): the same queue works as the cleanup action for a future Cleaner/PhantomReference-based mechanism.

Testing

  • New integration test (non_blocking_finalizers.djinni + expected output) covering the generated finalizer and the emitted support class.
  • Full integration suite passes (30/30); with the flag off, no expected file changes.
  • The second commit fixes a pre-existing NullPointerException in removeTestOutputDirectory (System.console() is null in non-interactive runs, e.g. CI/piped output), which aborted the integration suite whenever a previous run had left the result directory behind.

We are running the equivalent patch in production at Nutrient (PSPDFKit) against our vendored 1.3.2 generator. Happy to adjust naming/approach to maintainer preference.

A generated CppProxy.finalize() destroys the native object inline on
the GC finalizer thread. If the C++ destructor can block — e.g. on a
lock held by a long-running native operation — Android's
FinalizerWatchdogDaemon kills the process once a single finalize()
exceeds its deadline (10 s by default: 'TimeoutException: finalize()
timed out'), and the blocked finalizer thread stalls every other
pending finalization in the app.

Add an opt-in flag, --java-non-blocking-finalizers: generated
finalizers enqueue this::_djinni_private_destroy to a generated
DjinniNativeDestructionQueue support class (emitted once per run),
whose dedicated daemon thread may block safely. Enqueueing resurrects
the proxy until destruction has run — legal per the JLS, an object's
finalizer runs at most once — so the native reference stays valid; the
existing AtomicBoolean guard is unchanged and explicit
_djinni_private_destroy() callers keep synchronous destruction.

Default is false: generated output is unchanged unless the flag is set.
No JNI or C++/ObjC changes.
System.console() returns null when the test JVM runs without an
interactive console (CI, piped output), so the integration-test suite
aborted at construction with a NullPointerException whenever a previous
run had left the result directory behind. Use println instead.
@amit-nayar amit-nayar force-pushed the java-non-blocking-finalizers branch from 1a81686 to 722af07 Compare June 12, 2026 18:45
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