Commit 04f7226a2 for llama.cpp

commit 04f7226a2c12c7f382c545a6db5a034266aa53cb
Author: Yuri Khrustalev <ykhrustalev@users.noreply.github.com>
Date:   Tue Oct 13 19:17:11 2026 -0400

    server : escape the special tokens without std::regex (#30243)

    The d1-omni and kev prompts escape "<|name|>" in the request text with
    std::regex_replace. libstdc++ recurses for each char of the name, so a
    state of "<|" and 30000 letters overflows the stack of the HTTP thread
    and llama-server dies with SIGSEGV.

    server_decision_escape_special_tokens() writes the same text with a loop.
    test-server-component compares it with the regex on every string of up to
    6 chars of "<|>a-".

diff --git a/tests/test-server-component.cpp b/tests/test-server-component.cpp
index d2d103a45..aa353e190 100644
--- a/tests/test-server-component.cpp
+++ b/tests/test-server-component.cpp
@@ -1,10 +1,13 @@
 #include "testing.h"

 #include "../tools/server/server-common.h"
+#include "../tools/server/server-decision.h"

 #include <cstdlib>
+#include <regex>
 #include <string>
 #include <thread>
+#include <utility>
 #include <vector>

 // unit tests for server internal components, no model required
@@ -370,6 +373,68 @@ static void test_server_pipe(testing & t) {
     });
 }

+//
+// server_decision_escape_special_tokens
+//
+
+// "<|name|>" with U+00A6 in place of "|"
+static std::string escaped(const std::string & name) {
+    return "<\xC2\xA6" + name + "\xC2\xA6>";
+}
+
+static void test_decision_escape(testing & t) {
+    t.test("cases", [](testing & t) {
+        const std::vector<std::pair<std::string, std::string>> cases = {
+            { "",                 ""                               },
+            { "no tokens",        "no tokens"                      },
+            { "<|im_start|>user", escaped("im_start") + "user"     },
+            { "<|a_1|><|B|>",     escaped("a_1") + escaped("B")    },
+            { "<|<|a|>",          "<|" + escaped("a")              },
+            { "<|a|>|>",          escaped("a") + "|>"              },
+            { "<||>",             "<||>"                           },
+            { "<|a b|>",          "<|a b|>"                        },
+            { "<|a|b|>",          "<|a|b|>"                        },
+            { "<|caf\xC3\xA9|>",  "<|caf\xC3\xA9|>"                },
+            { "<|abc",            "<|abc"                          },
+        };
+        for (const auto & [text, expected] : cases) {
+            t.assert_equal(text, expected, server_decision_escape_special_tokens(text));
+        }
+    });
+
+    // every string of up to 6 chars from "<|>a-" gives the text of the regex
+    t.test("same as std::regex", [](testing & t) {
+        static const std::regex re_special("<\\|([A-Za-z0-9_]+)\\|>");
+        const std::string chars = "<|>a-";
+        size_t n_strings = 0;
+        size_t n_diff    = 0;
+        for (size_t len = 0; len <= 6; len++) {
+            size_t n = 1;
+            for (size_t k = 0; k < len; k++) {
+                n *= chars.size();
+            }
+            for (size_t idx = 0; idx < n; idx++) {
+                std::string text;
+                for (size_t k = 0, v = idx; k < len; k++, v /= chars.size()) {
+                    text += chars[v % chars.size()];
+                }
+                if (server_decision_escape_special_tokens(text) != std::regex_replace(text, re_special, "<\xC2\xA6$1\xC2\xA6>")) {
+                    n_diff++;
+                }
+                n_strings++;
+            }
+        }
+        t.assert_equal("strings that differ, of " + std::to_string(n_strings), (size_t) 0, n_diff);
+    });
+
+    // std::regex overflowed the stack on these
+    t.test("long input", [](testing & t) {
+        const std::string name(1000000, 'a');
+        t.assert_true("not closed", server_decision_escape_special_tokens("<|" + name) == "<|" + name);
+        t.assert_true("closed", server_decision_escape_special_tokens("<|" + name + "|>") == escaped(name));
+    });
+}
+
 int main(int argc, char ** argv) {
     testing t;

@@ -388,6 +453,7 @@ int main(int argc, char ** argv) {
     t.test("json_helpers",  test_json_helpers);
     t.test("lora_helpers",  test_lora_helpers);
     t.test("server_pipe",   test_server_pipe);
+    t.test("decision_escape", test_decision_escape);

     return t.summary();
 }
diff --git a/tools/server/server-decision.cpp b/tools/server/server-decision.cpp
index bd2c9c4aa..b3c19e1f0 100644
--- a/tools/server/server-decision.cpp
+++ b/tools/server/server-decision.cpp
@@ -5,7 +5,6 @@
 #include <algorithm>
 #include <cctype>
 #include <cmath>
-#include <regex>
 #include <stdexcept>

 static const char * decision_question_type_name(server_decision_question_type type) {
@@ -449,17 +448,43 @@ static std::string decision_kev_render(const json & val, int indent = 0) {
     return val.dump();
 }

+// same result as std::regex_replace with <\|([A-Za-z0-9_]+)\|>
+// std::regex is not used: libstdc++ recurses for each char, a long input overflows the stack
+std::string server_decision_escape_special_tokens(const std::string & text) {
+    const auto is_name = [](char c) {
+        return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_';
+    };
+    std::string out;
+    size_t n_done = 0; // the chars of text that are in out
+    for (size_t i = text.find("<|"); i != std::string::npos; i = text.find("<|", std::max(i + 1, n_done))) {
+        size_t j = i + 2;
+        while (j < text.size() && is_name(text[j])) {
+            j++;
+        }
+        if (j > i + 2 && text.compare(j, 2, "|>") == 0) {
+            out.append(text, n_done, i - n_done);
+            out += "<\xC2\xA6";
+            out.append(text, i + 2, j - i - 2);
+            out += "\xC2\xA6>";
+            n_done = j + 2;
+        }
+    }
+    if (n_done == 0) {
+        return text;
+    }
+    out.append(text, n_done, std::string::npos);
+    return out;
+}
+
 // kev text input: special tokens written in the text must not be parsed as such
 static std::string decision_kev_text(const json & val) {
-    static const std::regex re_special("<\\|([A-Za-z0-9_]+)\\|>");
-    return std::regex_replace(decision_kev_render(val), re_special, "<\xC2\xA6$1\xC2\xA6>");
+    return server_decision_escape_special_tokens(decision_kev_render(val));
 }

 // lfm2-d1-omni: special tokens written in the input must not be parsed as such, in keys too (d1-omni prompt.py: escape)
 static json decision_d1omni_escape(const json & val) {
-    static const std::regex re_special("<\\|([A-Za-z0-9_]+)\\|>");
     if (val.is_string()) {
-        return std::regex_replace(val.get<std::string>(), re_special, "<\xC2\xA6$1\xC2\xA6>");
+        return server_decision_escape_special_tokens(val.get<std::string>());
     }
     if (val.is_array()) {
         json out = json::array();
@@ -471,7 +496,7 @@ static json decision_d1omni_escape(const json & val) {
     if (val.is_object()) {
         json out = json::object();
         for (const auto & [key, item] : val.items()) {
-            out[std::regex_replace(key, re_special, "<\xC2\xA6$1\xC2\xA6>")] = decision_d1omni_escape(item);
+            out[server_decision_escape_special_tokens(key)] = decision_d1omni_escape(item);
         }
         return out;
     }
diff --git a/tools/server/server-decision.h b/tools/server/server-decision.h
index 2753daa82..bfe9fd0ee 100644
--- a/tools/server/server-decision.h
+++ b/tools/server/server-decision.h
@@ -161,3 +161,6 @@ private:
 // each group is one parent and its children, it takes at most n_slots slots
 // note: the order of the tasks is preserved
 std::vector<server_task> server_decision_group_tasks(std::vector<server_task> && tasks, size_t n_slots);
+
+// write "<|name|>" with U+00A6 in place of "|", so text from the request is not parsed as special tokens
+std::string server_decision_escape_special_tokens(const std::string & text);