Tokenizer panics on non-char-boundary offsets where the ds4 original degrades gracefully #16
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Symptom
Three tokenizer helpers slice a
&strat a byte offset andunwrap()the first character. If the offset is ever not on a UTF-8 character boundary, or is at end-of-string, this panics. The tokenizer runs on the model runtime thread, so a panic here does not just fail one request — it permanently disables generation for the whole process (see the runtime-panic issue).The C original these were ported from is a total function: it degrades gracefully on malformed input instead of faulting. The port turned a total function into a partial one.
Evidence
src/engine/tokenizer.rs:Two distinct failure modes:
text[position..]panics ifpositionis not a char boundary, and.unwrap()panics if the slice is empty (cjk_athas noposition >= lenguard, unlike the other two).The boundary invariant is maintained by convention across several functions rather than by construction. In particular
letter_like(src/engine/tokenizer.rs:579-582) indexestext.as_bytes()[position]directly and treats any byte ≥ 128 as a letter, andconsume_letters(:584-589) then advances vianext_char. So correctness depends on every producer of apositionvalue agreeing — including the pre-tokenizer loop aroundsrc/engine/tokenizer.rs:490-514, which computes positions fromlast_newline,last_start, andscan.How ds4 handles this
../ds4/ds4.cdecodes UTF-8 by hand and is explicitly tolerant of truncation:ds4.c:35677utf8_peek_one(const char *s, uint64_t len, uint64_t pos, uint64_t *next):ds4.c:35722joyai_cjk_at()short-circuits onif ((uint8_t)s[pos] < 128) return false;before decoding — the DS4Server port (cjk_at,:572) dropped that guard and decodes unconditionally.ds4.c:35699-35711joyai_letter_like_at()— the source of DS4Server'sletter_like, with a comment explaining exactly why non-ASCII bytes are treated as letters, and noting that CJK/kana are isolated by the pre-tokenizer before the generic letter rule. That comment did not survive the port and is worth carrying over, since it is the rationale for the whole byte-oriented scheme.The C version cannot panic on any input, valid or not. That property should be preserved.
Suggested fix
Make the three helpers total, matching the C semantics:
str::get(position..)returnsNoneinstead of panicking on a non-boundary offset, which is the direct Rust equivalent of ds4'sif (pos + n > len) n = 1;.position >= text.len()guard tocjk_at, plus the< 128ASCII short-circuit fromds4.c:35722— it is both a correctness guard and a fast path.char_infoalready guards the length; switch it toget()for the boundary case.Also port the explanatory comment from
ds4.c:35699-35711ontoletter_like.Acceptance criteria
\x80), a truncated multi-byte sequence at end of string, mixed CJK/ASCII/emoji, an empty string — encodes without panicking.src/engine/tokenizer.rs).cargo clippy --all-targets --all-features -- -D warningsstays clean.Fixed in
292ee82. next_char, cjk_at, and char_info now use checked string access and the ds4-compatible one-byte fallback for non-boundary offsets; cjk_at also restores the ASCII fast path, and letter_like documents the byte-oriented rule. Added a regression test covering every byte offset in mixed ASCII/accented/CJK/emoji text plus empty and beyond-end positions. Verified with cargo fmt, Clippy (-D warnings), make bundle, and cargo test --all-features.