Обрезанное сообщение об ошибке разрезало имя пополам. Код оставлял первые 40 байт через String.sub msg 0 40, имя ближе к концу было корейским, и разрез пришёлся на середину трёхбайтовой последовательности UTF-8. В терминале на месте последнего слога появился символ замены.
String.length в OCaml считает байты. Модуль String описывает строку как неизменяемую последовательность байтов, и String.sub принимает позиции в байтах. В Go len(s) то же самое, и s[:40] режет по байтам. Ни то ни другое не ошибка. Они просто считают не то, что считает читатель сообщения.
Локальное исправление в том, чтобы сдвинуть разрез назад к началу символа. В Go для этого хватит короткого цикла на utf8.RuneStart.
func truncate(s string, max int) string {
if len(s) <= max {
return s
}
i := max
for i > 0 && !utf8.RuneStart(s[i]) {
i--
}
return s[:i]
}
Здесь s[i] - первый байт, который будет отброшен. Если с него начинается символ, разрез чистый, по i. Если это байт продолжения, цикл идёт влево, пока не найдёт начало разрезаемого символа, и режет перед ним. На сообщении, которое кончается на 김다현, с лимитом внутри 다 получается текст до 김 включительно и корректный UTF-8, а s[:40] оставлял два бездомных байта.
В OCaml String.get_utf_8_uchar декодирует символ по индексу, а Uchar.utf_decode_length говорит, на сколько шагнуть, так что тот же обход выходит на пару строк длиннее и читается примерно так же. Версия на Go проходит ещё пару строк, чисто ASCII и такую, где лимит уже приходится на границу символа, и обе обрезаются ровно по лимиту.
Результат остаётся корректным UTF-8, и больше я этому исправлению не приписываю. Останется ли целым символ, который видит человек, уже другой вопрос.