Skip to content

Commit fc55802

Browse files
Refactor JNI lifecycle and log formatting into reusable helpers (#176)
* Extract pure log_helpers.hpp with unit tests Pulls log_level_name() and a deterministic format_log_as_json(level, text, ts) out of jllama.cpp into a new pure header with zero JNI/llama dependencies. Adds 13 GoogleTest cases covering level mapping, JSON shape, null text, and explicit timestamp handling. https://claude.ai/code/session_01TBELeBieKqvMwp1Ez8HgSk * Extract erase_reader() helper for readers-map cleanup Replaces four identical lock_guard + readers.erase() pairs in jllama.cpp (releaseTask, receiveCompletionJson x2, cancelCompletion) with a single inline helper in jni_helpers.hpp. Adds 3 unit tests covering present, missing, and selective-id removal. https://claude.ai/code/session_01TBELeBieKqvMwp1Ez8HgSk * Extract require_embedding_support() guard helper Replaces two identical 'if (!jctx->params.embedding) ThrowNew(...) return' blocks in embed() and handleEmbeddings() with a single helper in jni_helpers.hpp. Adds 2 unit tests using the existing mock JNI fixture. https://claude.ai/code/session_01TBELeBieKqvMwp1Ez8HgSk * Table-driven JNI global ref lifecycle in JNI_OnLoad/OnUnload Replaces 14 hand-written NewGlobalRef + 7 GetStaticObjectField + 7 NewGlobalRef + 21 DeleteGlobalRef calls with iteration over three small tables: g_global_class_refs, g_global_object_refs, g_static_object_bindings. Adding a new global ref is now a single-line table edit instead of touching four separate places. https://claude.ai/code/session_01TBELeBieKqvMwp1Ez8HgSk --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 652bb2a commit fc55802

6 files changed

Lines changed: 276 additions & 89 deletions

File tree

CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,7 @@ if(BUILD_TESTING)
297297
src/test/cpp/test_server.cpp
298298
src/test/cpp/test_jni_helpers.cpp
299299
src/test/cpp/test_json_helpers.cpp
300+
src/test/cpp/test_log_helpers.cpp
300301
${llama.cpp_SOURCE_DIR}/tools/server/server-common.cpp
301302
${llama.cpp_SOURCE_DIR}/tools/server/server-chat.cpp
302303
${llama.cpp_SOURCE_DIR}/tools/server/server-context.cpp

src/main/cpp/jllama.cpp

Lines changed: 62 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
#include "server-chat.h"
1919
#include "utils.hpp"
2020
#include "jni_helpers.hpp"
21+
#include "log_helpers.hpp"
2122

2223
#include <atomic>
2324
#include <chrono>
@@ -102,6 +103,42 @@ jobject o_log_format_json = nullptr;
102103
jobject o_log_format_text = nullptr;
103104
jobject o_log_callback = nullptr;
104105

106+
// ---------------------------------------------------------------------------
107+
// JNI global-ref lifecycle tables
108+
//
109+
// Every entry here is promoted to a JNI global ref in JNI_OnLoad and released
110+
// in JNI_OnUnload. Keep these in sync with the declarations above; ordering
111+
// within each table only matters for the human reader.
112+
// ---------------------------------------------------------------------------
113+
static jclass *const g_global_class_refs[] = {
114+
&c_llama_model, &c_string, &c_hash_map, &c_map, &c_set,
115+
&c_entry, &c_iterator, &c_integer, &c_float, &c_biconsumer,
116+
&c_llama_error, &c_log_level, &c_log_format, &c_error_oom,
117+
};
118+
119+
static jobject *const g_global_object_refs[] = {
120+
&o_utf_8,
121+
&o_log_level_debug, &o_log_level_info, &o_log_level_warn, &o_log_level_error,
122+
&o_log_format_json, &o_log_format_text,
123+
};
124+
125+
// Maps every object that is fetched from a Java static field on load to the
126+
// (class, field) pair it should be looked up from.
127+
struct static_object_binding {
128+
jobject *target;
129+
jclass *cls;
130+
jfieldID *field;
131+
};
132+
133+
static const static_object_binding g_static_object_bindings[] = {
134+
{&o_log_level_debug, &c_log_level, &f_log_level_debug},
135+
{&o_log_level_info, &c_log_level, &f_log_level_info},
136+
{&o_log_level_warn, &c_log_level, &f_log_level_warn},
137+
{&o_log_level_error, &c_log_level, &f_log_level_error},
138+
{&o_log_format_json, &c_log_format, &f_log_format_json},
139+
{&o_log_format_text, &c_log_format, &f_log_format_text},
140+
};
141+
105142
/**
106143
* Returns the jllama_context wrapper for the Java LlamaModel object.
107144
* Used by the delete path and any method that needs jctx directly.
@@ -390,38 +427,14 @@ JNIEnv *get_jni_env() {
390427
bool log_json;
391428
std::function<void(ggml_log_level, const char *, void *)> log_callback;
392429

393-
/**
394-
* Format a log message as JSON.
395-
*/
396-
std::string format_log_as_json(ggml_log_level level, const char *text) {
397-
std::string level_str;
398-
switch (level) {
399-
case GGML_LOG_LEVEL_ERROR:
400-
level_str = "ERROR";
401-
break;
402-
case GGML_LOG_LEVEL_WARN:
403-
level_str = "WARN";
404-
break;
405-
case GGML_LOG_LEVEL_INFO:
406-
level_str = "INFO";
407-
break;
408-
default:
409-
case GGML_LOG_LEVEL_DEBUG:
410-
level_str = "DEBUG";
411-
break;
412-
}
413-
nlohmann::json log_obj = {{"timestamp", std::time(nullptr)}, {"level", level_str}, {"message", text}};
414-
return log_obj.dump();
415-
}
416-
417430
/**
418431
* Invoke the log callback if there is any. When JSON mode is enabled,
419432
* the message is formatted as a JSON object before forwarding.
420433
*/
421434
void log_callback_trampoline(ggml_log_level level, const char *text, void *user_data) {
422435
if (log_callback != nullptr) {
423436
if (log_json) {
424-
std::string json_text = format_log_as_json(level, text);
437+
std::string json_text = format_log_as_json(level, text, std::time(nullptr));
425438
log_callback(level, json_text.c_str(), user_data);
426439
} else {
427440
log_callback(level, text, user_data);
@@ -477,21 +490,12 @@ JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *reserved) {
477490
goto error;
478491
}
479492

480-
// create references
481-
c_llama_model = (jclass)env->NewGlobalRef(c_llama_model);
482-
c_string = (jclass)env->NewGlobalRef(c_string);
483-
c_hash_map = (jclass)env->NewGlobalRef(c_hash_map);
484-
c_map = (jclass)env->NewGlobalRef(c_map);
485-
c_set = (jclass)env->NewGlobalRef(c_set);
486-
c_entry = (jclass)env->NewGlobalRef(c_entry);
487-
c_iterator = (jclass)env->NewGlobalRef(c_iterator);
488-
c_integer = (jclass)env->NewGlobalRef(c_integer);
489-
c_float = (jclass)env->NewGlobalRef(c_float);
490-
c_biconsumer = (jclass)env->NewGlobalRef(c_biconsumer);
491-
c_llama_error = (jclass)env->NewGlobalRef(c_llama_error);
492-
c_log_level = (jclass)env->NewGlobalRef(c_log_level);
493-
c_log_format = (jclass)env->NewGlobalRef(c_log_format);
494-
c_error_oom = (jclass)env->NewGlobalRef(c_error_oom);
493+
// Promote local class refs (from FindClass) to global refs in one pass.
494+
// c_standard_charsets is intentionally omitted: only used to look up
495+
// f_utf_8 in this function and never referenced again.
496+
for (jclass *p : g_global_class_refs) {
497+
*p = (jclass)env->NewGlobalRef(*p);
498+
}
495499

496500
// find constructors
497501
cc_hash_map = env->GetMethodID(c_hash_map, "<init>", "()V");
@@ -536,25 +540,18 @@ JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *reserved) {
536540
}
537541

538542
o_utf_8 = env->NewStringUTF("UTF-8");
539-
o_log_level_debug = env->GetStaticObjectField(c_log_level, f_log_level_debug);
540-
o_log_level_info = env->GetStaticObjectField(c_log_level, f_log_level_info);
541-
o_log_level_warn = env->GetStaticObjectField(c_log_level, f_log_level_warn);
542-
o_log_level_error = env->GetStaticObjectField(c_log_level, f_log_level_error);
543-
o_log_format_json = env->GetStaticObjectField(c_log_format, f_log_format_json);
544-
o_log_format_text = env->GetStaticObjectField(c_log_format, f_log_format_text);
543+
for (const auto &b : g_static_object_bindings) {
544+
*b.target = env->GetStaticObjectField(*b.cls, *b.field);
545+
}
545546

546547
if (!(o_utf_8 && o_log_level_debug && o_log_level_info && o_log_level_warn && o_log_level_error &&
547548
o_log_format_json && o_log_format_text)) {
548549
goto error;
549550
}
550551

551-
o_utf_8 = env->NewGlobalRef(o_utf_8);
552-
o_log_level_debug = env->NewGlobalRef(o_log_level_debug);
553-
o_log_level_info = env->NewGlobalRef(o_log_level_info);
554-
o_log_level_warn = env->NewGlobalRef(o_log_level_warn);
555-
o_log_level_error = env->NewGlobalRef(o_log_level_error);
556-
o_log_format_json = env->NewGlobalRef(o_log_format_json);
557-
o_log_format_text = env->NewGlobalRef(o_log_format_text);
552+
for (jobject *p : g_global_object_refs) {
553+
*p = env->NewGlobalRef(*p);
554+
}
558555

559556
if (env->ExceptionCheck()) {
560557
env->ExceptionDescribe();
@@ -587,28 +584,12 @@ JNIEXPORT void JNICALL JNI_OnUnload(JavaVM *vm, void *reserved) {
587584
return;
588585
}
589586

590-
env->DeleteGlobalRef(c_llama_model);
591-
env->DeleteGlobalRef(c_string);
592-
env->DeleteGlobalRef(c_hash_map);
593-
env->DeleteGlobalRef(c_map);
594-
env->DeleteGlobalRef(c_set);
595-
env->DeleteGlobalRef(c_entry);
596-
env->DeleteGlobalRef(c_iterator);
597-
env->DeleteGlobalRef(c_integer);
598-
env->DeleteGlobalRef(c_float);
599-
env->DeleteGlobalRef(c_biconsumer);
600-
env->DeleteGlobalRef(c_llama_error);
601-
env->DeleteGlobalRef(c_log_level);
602-
env->DeleteGlobalRef(c_log_format);
603-
env->DeleteGlobalRef(c_error_oom);
604-
605-
env->DeleteGlobalRef(o_utf_8);
606-
env->DeleteGlobalRef(o_log_level_debug);
607-
env->DeleteGlobalRef(o_log_level_info);
608-
env->DeleteGlobalRef(o_log_level_warn);
609-
env->DeleteGlobalRef(o_log_level_error);
610-
env->DeleteGlobalRef(o_log_format_json);
611-
env->DeleteGlobalRef(o_log_format_text);
587+
for (jclass *p : g_global_class_refs) {
588+
env->DeleteGlobalRef(*p);
589+
}
590+
for (jobject *p : g_global_object_refs) {
591+
env->DeleteGlobalRef(*p);
592+
}
612593

613594
if (o_log_callback != nullptr) {
614595
env->DeleteGlobalRef(o_log_callback);
@@ -769,8 +750,7 @@ JNIEXPORT jint JNICALL Java_net_ladenthin_llama_LlamaModel_requestCompletion(JNI
769750

770751
JNIEXPORT void JNICALL Java_net_ladenthin_llama_LlamaModel_releaseTask(JNIEnv *env, jobject obj, jint id_task) {
771752
REQUIRE_SERVER_CONTEXT();
772-
std::lock_guard<std::mutex> lk(jctx->readers_mutex);
773-
jctx->readers.erase(id_task);
753+
erase_reader(jctx, id_task);
774754
}
775755

776756
JNIEXPORT jstring JNICALL Java_net_ladenthin_llama_LlamaModel_receiveCompletionJson(JNIEnv *env, jobject obj,
@@ -791,17 +771,15 @@ JNIEXPORT jstring JNICALL Java_net_ladenthin_llama_LlamaModel_receiveCompletionJ
791771
server_task_result_ptr result = rd->next([] { return false; });
792772

793773
if (!result_ok_or_throw(env, result)) {
794-
std::lock_guard<std::mutex> lk(jctx->readers_mutex);
795-
jctx->readers.erase(id_task);
774+
erase_reader(jctx, id_task);
796775
return nullptr;
797776
}
798777

799778
json response = result->to_json();
800779
response["stop"] = result->is_stop();
801780

802781
if (result->is_stop()) {
803-
std::lock_guard<std::mutex> lk(jctx->readers_mutex);
804-
jctx->readers.erase(id_task);
782+
erase_reader(jctx, id_task);
805783
}
806784

807785
return json_to_jstring_impl(env, response);
@@ -810,9 +788,7 @@ JNIEXPORT jstring JNICALL Java_net_ladenthin_llama_LlamaModel_receiveCompletionJ
810788
JNIEXPORT jfloatArray JNICALL Java_net_ladenthin_llama_LlamaModel_embed(JNIEnv *env, jobject obj, jstring jprompt) {
811789
REQUIRE_SERVER_CONTEXT(nullptr);
812790

813-
if (!jctx->params.embedding) {
814-
env->ThrowNew(c_llama_error,
815-
"Model was not loaded with embedding support (see ModelParameters#setEmbedding(boolean))");
791+
if (!require_embedding_support(env, jctx->params.embedding, c_llama_error)) {
816792
return nullptr;
817793
}
818794

@@ -972,8 +948,7 @@ JNIEXPORT void JNICALL Java_net_ladenthin_llama_LlamaModel_delete(JNIEnv *env, j
972948

973949
JNIEXPORT void JNICALL Java_net_ladenthin_llama_LlamaModel_cancelCompletion(JNIEnv *env, jobject obj, jint id_task) {
974950
REQUIRE_SERVER_CONTEXT();
975-
std::lock_guard<std::mutex> lk(jctx->readers_mutex);
976-
jctx->readers.erase(id_task);
951+
erase_reader(jctx, id_task);
977952
}
978953

979954
JNIEXPORT void JNICALL Java_net_ladenthin_llama_LlamaModel_setLogger(JNIEnv *env, jclass clazz, jobject log_format,
@@ -1076,9 +1051,7 @@ JNIEXPORT jstring JNICALL Java_net_ladenthin_llama_LlamaModel_handleEmbeddings(J
10761051
jstring jparams, jboolean joaiCompat) {
10771052
REQUIRE_SERVER_CONTEXT(nullptr);
10781053

1079-
if (!jctx->params.embedding) {
1080-
env->ThrowNew(c_llama_error,
1081-
"Model was not loaded with embedding support (see ModelParameters#setEmbedding(boolean))");
1054+
if (!require_embedding_support(env, jctx->params.embedding, c_llama_error)) {
10821055
return nullptr;
10831056
}
10841057

src/main/cpp/jni_helpers.hpp

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,27 @@ struct jllama_context {
7070
std::map<int, std::unique_ptr<server_response_reader>> readers;
7171
};
7272

73+
// Removes the streaming reader entry for `id_task` under the readers_mutex.
74+
// No-op if the id is not in the map. Used to release the JNI side of a
75+
// completed, cancelled, or failed streaming task.
76+
inline void erase_reader(jllama_context *jctx, int id_task) {
77+
std::lock_guard<std::mutex> lk(jctx->readers_mutex);
78+
jctx->readers.erase(id_task);
79+
}
80+
81+
// Guard: throw and return false if the model was loaded without embedding
82+
// support enabled. Used by every JNI entry point that produces embeddings.
83+
[[nodiscard]] inline bool require_embedding_support(JNIEnv *env,
84+
bool embedding_enabled,
85+
jclass error_class) {
86+
if (embedding_enabled) {
87+
return true;
88+
}
89+
env->ThrowNew(error_class,
90+
"Model was not loaded with embedding support (see ModelParameters#setEmbedding(boolean))");
91+
return false;
92+
}
93+
7394
// ---------------------------------------------------------------------------
7495
// get_jllama_context_impl
7596
//

src/main/cpp/log_helpers.hpp

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// SPDX-FileCopyrightText: 2026 Bernard Ladenthin <bernard.ladenthin@gmail.com>
2+
// SPDX-FileCopyrightText: 2023-2025 Konstantin Herud
3+
//
4+
// SPDX-License-Identifier: MIT
5+
6+
#pragma once
7+
8+
// log_helpers.hpp — Pure log-formatting helpers.
9+
//
10+
// No JNI, no llama state. Depends only on the ggml_log_level enum (declared
11+
// in ggml.h) and nlohmann/json. Unit-testable without a JVM or model.
12+
13+
#include "ggml.h"
14+
#include "nlohmann/json.hpp"
15+
16+
#include <ctime>
17+
#include <string>
18+
19+
// Returns the canonical short name for a ggml log level. INFO is the default
20+
// fall-through to mirror llama.cpp's own log routing.
21+
[[nodiscard]] inline const char *log_level_name(ggml_log_level level) {
22+
switch (level) {
23+
case GGML_LOG_LEVEL_ERROR: return "ERROR";
24+
case GGML_LOG_LEVEL_WARN: return "WARN";
25+
case GGML_LOG_LEVEL_DEBUG: return "DEBUG";
26+
case GGML_LOG_LEVEL_INFO:
27+
default: return "INFO";
28+
}
29+
}
30+
31+
// Pure variant taking an explicit timestamp so tests are deterministic.
32+
[[nodiscard]] inline std::string format_log_as_json(
33+
ggml_log_level level, const char *text, std::time_t timestamp) {
34+
nlohmann::json log_obj = {
35+
{"timestamp", timestamp},
36+
{"level", log_level_name(level)},
37+
{"message", text ? text : ""},
38+
};
39+
return log_obj.dump();
40+
}

src/test/cpp/test_jni_helpers.cpp

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,47 @@ TEST(JllamaContextReaders, Erase_MapBecomesEmpty) {
166166
EXPECT_TRUE(ctx.readers.empty());
167167
}
168168

169+
TEST(EraseReader, RemovesExistingEntry) {
170+
jllama_context ctx;
171+
{
172+
std::lock_guard<std::mutex> lk(ctx.readers_mutex);
173+
ctx.readers.emplace(11, nullptr);
174+
}
175+
erase_reader(&ctx, 11);
176+
std::lock_guard<std::mutex> lk(ctx.readers_mutex);
177+
EXPECT_TRUE(ctx.readers.empty());
178+
}
179+
180+
TEST(EraseReader, MissingIdIsNoOp) {
181+
jllama_context ctx;
182+
{
183+
std::lock_guard<std::mutex> lk(ctx.readers_mutex);
184+
ctx.readers.emplace(1, nullptr);
185+
ctx.readers.emplace(2, nullptr);
186+
}
187+
erase_reader(&ctx, 99); // not present — must not throw or modify state
188+
std::lock_guard<std::mutex> lk(ctx.readers_mutex);
189+
EXPECT_EQ(ctx.readers.size(), 2u);
190+
EXPECT_TRUE(ctx.readers.count(1));
191+
EXPECT_TRUE(ctx.readers.count(2));
192+
}
193+
194+
TEST(EraseReader, OnlyRemovesGivenId) {
195+
jllama_context ctx;
196+
{
197+
std::lock_guard<std::mutex> lk(ctx.readers_mutex);
198+
ctx.readers.emplace(5, nullptr);
199+
ctx.readers.emplace(6, nullptr);
200+
ctx.readers.emplace(7, nullptr);
201+
}
202+
erase_reader(&ctx, 6);
203+
std::lock_guard<std::mutex> lk(ctx.readers_mutex);
204+
EXPECT_EQ(ctx.readers.size(), 2u);
205+
EXPECT_TRUE(ctx.readers.count(5));
206+
EXPECT_FALSE(ctx.readers.count(6));
207+
EXPECT_TRUE(ctx.readers.count(7));
208+
}
209+
169210
TEST(JllamaContextReaders, MultipleTaskIds_IndependentSlots) {
170211
// Erase one task id while others remain — models cancelCompletion
171212
// mid-stream without disturbing other active streaming tasks.
@@ -222,6 +263,22 @@ TEST_F(MockJniFixture, GetJllamaContext_ReturnsWrapperNotInnerServer) {
222263
// the type-level distinction (jllama_context* vs server_context*) is sufficient.
223264
}
224265

266+
// ============================================================
267+
// require_embedding_support
268+
// ============================================================
269+
270+
TEST_F(MockJniFixture, RequireEmbeddingSupport_Enabled_ReturnsTrueNoThrow) {
271+
EXPECT_TRUE(require_embedding_support(env, true, dummy_class));
272+
EXPECT_FALSE(g_throw_called);
273+
}
274+
275+
TEST_F(MockJniFixture, RequireEmbeddingSupport_Disabled_ReturnsFalseAndThrows) {
276+
EXPECT_FALSE(require_embedding_support(env, false, dummy_class));
277+
EXPECT_TRUE(g_throw_called);
278+
EXPECT_NE(g_throw_message.find("embedding support"), std::string::npos);
279+
EXPECT_NE(g_throw_message.find("setEmbedding"), std::string::npos);
280+
}
281+
225282
// ============================================================
226283
// require_json_field_impl
227284
// ============================================================

0 commit comments

Comments
 (0)