Skip to content

PosixFileSystemFunctions.listFileSystems throws NPE instead of NativeException when a mount point contains non-ASCII bytes under C/POSIX locale #389

@IzumiTonata

Description

@IzumiTonata

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

  1. Linux host.
  2. 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/新加卷
  1. Launch a Gradle build with the daemon's LC_CTYPE set to C or POSIX:
   LC_ALL=C ./gradlew help

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=

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions