server: do not abort a completion when the model emits invalid UTF-8 - #202
server: do not abort a completion when the model emits invalid UTF-8#202danielhanchen wants to merge 1 commit into
Conversation
The generated text is a raw byte stream and is not guaranteed to be valid UTF-8. A byte fallback token, or a prompt that ends in the middle of a multi-byte character (the model then continues with the remaining continuation bytes), makes the generated text start with, or contain, bytes that do not decode. task_result_state::update_chat_msg() hands that text to common_chat_parse() on every token. The PEG parsers reject malformed UTF-8 by design, and the std::runtime_error thrown for it propagates out of the streaming loop, closes the connection and cancels the task, so the request ends with no tokens at all. On a non-streaming request it turns into a 500. The endpoint does not matter: /completion parses the text for the chat message as well. Normalise the text before parsing: hold back the trailing bytes of an incomplete sequence until the next chunk completes it, and replace bytes that can never form a codepoint with U+FFFD. That is what the JSON serialiser already substitutes on the way to the client, so the parser now sees exactly the text the client receives.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
You have reached your Codex usage limits for security reviews. Please try again later. |
|
Hardware evidence, since the report above was proved on CPU with a 19 MB model and a forced logit bias. Reproduced and cured on a DGX Spark GPU with a real quantised model, using the harness and prompt shape that produced the original observation: Qwen3.5-4B UD-Q4_K_XL, Before: 63 of 64 requests return tokens, in six independent arms across two cells. The server log carries the signature three times, once per arm:
After: 64 of 64 in every arm, and zero occurrences of the warning. The failing byte is With the fix the request returns Cost. Bracketed base / fixed / base at 32 concurrent, three arms of 64 requests per cell, one lock hold, GPU clocks pinned at 1690 MHz for the whole block, every arm 1677 to 1683 MHz, cell maxima 54 C, no clock transition:
The fixed cell lands between the two base cells, which themselves differ by 0.58 percent. The per-token figure in that bracket is confounded, because the base arm loses a slot in its first decode step and then runs 31 requests where the fixed arm runs 32. So the cost was measured again on a shape where both builds complete all 64, concurrency 8 with 8 requests per client, same pin, same block:
With identical work on both sides the fixed build is inside the 0.46 percent drift between the two base cells and the TPOT distributions are indistinguishable. |
What happens
A request can end with no tokens at all. The server logs
and the client sees a 200 with a truncated SSE stream and zero content deltas. On a
non-streaming request the same thing surfaces as
Why
The generated text is a raw byte stream and is not guaranteed to be valid UTF-8. A byte
fallback token, or a prompt that ends in the middle of a multi-byte character, in which case
the model correctly continues with the remaining continuation bytes, makes the generated text
start with or contain bytes that do not decode.
task_result_state::update_chat_msg()hands that text tocommon_chat_parse()on everytoken. The PEG parsers reject malformed UTF-8 by design,
tests/peg-parser/test-unicode.cppasserts exactly that, and the
std::runtime_errorthrown for it is raised insideserver_response_reader::next(), which runs on the HTTP thread inside the streamingres->nextclosure. It escapes into the HTTP layer, the connection is torn down, and thereader's destructor cancels the task. The endpoint does not matter:
/completionbuilds thechat message too, so a plain completion request dies the same way.
validate_utf8()in the token loop only holds back a multi-byte sequence that is cut off atthe end. A byte that can never start a codepoint passes straight through to the parser.
The fix
Normalise the text before parsing: hold back the trailing bytes of an incomplete sequence
until the next chunk completes it, and replace bytes that can never form a codepoint with
U+FFFD. That is what the JSON serialiser already substitutes on the way to the client, so the
parser now sees exactly the text the client receives.
generated_textintask_result_stateis only read by
update_chat_msg(), so nothing else changes.What was measured
Reproduced deterministically with
stories15M-q4_0.ggufon CPU, forcing the byte fallbacktoken 164 (a lone
0xA1) with a logit bias, so no GPU and no large model is needed:Before:
{"error":{"code":500,"message":"The model produced output that does not match the expected Content-only format"}}, zero tokens, streaming and non-streaming alike.After: all 8 tokens are delivered,
tokens_predicted = 8, content rendered as U+FFFD.In production traffic this was one request in 64 at 32 concurrent users on Qwen3 27B
UD-Q4_K_XL with 128 token prompts drawn as arbitrary token id windows of a corpus, so a
prompt ending mid-character is common. Across 52 server logs from unrelated benchmark
windows there are 65 of these aborted requests and every single one of them contains invalid
UTF-8 in the text the parser was given: 38 with an invalid lead byte, 27 with a bad
continuation byte, none with anything else.
test-peg-parser,test-chat-peg-parserandtest-chatpass unchanged. Plain completions,chat completions and streaming chat completions were checked for regressions on the same
tiny model, and a multi-byte character split across two tokens still arrives whole.