I hit this while writing an Arrow-based bulk loader for a JVM application: my long-running dev backend died, with no exception and no stack unwinding. Verified against com.ladybugdb/lbug 0.19.0 (current release on Maven Central) on Linux x86-64 / OpenJDK 25.0.3. The code below is ladybug-java @ a0e7728 — the tip of main, and the commit that ladybug v0.19.0 pins at tools/java_api.
The problem
Connection.execute is declared execute(PreparedStatement, Map<String, Value>) (src/main/java/com/lbugdb/Connection.java:116), and the JNI layer trusts that declaration absolutely. Any caller that reaches it through erasure — a raw or unchecked Map, reflection, or a JVM language that does not enforce Java generics (my case: Clojure) — hands ordinary Java objects to a reinterpret_cast, and the process dies.
Map<String, Object> raw = new HashMap<>();
raw.put("p_id", UUID.fromString("11111111-1111-1111-1111-111111111111"));
raw.put("p_name", "hello");
@SuppressWarnings("unchecked")
Map<String, Value> params = (Map<String, Value>) (Map<?, ?>) raw;
try (PreparedStatement ps = conn.prepare("MATCH (n:T {id: $p_id}) SET n.name = $p_name;")) {
conn.execute(ps, params); // process dies here
}
# A fatal error has been detected by the Java Runtime Environment:
# SIGSEGV (0xb) at pc=0x00007fc0f4ba89b3
# Problematic frame:
# C [liblbug_java_native....so+0x29a9b3] lbug_value_clone+0x23
#
siginfo: si_signo: 11 (SIGSEGV), si_code: 128 (SI_KERNEL), si_addr: 0x0000000000000000
j com.ladybugdb.Native.lbugConnectionExecute(Lcom/ladybugdb/Connection;Lcom/ladybugdb/PreparedStatement;Ljava/util/Map;)Lcom/ladybugdb/QueryResult;+0
j com.ladybugdb.Connection.execute(Lcom/ladybugdb/PreparedStatement;Ljava/util/Map;)Lcom/ladybugdb/QueryResult;+7
One unwrapped entry suffices — a bare String for a single parameter crashes identically. Wrapping the same values works correctly:
Map.of("p_id", new Value(uuid), "p_name", new Value("hello"))
Diagnostic
bindJavaParamsToPreparedStatement passes each map value straight to getValue (src/jni/lbug_java.cpp:516):
auto* clonedValue = lbug_value_clone(getValue(env, value));
but getValue (src/jni/lbug_java.cpp:381) reads a field that only exists on Value, with no type check:
lbug_value* getValue(JNIEnv* env, jobject thisValue) {
jlong fieldValue = env->GetLongField(thisValue, J_C_Value_F_v_ref);
uint64_t address = static_cast<uint64_t>(fieldValue);
return reinterpret_cast<lbug_value*>(address);
}
GetLongField against something that is not a Value returns whatever occupies that field offset, and lbug_value_clone then dereferences it.
Line 516 looks like the only getValue call site fed by an unconstrained user object. The other 38 either take the receiver of a Value instance method, or an element of a Value[] (Java_com_ladybugdb_Native_lbugCreateList___3Lcom_ladybugdb_Value_2, src/jni/lbug_java.cpp:1623), where the JVM's array-store check already guarantees the type.
Proposed fix
Option A — accept boxed Java values. Java_com_ladybugdb_Native_lbugValueCreateValue (src/jni/lbug_java.cpp:1485) already converts Boolean, Byte, Short, Integer, Long, BigInteger, Float, Double, BigDecimal, String, InternalID, UUID, LocalDate, Instant and Duration through an IsInstanceOf ladder. Reusing that ladder in bindJavaParamsToPreparedStatement for values that are not already a Value makes Map.of("p_id", uuid) behave the way a JVM caller expects, and leaves the wrapped form working exactly as before. To be reachable from Java rather than only through erasure, this wants the signature widened to Map<String, ?> (or an overload) — happy to follow whichever you prefer.
Option B — guard and throw. env->IsInstanceOf(value, J_C_Value) before getValue, raising IllegalArgumentException naming the parameter and the offending class. Keeps the current contract, turns a JVM kill into a catchable error.
Value itself already validates correctly — new Value(new ArrayList<String>()) raises java.lang.RuntimeException: Type of value is not supported in value_create_value (src/jni/lbug_java.cpp:1562) — so only the unwrapped path is problematic.
Tests
src/test/java/com/lbugdb/PreparedStatementTest.java: bind a raw UUID, String and Long through an unchecked map and assert the documented behaviour (converted under option A, IllegalArgumentException under option B).
- A regression case asserting the wrapped
Value path is unchanged.
I hit this while writing an Arrow-based bulk loader for a JVM application: my long-running dev backend died, with no exception and no stack unwinding. Verified against
com.ladybugdb/lbug0.19.0 (current release on Maven Central) on Linux x86-64 / OpenJDK 25.0.3. The code below isladybug-java@a0e7728— the tip ofmain, and the commit thatladybugv0.19.0 pins attools/java_api.The problem
Connection.executeis declaredexecute(PreparedStatement, Map<String, Value>)(src/main/java/com/lbugdb/Connection.java:116), and the JNI layer trusts that declaration absolutely. Any caller that reaches it through erasure — a raw or uncheckedMap, reflection, or a JVM language that does not enforce Java generics (my case: Clojure) — hands ordinary Java objects to areinterpret_cast, and the process dies.One unwrapped entry suffices — a bare
Stringfor a single parameter crashes identically. Wrapping the same values works correctly:Diagnostic
bindJavaParamsToPreparedStatementpasses each map value straight togetValue(src/jni/lbug_java.cpp:516):auto* clonedValue = lbug_value_clone(getValue(env, value));but
getValue(src/jni/lbug_java.cpp:381) reads a field that only exists onValue, with no type check:GetLongFieldagainst something that is not aValuereturns whatever occupies that field offset, andlbug_value_clonethen dereferences it.Line 516 looks like the only
getValuecall site fed by an unconstrained user object. The other 38 either take the receiver of aValueinstance method, or an element of aValue[](Java_com_ladybugdb_Native_lbugCreateList___3Lcom_ladybugdb_Value_2,src/jni/lbug_java.cpp:1623), where the JVM's array-store check already guarantees the type.Proposed fix
Option A — accept boxed Java values.
Java_com_ladybugdb_Native_lbugValueCreateValue(src/jni/lbug_java.cpp:1485) already convertsBoolean,Byte,Short,Integer,Long,BigInteger,Float,Double,BigDecimal,String,InternalID,UUID,LocalDate,InstantandDurationthrough anIsInstanceOfladder. Reusing that ladder inbindJavaParamsToPreparedStatementfor values that are not already aValuemakesMap.of("p_id", uuid)behave the way a JVM caller expects, and leaves the wrapped form working exactly as before. To be reachable from Java rather than only through erasure, this wants the signature widened toMap<String, ?>(or an overload) — happy to follow whichever you prefer.Option B — guard and throw.
env->IsInstanceOf(value, J_C_Value)beforegetValue, raisingIllegalArgumentExceptionnaming the parameter and the offending class. Keeps the current contract, turns a JVM kill into a catchable error.Valueitself already validates correctly —new Value(new ArrayList<String>())raisesjava.lang.RuntimeException: Type of value is not supported in value_create_value(src/jni/lbug_java.cpp:1562) — so only the unwrapped path is problematic.Tests
src/test/java/com/lbugdb/PreparedStatementTest.java: bind a rawUUID,StringandLongthrough an unchecked map and assert the documented behaviour (converted under option A,IllegalArgumentExceptionunder option B).Valuepath is unchanged.