Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions tools/server/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,16 @@ install(TARGETS ${TARGET} RUNTIME)

target_link_libraries(${TARGET} PRIVATE llama-server-impl)
target_compile_features(${TARGET} PRIVATE cxx_std_17)

# UTF-8 sanitising unit test: needs no model, so it stays out of the default build

if (LLAMA_BUILD_TESTS AND NOT CMAKE_CROSSCOMPILING)
set(TARGET test-server-utf8)

add_executable(${TARGET} tests/test-server-utf8.cpp)
target_link_libraries(${TARGET} PRIVATE server-context ${CMAKE_THREAD_LIBS_INIT})
target_include_directories(${TARGET} PRIVATE ${CMAKE_SOURCE_DIR})
target_compile_features(${TARGET} PRIVATE cxx_std_17)

add_test(NAME ${TARGET} COMMAND ${TARGET})
endif()
96 changes: 95 additions & 1 deletion tools/server/server-task.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
#include "sampling.h"
#include "speculative.h"
#include "server-common.h"
#include "unicode.h"

#include <sstream>

Expand Down Expand Up @@ -159,12 +160,105 @@ task_result_state::task_result_state(const common_chat_parser_params & chat_pars
}
}

// Bytes a lead announces, and the range its FIRST continuation must fall in: that range is what
// rejects overlong forms and surrogates.
static bool utf8_lead_bounds(unsigned char lead, size_t & want, unsigned char & lo, unsigned char & hi) {
lo = 0x80;
hi = 0xbf;
if (lead < 0x80) {
want = 1;
return true;
}
if (lead >= 0xc2 && lead <= 0xdf) {
want = 2;
return true;
}
if (lead >= 0xe0 && lead <= 0xef) {
want = 3;
if (lead == 0xe0) { lo = 0xa0; }
if (lead == 0xed) { hi = 0x9f; }
return true;
}
if (lead >= 0xf0 && lead <= 0xf4) {
want = 4;
if (lead == 0xf0) { lo = 0x90; }
if (lead == 0xf4) { hi = 0x8f; }
return true;
}
return false;
}

// `common_parse_utf8_codepoint` tests continuation shape only and accepts C0 80, which the client
// is never shown and the `params.debug` AST dump throws on. Checked here: that parser has other callers.
static bool utf8_is_scalar(const std::string & s, size_t pos, size_t len) {
if (len == 0 || pos + len > s.size()) {
return false;
}
size_t want = 0;
unsigned char lo = 0, hi = 0;
if (!utf8_lead_bounds(static_cast<unsigned char>(s[pos]), want, lo, hi) || want != len) {
return false;
}
for (size_t i = 1; i < want; i++) {
const unsigned char c = static_cast<unsigned char>(s[pos + i]);
const bool ok = (i == 1) ? (c >= lo && c <= hi) : ((c & 0xc0) == 0x80);
if (!ok) {
return false;
}
}
return true;
}

// One failed sequence as the serialiser counts it: "\xE2\x80A" is one replacement, "\xC3\xC3" two.
static size_t utf8_malformed_prefix(const std::string & s, size_t pos) {
size_t want = 0;
unsigned char lo = 0, hi = 0;
if (!utf8_lead_bounds(static_cast<unsigned char>(s[pos]), want, lo, hi) || want == 1) {
return 1;
}
size_t have = 1;
while (have < want && pos + have < s.size()) {
const unsigned char c = static_cast<unsigned char>(s[pos + have]);
const bool ok = (have == 1) ? (c >= lo && c <= hi) : ((c & 0xc0) == 0x80);
if (!ok) {
break;
}
have++;
}
return have;
}

// A byte-fallback token, or a prompt cut mid-character, puts undecodable bytes in the stream and the
// chat parsers throw on them, cancelling the task. Substitute as the serialiser already does.
static void append_utf8_sanitized(std::string & text, std::string & pending, const std::string & text_added, bool is_final = false) {
pending += text_added;

size_t pos = 0;
while (pos < pending.size()) {
const auto res = common_parse_utf8_codepoint(pending, pos);
if (res.status == utf8_parse_result::INCOMPLETE && !is_final) {
break;
}
if (res.status == utf8_parse_result::SUCCESS && utf8_is_scalar(pending, pos, res.bytes_consumed)) {
text.append(pending, pos, res.bytes_consumed);
pos += res.bytes_consumed;
continue;
}
// INCOMPLETE lands here on the final call, and "\xE2A" must give one replacement and keep the A.
text += "\xEF\xBF\xBD"; // U+FFFD REPLACEMENT CHARACTER
pos += utf8_malformed_prefix(pending, pos);
}

pending.erase(0, pos);
}

common_chat_msg task_result_state::update_chat_msg(
const std::string & text_added,
bool is_partial,
std::vector<common_chat_msg_diff> & diffs,
bool filter_tool_calls) {
generated_text += text_added;
// Nothing can complete a held-back sequence after the last update; unresolved, it was dropped.
append_utf8_sanitized(generated_text, generated_text_pending, text_added, !is_partial);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve trailing malformed bytes in streaming output

When a streamed completion ends with an incomplete UTF-8 sequence, this final flush cannot replace it because server-context.cpp detects the incomplete suffix at lines 1744–1749 and never sends those bytes in a partial result; the streaming final result then supplies an empty content at lines 2002–2005. Consequently generated_text_pending is empty here, so the malformed suffix is silently omitted rather than emitted as U+FFFD, while the equivalent non-streaming request is sanitized. This occurs when EOS or the token limit follows a token ending in a UTF-8 lead/prefix, and the new test misses it because it calls update_chat_msg() directly instead of exercising the server token pipeline.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct on the mechanism, and it is now pinned by a test. Measured rather than reasoned, on both arms.

What the streaming path actually does. validate_utf8() at server-context.cpp:1745 scans the last up to four bytes of slot.generated_text for a lead byte and returns len - i if the sequence is cut off, so incomplete is true and process_token() skips send_partial_response() for that token entirely. send_final_response() then sets content = "" in stream mode. So generated_text_pending is indeed empty at the final flush and the tail is dropped.

But it was dropped before this change too, so nothing is lost that was not lost already. End to end on stories15M-q4_0.gguf, logit_bias forcing a lead byte so generation ends on one, 12 streaming configurations (n_predict 1 and 4, three lead bytes 0xC3 0xE2 0xF4, /completion and /v1/chat/completions):

before after
streaming, trailing incomplete sequence empty content, 12 of 12 empty content, 12 of 12
non streaming, same inputs HTTP 500 or bytes dropped U+FFFD

With four forced 0xC3 tokens the stream carries exactly one SSE event, the final one with "content":"", on both arms: no partial is ever emitted for a run of lead bytes, because validate_utf8() holds the last one back every time.

So what this change creates is a divergence, not a loss: the non-streaming side is now substituted and the streaming side still is not, where before both were lossy. That is worth fixing, and the fix is in process_token() and send_final_response() rather than here: it means sending bytes that are currently deliberately withheld, which changes what every streaming client receives. Widening this PR to do that would put a streaming-content change behind a "do not abort" fix, so I have left it out and noted it as a follow up instead.

On the test missing it. Fair, and fixed in ff1d5901e. test-server-utf8 pipeline now models process_token()'s hold-back rather than calling update_chat_msg() directly: it accumulates a slot text, applies the real validate_utf8() gate, sends only what the slot would send, then does the empty final flush, and compares that against the same text driven non-streaming. 13 cases.

after
PIPE tail-c3    text=[61 62 63 C3]  held_back=[C3]  stream=[61 62 63]  nostream=[61 62 63 EF BF BD]  stream-ok nostream-ok ASYMMETRIC
PIPE run-c3     text=[C3 C3 C3 C3]  held_back=[C3 C3 C3 C3]  stream=[]  nostream=[EF BF BD x4]        stream-ok nostream-ok ASYMMETRIC
PIPE surrogate  text=[61 62 63 ED A0 80]  held_back=[]  stream=[61 62 63 EF BF BD x3]  nostream=[same] stream-ok nostream-ok agree
PIPE stray-80   text=[61 62 63 80]        held_back=[]  stream=[61 62 63 EF BF BD]     nostream=[same] stream-ok nostream-ok agree
RESULT pipeline stream_lost_content=0 nostream_bad=0 asymmetric=6

before
PIPE tail-c3    stream=[61 62 63]  nostream=[61 62 63]            stream-ok NOSTREAM-BAD agree
PIPE run-c3     stream=[]          nostream=[THROW]               stream-ok NOSTREAM-BAD ASYMMETRIC
PIPE surrogate  stream=[61 62 63 ED A0 80]  nostream=[61 62 63 ED A0 80]  STREAM-LOST-CONTENT NOSTREAM-BAD agree
PIPE stray-80   stream=[THROW]     nostream=[THROW]               STREAM-LOST-CONTENT NOSTREAM-BAD agree
RESULT pipeline stream_lost_content=4 nostream_bad=10 asymmetric=2

The hard assertion is that no decodable content may be lost in either mode: 0 before, 0 after, on both sides. The asymmetric rows are reported and deliberately not counted as failures, with the reason stated in the output. Note the tail-* rows are agree before the change because both modes dropped the tail, and ASYMMETRIC after because only one of them was fixed, which is exactly the point you are making.

Also worth noting from the same table: a complete but ill formed sequence at the end (surrogate, overlong, above U+10FFFF, stray continuation byte) is not held back by validate_utf8(), so both modes see it and both substitute identically. The divergence is confined to the incomplete-suffix case.

auto msg_prv_copy = chat_msg;
//SRV_DBG("Parsing chat message: %s\n", generated_text.c_str());
auto new_msg = common_chat_parse(
Expand Down
3 changes: 2 additions & 1 deletion tools/server/server-task.h
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,8 @@ struct task_result_state {
std::vector<common_chat_msg_diff> diffs;
common_chat_parser_params chat_parser_params;
common_chat_msg chat_msg;
std::string generated_text; // append new chunks of generated text here
std::string generated_text; // append new chunks of generated text here, see update_chat_msg()
std::string generated_text_pending; // trailing bytes of an incomplete UTF-8 sequence
std::vector<std::string> generated_tool_call_ids;
std::unordered_set<size_t> sent_tool_call_names;

Expand Down
Loading