fix(jni): detach native threads and preserve callbacks - #51
fix(jni): detach native threads and preserve callbacks#51AlexProgrammerDE wants to merge 3 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
This PR hardens the JNI lifecycle and callback teardown behavior for the stable C bindings of libdatachannel-java, addressing native-thread detachment correctness, unload safety, and callback lifetime ordering. It also adds a host-only JNI regression test to validate that native pthread termination no longer leaves stale attached-thread records in HotSpot.
Changes:
- Serialize JVM access / native-thread attachment / unload using a single mutex and correct POSIX TLS destructor usage for detaching threads.
- Make initialization/unload paths fail-fast and cleanup on partial state (pthread key creation,
GetEnv, module init/preload exceptions). - Fix peer deletion callback lifetime ordering and add a host-only regression test + Gradle runtime dependency for the JUnit Platform launcher.
Reviewed changes
Copilot reviewed 8 out of 9 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| src/test/java/tel/schich/libdatachannel/NativeThreadLifecycleTest.java | Adds a JUnit regression test asserting terminated native threads are detached and observed as TERMINATED. |
| jni/test/thread_lifecycle.c | Implements the JNI native helper that spawns/joins a pthread and returns its Thread object for assertions. |
| jni/src/native_peer.c | Ensures callback/global-ref cleanup occurs only after rtcDeletePeerConnection completes successfully. |
| jni/src/init.c | Adds mutex-guarded JVM lifecycle state, correct TLS destructor handling, and stricter init/unload cleanup paths. |
| jni/CMakeLists.txt | Adds BUILD_JNI_TESTS option to include JNI test sources only for host builds. |
| jni/build.sh | Wires BUILD_JNI_TESTS through to CMake. |
| gradle/libs.versions.toml | Adds the JUnit Platform launcher dependency entry. |
| conventions/src/main/kotlin/tel.schich.libdatachannel.convention.common.gradle.kts | Adds testRuntimeOnly for the JUnit Platform launcher so tests run under newer Gradle. |
| build.gradle.kts | Enables BUILD_JNI_TESTS=ON for host native builds used by the JVM test suite. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
4fc3f71 to
826d08b
Compare
|
Can you elaborate a bit on what concrete issues this fixes? |
|
|
I also added a unit test for properly start/stop and thread state. |
|
Hi, any update on this? |
|
This is not a small change, so I have to find some proper time to get into this. I'm still fairly uncertain about the changes around the lifecycle, so I'll have to do some research on that for my own understanding if there really is an issue. Can you introduce tests for all the issues you had, ideally without relying on JNI? For example (2) and (4) feel like they should be reproducible in tests. For (1) and (3) I'm not sure how these issues would present themselves. |
Summary
rtcDeletePeerConnectiondrains scheduled callbacksThreadas terminatedRoot cause
POSIX clears a thread-specific key before calling its destructor and passes the previous value as the destructor argument. The current destructor ignores that argument and calls
pthread_getspecific(thread_key), which therefore returnsNULL.DetachCurrentThreadis skipped and the terminated native thread remains registered with the JVM.The surrounding JVM lifecycle also permits unsafe partial states. The JVM pointer and unload state were accessed without common synchronization, pthread key creation and thread-specific registration failures were ignored, and
JNI_OnUnloadrelied on the shared environment accessor while tearing down the same state.The peer deletion wrapper has a separate lifetime inversion. It frees the callback allocation and its JNI global reference before calling
rtcDeletePeerConnection, even though that operation is what blocks until scheduled callbacks return. A callback racing with deletion can consequently dereference the freed user pointer.This replaces #50 with the same fixes ported to the production-oriented
stablebranch and its C JNI implementation.Lifecycle hardening
JVM lifecycle state is now protected by one mutex so unloading cannot delete the pthread key while a callback thread is between attachment and TLS registration. A failed
pthread_setspecificimmediately detaches the thread. Load failures clean up the pthread key and returnJNI_ERR; initialization exceptions also release cached global references and preloaded RTC state.Unload first prevents new JVM access, disables Java logging callbacks, drains libdatachannel, obtains the unloading thread's existing
JNIEnv*directly from the suppliedJavaVM*, releases cached global references when an environment is available, and finally deletes the pthread key.Impact
Terminated callback threads no longer leave stale attached-thread records in HotSpot. Load and unload cannot publish or retain partially initialized JNI state, and peer teardown retains its callback state until libdatachannel guarantees that no pending callback can use it.
Validation
./gradlew check --no-configuration-cachesuccessfully on Linux with JDK 25.NativeThreadLifecycleTestruns and passes.References:
pthread_key_createdestructor semantics