Summary
On Linux, when the JVM's LC_CTYPE is C or POSIX and /etc/mtab contains a mount entry whose path includes non-ASCII bytes (e.g. a removable volume named 新加卷), PosixFileSystemFunctions.listFileSystems crashes with a bare NullPointerException originating from JNI. The real underlying error — a locale conversion failure — is recorded but never surfaced, because the NPE is thrown first and bypasses the NativeException path that callers expect.
This breaks Gradle builds that would otherwise be saved by the lenient handling added in gradle/gradle#16899, since that code only catches NativeException.
Related: #28 reports a different surface symptom ("file does not exist" from getMode) that traces back to the same underlying defect in char_to_java / java_to_char.
Stack trace
Observed with Gradle 9.1.0 on Android Studio (JBR 21), Linux:
* What went wrong:
java.lang.NullPointerException (no error message)
* Exception is:
java.lang.NullPointerException
at net.rubygrapefruit.platform.internal.FileSystemList.add(FileSystemList.java:31)
at net.rubygrapefruit.platform.internal.jni.PosixFileSystemFunctions.listFileSystems(Native Method)
at net.rubygrapefruit.platform.internal.PosixFileSystems.getFileSystems(PosixFileSystems.java:30)
at org.gradle.internal.watch.vfs.impl.DefaultWatchableFileSystemDetector.detectUnsupportedFileSystems(DefaultWatchableFileSystemDetector.java:62)
... (Gradle frames omitted)
Root cause
Two issues combine to produce this crash.
1. char_to_java returns NULL on locale conversion failure (expected) — but only after marking the failure on result.
In
|
jstring char_to_java(JNIEnv* env, const char* chars, jobject result) { |
|
size_t bytes = strlen(chars); |
|
wchar_t* wideString = (wchar_t*) malloc(sizeof(wchar_t) * (bytes + 1)); |
|
if (mbstowcs(wideString, chars, bytes + 1) == (size_t) -1) { |
|
mark_failed_with_message(env, "could not convert string from current locale", result); |
|
free(wideString); |
|
return NULL; |
|
} |
|
size_t stringLen = wcslen(wideString); |
|
jchar* javaString = (jchar*) malloc(sizeof(jchar) * stringLen); |
|
for (int i = 0; i < stringLen; i++) { |
|
javaString[i] = (jchar) wideString[i]; |
|
} |
|
jstring string = env->NewString(javaString, stringLen); |
|
free(wideString); |
|
free(javaString); |
|
return string; |
|
} |
mbstowcs depends on LC_CTYPE. Under the C / POSIX locale, any byte > 0x7F is treated as an invalid multibyte sequence, so mbstowcs returns (size_t) -1 and char_to_java correctly returns NULL after calling mark_failed_with_message.
2. Java_..._listFileSystems does not check for NULL before passing the result back to Java.
In
|
JNIEXPORT void JNICALL |
|
Java_net_rubygrapefruit_platform_internal_jni_PosixFileSystemFunctions_listFileSystems(JNIEnv* env, jclass target, jobject info, jobject result) { |
|
FILE* fp = setmntent(MOUNTED, "r"); |
|
if (fp == NULL) { |
|
mark_failed_with_errno(env, "could not open mount file", result); |
|
return; |
|
} |
|
char buf[1024]; |
|
struct mntent mount_info; |
|
|
|
jclass info_class = env->GetObjectClass(info); |
|
jmethodID method = env->GetMethodID(info_class, "add", "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZZZ)V"); |
|
|
|
while (getmntent_r(fp, &mount_info, buf, sizeof(buf)) != NULL) { |
|
jstring mount_point = char_to_java(env, mount_info.mnt_dir, result); |
|
jstring file_system_type = char_to_java(env, mount_info.mnt_type, result); |
|
jstring device_name = char_to_java(env, mount_info.mnt_fsname, result); |
|
env->CallVoidMethod(info, method, mount_point, file_system_type, device_name, JNI_FALSE, JNI_TRUE, JNI_TRUE); |
|
} |
|
|
|
endmntent(fp); |
|
} |
When any of those is NULL, it gets passed straight into FileSystemList.add(...) on the Java side, which has non-nullable parameters and throws NPE.
The damaging side effect is that the NPE shadows the meaningful error already recorded via mark_failed_with_message. Callers never see "could not convert string from current locale"; they only see a NullPointerException with no message and a stack trace that points into a Native Method, which makes this very hard to diagnose from the build log alone.
It also defeats Gradle's lenient fallback in WatchingVirtualFileSystem (added in gradle/gradle#16899), because that code catches NativeException, not RuntimeException.
The same defect exists symmetrically in java_to_char / wcstombs
unix_strings.cpp also exposes java_to_char, the inverse of char_to_java, which uses wcstombs instead of mbstowcs. wcstombs has the same LC_CTYPE dependency and the same (size_t) -1 failure mode — see wcstombs(3): "The behavior of wcstombs() depends on the LC_CTYPE category of the current locale."
This means any Java-side path containing characters not representable in the daemon's current locale — typically a user project under ~/projects/我的项目/ or similar — will produce a NULL C string when crossing into native code under C/POSIX locale. Depending on whether the caller null-checks, the result is either a NativeException, an NPE, a misleading errno-based error (e.g. "file does not exist", which is what #28 reports), or — in the worst case, if the NULL reaches a C library function that dereferences it — a daemon crash with no diagnostic at all.
The listFileSystems NPE reported here and the getMode "file does not exist" reported in #28 are two surface symptoms of the same underlying defect: char_to_java / java_to_char rely on the JVM process's LC_CTYPE, but Linux filesystem paths are byte sequences whose encoding is not guaranteed to match that locale (and in practice often doesn't, especially in CI containers and minimal Docker images which default to C/POSIX).
Reproduction
- Linux host.
- A mount point whose path contains non-ASCII bytes. Easiest way to set one up:
sudo mkdir -p /mnt/新加卷
sudo mount -t tmpfs tmpfs /mnt/新加卷
- Launch a Gradle build with the daemon's
LC_CTYPE set to C or POSIX:
Expected: a NativeException with the message "could not convert string from current locale", which Gradle can catch and degrade gracefully (disable file-system watching, continue the build).
Actual: bare NullPointerException, build fails.
Suggested fix
A complete fix has two layers.
Short term (this issue): null-check every char_to_java / java_to_char call site so failures surface as NativeException with the message already set by mark_failed_with_message, instead of NPEs, segfaults, or misleading errnos. For the immediate crash:
while (getmntent_r(fp, &mount_info, buf, sizeof(buf)) != NULL) {
jstring mount_point = char_to_java(env, mount_info.mnt_dir, result);
if (mount_point == NULL) { endmntent(fp); return; }
jstring file_system_type = char_to_java(env, mount_info.mnt_type, result);
if (file_system_type == NULL) { endmntent(fp); return; }
jstring device_name = char_to_java(env, mount_info.mnt_fsname, result);
if (device_name == NULL) { endmntent(fp); return; }
env->CallVoidMethod(info, method, mount_point, file_system_type, device_name,
JNI_FALSE, JNI_TRUE, JNI_TRUE);
}
The same pattern should be applied to all other call sites of char_to_java and java_to_char across the POSIX/Linux/macOS sources — a sweep is warranted, since mark_failed_with_message is already being called inside the helpers and the convention clearly intends the caller to bail out.
Long term: stop routing filesystem paths through the process locale at all. Treat Linux paths as opaque byte sequences and exchange them with Java as byte[] (decoded on the Java side using sun.jnu.encoding, which is what the JDK itself does for java.io.File), or force a UTF-8 conversion via mbstowcs_l / wcstombs_l against a fixed C.UTF-8 locale independent of the process locale. This would resolve the root cause behind both this issue and #28.
Environment
- Gradle: 9.1.0
- OS: Linux
- JVM: JetBrains Runtime 21 (bundled with Android Studio)
- Daemon
LC_CTYPE: C (no LC_ALL / LANG set; daemon was started with -Duser.country=US -Duser.language=en only, which does not affect the C-side locale)
- Mount point triggering the crash: a removable volume labeled
新加卷
- $ locale
LANG=zh_CN.UTF-8
LC_CTYPE=C.UTF-8
LC_NUMERIC="zh_CN.UTF-8"
LC_TIME="zh_CN.UTF-8"
LC_COLLATE="zh_CN.UTF-8"
LC_MONETARY="zh_CN.UTF-8"
LC_MESSAGES="zh_CN.UTF-8"
LC_PAPER="zh_CN.UTF-8"
LC_NAME="zh_CN.UTF-8"
LC_ADDRESS="zh_CN.UTF-8"
LC_TELEPHONE="zh_CN.UTF-8"
LC_MEASUREMENT="zh_CN.UTF-8"
LC_IDENTIFICATION="zh_CN.UTF-8"
LC_ALL=
Summary
On Linux, when the JVM's
LC_CTYPEisCorPOSIXand/etc/mtabcontains a mount entry whose path includes non-ASCII bytes (e.g. a removable volume named新加卷),PosixFileSystemFunctions.listFileSystemscrashes with a bareNullPointerExceptionoriginating from JNI. The real underlying error — a locale conversion failure — is recorded but never surfaced, because the NPE is thrown first and bypasses theNativeExceptionpath that callers expect.This breaks Gradle builds that would otherwise be saved by the lenient handling added in gradle/gradle#16899, since that code only catches
NativeException.Related: #28 reports a different surface symptom ("file does not exist" from
getMode) that traces back to the same underlying defect inchar_to_java/java_to_char.Stack trace
Observed with Gradle 9.1.0 on Android Studio (JBR 21), Linux:
Root cause
Two issues combine to produce this crash.
1.
char_to_javareturnsNULLon locale conversion failure (expected) — but only after marking the failure onresult.In
native-platform/native-platform/src/shared/cpp/unix_strings.cpp
Lines 51 to 68 in eec5280
mbstowcsdepends onLC_CTYPE. Under theC/POSIXlocale, any byte> 0x7Fis treated as an invalid multibyte sequence, sombstowcsreturns(size_t) -1andchar_to_javacorrectly returnsNULLafter callingmark_failed_with_message.2.
Java_..._listFileSystemsdoes not check forNULLbefore passing the result back to Java.In
native-platform/native-platform/src/main/cpp/linux.cpp
Lines 34 to 55 in eec5280
When any of those is
NULL, it gets passed straight intoFileSystemList.add(...)on the Java side, which has non-nullable parameters and throws NPE.The damaging side effect is that the NPE shadows the meaningful error already recorded via
mark_failed_with_message. Callers never see"could not convert string from current locale"; they only see aNullPointerExceptionwith no message and a stack trace that points into aNative Method, which makes this very hard to diagnose from the build log alone.It also defeats Gradle's lenient fallback in
WatchingVirtualFileSystem(added in gradle/gradle#16899), because that code catchesNativeException, notRuntimeException.The same defect exists symmetrically in
java_to_char/wcstombsunix_strings.cppalso exposesjava_to_char, the inverse ofchar_to_java, which useswcstombsinstead ofmbstowcs.wcstombshas the sameLC_CTYPEdependency and the same(size_t) -1failure mode — seewcstombs(3): "The behavior of wcstombs() depends on the LC_CTYPE category of the current locale."This means any Java-side path containing characters not representable in the daemon's current locale — typically a user project under
~/projects/我的项目/or similar — will produce aNULLC string when crossing into native code under C/POSIX locale. Depending on whether the caller null-checks, the result is either aNativeException, an NPE, a misleading errno-based error (e.g. "file does not exist", which is what #28 reports), or — in the worst case, if theNULLreaches a C library function that dereferences it — a daemon crash with no diagnostic at all.The
listFileSystemsNPE reported here and thegetMode"file does not exist" reported in #28 are two surface symptoms of the same underlying defect:char_to_java/java_to_charrely on the JVM process'sLC_CTYPE, but Linux filesystem paths are byte sequences whose encoding is not guaranteed to match that locale (and in practice often doesn't, especially in CI containers and minimal Docker images which default to C/POSIX).Reproduction
LC_CTYPEset toCorPOSIX:LC_ALL=C ./gradlew helpExpected: a
NativeExceptionwith the message"could not convert string from current locale", which Gradle can catch and degrade gracefully (disable file-system watching, continue the build).Actual: bare
NullPointerException, build fails.Suggested fix
A complete fix has two layers.
Short term (this issue): null-check every
char_to_java/java_to_charcall site so failures surface asNativeExceptionwith the message already set bymark_failed_with_message, instead of NPEs, segfaults, or misleading errnos. For the immediate crash:The same pattern should be applied to all other call sites of
char_to_javaandjava_to_characross the POSIX/Linux/macOS sources — a sweep is warranted, sincemark_failed_with_messageis already being called inside the helpers and the convention clearly intends the caller to bail out.Long term: stop routing filesystem paths through the process locale at all. Treat Linux paths as opaque byte sequences and exchange them with Java as
byte[](decoded on the Java side usingsun.jnu.encoding, which is what the JDK itself does forjava.io.File), or force a UTF-8 conversion viambstowcs_l/wcstombs_lagainst a fixedC.UTF-8locale independent of the process locale. This would resolve the root cause behind both this issue and #28.Environment
LC_CTYPE:C(noLC_ALL/LANGset; daemon was started with-Duser.country=US -Duser.language=enonly, which does not affect the C-side locale)新加卷LANG=zh_CN.UTF-8
LC_CTYPE=C.UTF-8
LC_NUMERIC="zh_CN.UTF-8"
LC_TIME="zh_CN.UTF-8"
LC_COLLATE="zh_CN.UTF-8"
LC_MONETARY="zh_CN.UTF-8"
LC_MESSAGES="zh_CN.UTF-8"
LC_PAPER="zh_CN.UTF-8"
LC_NAME="zh_CN.UTF-8"
LC_ADDRESS="zh_CN.UTF-8"
LC_TELEPHONE="zh_CN.UTF-8"
LC_MEASUREMENT="zh_CN.UTF-8"
LC_IDENTIFICATION="zh_CN.UTF-8"
LC_ALL=