Backend
VL (Velox)
Bug description
When running TPC-DS or heavy scan workloads on HDFS with IOThreads > 0 and SplitPreloadPerDriver > 0, the JVM process has a change to crash with SIGSEGV inside jni_NewStringUTF during hdfsGetPathInfo(). The crashing thread is a CPUThreadPoolN thread used for async split preloading.
Expected behavior: HDFS file operations should work reliably on IOThreadPool threads across consecutive preload tasks.
Actual behavior: After a certain number of tasks, the IOThreadPool thread crashes with SIGSEGV when calling hdfsGetPathInfo() via libhdfs.so.
Root cause
libhdfs.so caches JNIEnv* in an ELF thread-local (__thread) variable after the first AttachCurrentThread on each thread. The cached env is returned on all subsequent calls without re-validation (confirmed by disassembly of libhdfs.so's getJNIEnv function).
Gluten's JniColumnarBatchIterator::~JniColumnarBatchIterator() (JniCommon.cc) and JavaInputStreamAdaptor::Close() (JniWrapper.cc) call vm_->DetachCurrentThread() after JNI cleanup. This invalidates the JNIEnv* and frees the backing JavaThread object in the JVM. But libhdfs's TLS cache still holds the old pointer. On the next HDFS call, libhdfs's getJNIEnv() returns the stale pointer, and the JVM crashes when it tries to transition the freed thread state.
Detailed mechanism
libhdfs getJNIEnv fast path (from disassembly):
1. __tls_get_addr() → get &(__thread hdfsTls*)
2. if (tls_ptr != NULL) → return tls_ptr->env // NO RE-VALIDATION
3. else → slow path: AttachCurrentThread, cache env
After DetachCurrentThread:
- JVM frees the
JavaThread object, reclaims the memory at the env address
- libhdfs
__thread TLS still holds the stale hdfsTls* → stale env
- Next HDFS call →
getJNIEnv() fast path returns stale env
jni_NewStringUTF(stale_env, ...) → computes JavaThread* = env - 0x200 → freed memory
- JVM reads
*(JavaThread + 0x290) — gets garbage (not the magic alive marker 0xdeab)
- JVM calls
block_if_vm_exited(), sets JavaThread* = NULL
transition_from_native(NULL, ...) → SIGSEGV at address 0x278
Evidence from core dump
Core dump: core.CPUThreadPool21.1770392 (from TPC-DS benchmark on YARN)
Registers at crash frame (ThreadStateTransition::transition_from_native):
RDI = 0x0 ← JavaThread* is NULL (set by block_if_vm_exited)
R12 = 0x7f3003a52200 ← stale JNIEnv* from libhdfs TLS cache
Memory at stale env (0x7f3003a52200):
0x7f3003a52200: 0x0000000000000000 0x0000000000000000 ← JNI function table is NULL
0x7f3003a52210: 0x0000001200000112 0x0000000000000000 ← JVM method resolution data (reused memory)
Call chain (resolved from libvelox.so symbol table via nm):
CPUThreadPool21 (preload task)
→ SplitReader::createReader() [libvelox.so + 0x6173914]
→ HdfsFileSystem::openFileForRead() [libvelox.so + 0x3787216]
→ HdfsReadFile::HdfsReadFile() [libvelox.so + 0x378AB36, constructor]
→ driver_->GetPathInfo()
→ hdfsGetPathInfo() [libhdfs.so]
→ getJNIEnv() → returns stale env
→ jni_NewStringUTF(stale_env, path) → SIGSEGV
How DetachCurrentThread gets called on CPUThreadPool threads
The two call sites:
JniColumnarBatchIterator::~JniColumnarBatchIterator() — cpp/core/jni/JniCommon.cc
JavaInputStreamAdaptor::Close() — cpp/core/jni/JniWrapper.cc
These objects are held via shared_ptr chains rooted in the Velox Task. When a task is terminated (e.g., by memory arbitration or WholeStageResultIterator::~WholeStageResultIterator() calling task_->requestCancel()), Task::terminate() calls driver->closeByTask() → closeOperators() which destroys DataSource objects, dropping the last shared_ptr references. If this cleanup runs on a CPUThreadPool thread (e.g., triggered by memory pressure callback during a preload task), the destructor calls DetachCurrentThread on that thread.
Sequence:
- CPUThreadPool21 runs preload task A → libhdfs attaches thread, caches env in TLS
- Object cleanup on the same thread → destructor calls
DetachCurrentThread → env invalidated, but libhdfs TLS still holds it
- CPUThreadPool21 runs preload task B →
hdfsGetPathInfo() → stale env → SIGSEGV
Gluten version
main branch
Spark version
Spark-3.5.x
Spark configurations
No response
System information
No response
Relevant logs
core dump back trace:
Core: core.CPUThreadPool21.1770392
#10 ThreadStateTransition::transition_from_native(JavaThread*, JavaThreadState) [libjvm.so]
RDI=0x0 (NULL JavaThread*), R12=0x7f3003a52200 (stale JNIEnv*)
#11 jni_NewStringUTF [libjvm.so]
#12 newJavaStr (env=0x7f3003a52200, path="/.../catalog_sales/...parquet") [libhdfs.so]
#13 constructNewObjectOfPath [libhdfs.so]
#14 hdfsGetPathInfo [libhdfs.so]
#15 HdfsReadFile::HdfsReadFile() [libvelox.so + 0x378AB36]
#16 HdfsFileSystem::openFileForRead() [libvelox.so + 0x3787216]
Backend
VL (Velox)
Bug description
When running TPC-DS or heavy scan workloads on HDFS with
IOThreads > 0andSplitPreloadPerDriver > 0, the JVM process has a change to crash with SIGSEGV insidejni_NewStringUTFduringhdfsGetPathInfo(). The crashing thread is aCPUThreadPoolNthread used for async split preloading.Expected behavior: HDFS file operations should work reliably on IOThreadPool threads across consecutive preload tasks.
Actual behavior: After a certain number of tasks, the IOThreadPool thread crashes with SIGSEGV when calling
hdfsGetPathInfo()vialibhdfs.so.Root cause
libhdfs.socachesJNIEnv*in an ELF thread-local (__thread) variable after the firstAttachCurrentThreadon each thread. The cached env is returned on all subsequent calls without re-validation (confirmed by disassembly oflibhdfs.so'sgetJNIEnvfunction).Gluten's
JniColumnarBatchIterator::~JniColumnarBatchIterator()(JniCommon.cc) andJavaInputStreamAdaptor::Close()(JniWrapper.cc) callvm_->DetachCurrentThread()after JNI cleanup. This invalidates theJNIEnv*and frees the backingJavaThreadobject in the JVM. But libhdfs's TLS cache still holds the old pointer. On the next HDFS call,libhdfs'sgetJNIEnv()returns the stale pointer, and the JVM crashes when it tries to transition the freed thread state.Detailed mechanism
libhdfs
getJNIEnvfast path (from disassembly):After
DetachCurrentThread:JavaThreadobject, reclaims the memory at the env address__threadTLS still holds the stalehdfsTls*→ staleenvgetJNIEnv()fast path returns stale envjni_NewStringUTF(stale_env, ...)→ computesJavaThread* = env - 0x200→ freed memory*(JavaThread + 0x290)— gets garbage (not the magic alive marker0xdeab)block_if_vm_exited(), sets JavaThread* = NULLtransition_from_native(NULL, ...)→ SIGSEGV at address 0x278Evidence from core dump
Core dump:
core.CPUThreadPool21.1770392(from TPC-DS benchmark on YARN)Registers at crash frame (
ThreadStateTransition::transition_from_native):Memory at stale env (
0x7f3003a52200):Call chain (resolved from
libvelox.sosymbol table vianm):How DetachCurrentThread gets called on CPUThreadPool threads
The two call sites:
JniColumnarBatchIterator::~JniColumnarBatchIterator()—cpp/core/jni/JniCommon.ccJavaInputStreamAdaptor::Close()—cpp/core/jni/JniWrapper.ccThese objects are held via
shared_ptrchains rooted in the VeloxTask. When a task is terminated (e.g., by memory arbitration orWholeStageResultIterator::~WholeStageResultIterator()callingtask_->requestCancel()),Task::terminate()callsdriver->closeByTask()→closeOperators()which destroysDataSourceobjects, dropping the lastshared_ptrreferences. If this cleanup runs on a CPUThreadPool thread (e.g., triggered by memory pressure callback during a preload task), the destructor callsDetachCurrentThreadon that thread.Sequence:
DetachCurrentThread→ env invalidated, but libhdfs TLS still holds ithdfsGetPathInfo()→ stale env → SIGSEGVGluten version
main branch
Spark version
Spark-3.5.x
Spark configurations
No response
System information
No response
Relevant logs