Commit 370df3f

mo khan <mo@mokhan.ca>
2026-08-30 05:53:42
fix: harden GGUF shim against native exceptions crossing the FFI boundary
common_chat_parse and common_chat_templates_init can throw when a model's raw output or embedded chat template doesn't match what llama.cpp expects (hit while running gpt-oss-20b through the eval suite: an unguarded throw in common_chat_parse aborted the whole Ruby process via std::terminate, since C++ exceptions can't cross Fiddle's FFI boundary). Wrap el_generate and el_open so a parse/template failure degrades to an error result instead of crashing the caller. Also set reasoning_format = AUTO so harmony/thinking-tag models route reasoning text into reasoning_content instead of leaving it inline in content, where it polluted eval assertions. Surface reasoning/error on the Ruby side via Elelem.logger. Add -Wall -Wextra to the shim's compile flags and fix the one warning it turned up in our own code. Boot Elelem.logger in spec_helper before any spec chdirs into a tmpdir, since it opens a relative-path log file.
Changed files (4)
ext/elelem_llama/elelem_llama.cpp
@@ -42,7 +42,7 @@ static enum ggml_log_level el_log_threshold() {
     return GGML_LOG_LEVEL_WARN;
 }
 
-static void el_log_callback(enum ggml_log_level level, const char *text, void *user_data) {
+static void el_log_callback(enum ggml_log_level level, const char *text, void * /*user_data*/) {
     if (level < el_log_threshold()) return;
     fputs(text, stderr);
 }
@@ -61,7 +61,16 @@ void *el_open(const char *path, int n_gpu_layers, int n_ctx, int n_threads, floa
     llama_model *model = llama_model_load_from_file(path, mp);
     if (!model) return nullptr;
 
-    return new el_handle{model, common_chat_templates_init(model, ""), n_ctx, n_threads, temp, (uint32_t) seed};
+    // common_chat_templates_init parses the model's embedded Jinja chat
+    // template; a malformed/unsupported template throws instead of
+    // returning null, which would otherwise abort the whole process (see
+    // el_generate's comment on the FFI boundary).
+    try {
+        return new el_handle{model, common_chat_templates_init(model, ""), n_ctx, n_threads, temp, (uint32_t) seed};
+    } catch (const std::exception &) {
+        llama_model_free(model);
+        return nullptr;
+    }
 }
 
 // Tool-call arguments arrive as either a JSON string or an object; llama.cpp's
@@ -110,7 +119,14 @@ static std::vector<common_chat_tool> build_tools(const json &arr) {
 
 // Returns a JSON string {"content": "...", "tool_calls": [{id,name,arguments}]}.
 // The buffer is valid until the next el_generate call on this thread.
-const char *el_generate(void *handle, const char *messages_json, const char *tools_json, int max_tokens) {
+// Every throwing call in here (nlohmann::json parsing, jinja template
+// application, the chat PEG parser) is wrapped by the caller, el_generate,
+// in one top-level try/catch: any C++ exception that unwinds past this
+// function crosses the Fiddle FFI boundary into Ruby and aborts the whole
+// process (std::terminate, not a catchable Ruby exception). A malformed
+// request or an unfamiliar template family (new model = new chat_template)
+// must degrade to an error result, never crash the caller.
+static const char *el_generate_impl(void *handle, const char *messages_json, const char *tools_json, int max_tokens) {
     auto *h = (el_handle *) handle;
     const llama_vocab *vocab = llama_model_get_vocab(h->model);
     static thread_local std::string buf; // result JSON; valid until the next call on this thread
@@ -175,9 +191,26 @@ const char *el_generate(void *handle, const char *messages_json, const char *too
     // cparams.parser; common_chat_parse forwards params.parser to the PEG engine,
     // so it must be deserialized here or every format returns content-only (no
     // tool calls). The converting ctor carries format + generation_prompt.
-    common_chat_parser_params pp(cparams);
-    if (!cparams.parser.empty()) pp.parser.load(cparams.parser);
-    common_chat_msg parsed = common_chat_parse(output, false, pp);
+    //
+    // common_chat_parse throws std::runtime_error when the model's raw output
+    // doesn't match its own template's expected grammar (seen with gpt-oss's
+    // harmony format on malformed/truncated generations). That exception can't
+    // cross the Fiddle FFI boundary -- it aborts the whole Ruby process -- so
+    // treat a parse failure the same as "nothing parsed": fall through to the
+    // lenient fallback below with the raw text kept as content.
+    common_chat_msg parsed;
+    parsed.content = output;
+    try {
+        common_chat_parser_params pp(cparams);
+        // AUTO routes <think>/harmony-analysis text into reasoning_content
+        // instead of leaving it inline in content (the default, NONE, does not
+        // split it out at all -- see gpt-oss's <|channel|>analysis<|message|>).
+        pp.reasoning_format = COMMON_REASONING_FORMAT_AUTO;
+        if (!cparams.parser.empty()) pp.parser.load(cparams.parser);
+        parsed = common_chat_parse(output, false, pp);
+    } catch (const std::exception &) {
+        parsed.content = output;
+    }
 
     // Lenient fallback: small/quantized models often emit a bare {"name","arguments"}
     // tool-call JSON (frequently fenced) instead of the template's exact tag syntax,
@@ -207,6 +240,7 @@ const char *el_generate(void *handle, const char *messages_json, const char *too
 
     result["fallback"] = fallback_used;
     result["content"] = parsed.content;
+    result["reasoning"] = parsed.reasoning_content;
     int i = 0;
     for (const auto &tc : parsed.tool_calls) {
         result["tool_calls"].push_back({
@@ -221,6 +255,21 @@ const char *el_generate(void *handle, const char *messages_json, const char *too
     return buf.c_str();
 }
 
+const char *el_generate(void *handle, const char *messages_json, const char *tools_json, int max_tokens) {
+    static thread_local std::string errbuf;
+    try {
+        return el_generate_impl(handle, messages_json, tools_json, max_tokens);
+    } catch (const std::exception &e) {
+        json result = {{"content", ""}, {"tool_calls", json::array()}, {"reasoning", ""}, {"fallback", false}, {"error", e.what()}};
+        errbuf = result.dump();
+        return errbuf.c_str();
+    } catch (...) {
+        json result = {{"content", ""}, {"tool_calls", json::array()}, {"reasoning", ""}, {"fallback", false}, {"error", "unknown exception in el_generate"}};
+        errbuf = result.dump();
+        return errbuf.c_str();
+    }
+}
+
 void el_close(void *handle) {
     auto *h = (el_handle *) handle;
     if (!h) return;
ext/elelem_llama/extconf.rb
@@ -135,7 +135,7 @@ run("sh", "-c", "cp -a #{libdir}/*.so* #{NATIVE}/")
 
 # 3. Compile the shim against the vendored headers, link libllama, rpath $ORIGIN.
 cxx = ENV["CXX"] || "c++"
-run(cxx, "-std=c++17", "-O2", "-shared", "-fPIC",
+run(cxx, "-std=c++17", "-O2", "-Wall", "-Wextra", "-shared", "-fPIC",
     "-I", File.join(VENDOR, "include"),
     "-I", File.join(VENDOR, "ggml", "include"),
     "-I", File.join(VENDOR, "common"),   # chat.h (common_chat)
lib/elelem/net/gguf.rb
@@ -42,6 +42,8 @@ module Elelem
         if result["tool_calls"].to_a.empty? && !tools.empty? && result["content"].to_s.include?("\"name\"")
           Elelem.logger.debug("gguf: no tool calls parsed, tools offered")
         end
+        Elelem.logger.warn("gguf: native generate error: #{result["error"]}") if result["error"]
+        Elelem.logger.debug("gguf: reasoning: #{result["reasoning"]}") if result["reasoning"].to_s != ""
 
         content = result["content"].to_s
         block&.call(type: "saying", text: content) unless content.empty?
spec/spec_helper.rb
@@ -9,6 +9,8 @@ require "yaml"
 
 Dir[File.join(__dir__, "support/**/*.rb")].each { |f| require f }
 
+Elelem.logger
+
 RSpec.configure do |config|
   config.disable_monkey_patching!