#42188·neovim

Statusline/`'titlestring'` truncation with `maxwidth` leaves unterminated buffer: invalid UTF-8 in `set_title` (and `nvim_eval_statusline()`)

Author: creabosCreated Sep 30, 2026Updated Oct 1, 2026
Labelsrevisit-at-releasestatusline

Problem

When the evaluated statusline-format string is wider than maxwidth and contains double-width (CJK) characters, the truncation in build_stl_str_hl() can leave the result without a terminating NUL at the right place. The returned string then has stale bytes from the pre-truncation buffer appended, starting in the middle of a multibyte character, i.e. invalid UTF-8.

Trigger: truncation removes from the start (< case), the remaining width is one cell short because a double-width character had to be dropped (width + 1 < maxwidth), so the "Fill up for half a double-wide character" loop runs. That loop writes the fill char at the old end of the string and never writes a NUL afterwards (the original NUL was just overwritten).

Real-world impact: with the default 'titlestring' and 'title' enabled, opening a file with a long Japanese path makes every UI attached via --embed receive a set_title event whose string is not valid UTF-8. MessagePack consumers that validate strings (e.g. VimR, see https://github.com/qvacua/vimr/issues/1172) fail to decode the whole redraw batch. Observed set_title value looks like:

< + (valid tail of the title) + + <stale bytes starting with a continuation byte, e.g. 0x88> + ... + - Nvim, with length larger than the valid title.

Code (v0.12.5, src/nvim/statusline.c, build_stl_str_hl(), ~L1990-2045):

c
      char *end = out + outputlen;
      ...
      memmove(trunc_p + 1, trunc_end_p, (size_t)(end - trunc_end_p) + 1);  // +1 for NUL
      end -= (size_t)(trunc_end_p - (trunc_p + 1));
      *trunc_p = '<';
      ...
      if (width + 1 < maxwidth) {
        // Advance the pointer to the end of the string
        trunc_p = end;
      }

      // Fill up for half a double-wide character.
      while (++width < maxwidth) {
        schar_get_adv(&trunc_p, fillchar);
        end = trunc_p;
      }
      (void)end;          // <-- nothing ever writes NUL at trunc_p / end

(void)end; discards the updated end. (Similar to Vim, where the same logic appears; I did not test Vim.)

Steps to reproduce

Minimal, Nvim only (nvim --clean --headless -l min.lua):

lua
local function valid(s) -- strict UTF-8 check
  local i, n = 1, #s
  while i <= n do
    local c = s:byte(i)
    local k = c < 0x80 and 0 or (c >= 0xC2 and c <= 0xDF) and 1 or (c >= 0xE0 and c <= 0xEF) and 2 or (c >= 0xF0 and c <= 0xF4) and 3 or nil
    if not k then return false end
    for j = 1, k do local d = s:byte(i + j); if not d or d < 0x80 or d > 0xBF then return false end end
    i = i + 1 + k
  end
  return true
end
local s = vim.api.nvim_eval_statusline("%{'あいうえおかきくけこ'}", { maxwidth = 8 })
print(vim.inspect(s.str), "valid UTF-8:", valid(s.str))

Actual output on 0.12.5:

"<くけこ <88>おかきくけこ" valid UTF-8: false

(a <, three kana, the fill space, then stale bytes of the untruncated string starting with continuation byte 0x88). Even maxwidth hits it (odd maxwidth does not), because 20 cells cannot be cut to exactly 8 with 2-cell characters.

Via a UI ('title' + default 'titlestring' + long CJK path), script repro.py (below) (uv run --with msgpack repro.py /opt/homebrew/bin/nvim): attaches with nvim --clean --embed, :set title, :edit /tmp/日本語テスト/とても長い日本語のディレクトリ名その1/…その2/…その3/メモ.txt and reads raw set_title bytes for UI widths 20..120. Result on 0.12.5: 7 of 51 widths give invalid UTF-8 (e.g. columns=28, 219 bytes, ending in ...その3) - Nvim after stale bytes). With :set titlelen=0 or :set titlestring=%t: 0 of 51.

repro.py
python
# Run: uv run --with msgpack repro.py [/path/to/nvim]
# Attach a UI to `nvim --clean --embed` and look at the raw bytes of `set_title`
# for several UI widths (the result depends on 'columns' / 'titlelen').
import os, select, subprocess, sys, time, msgpack

nvim = sys.argv[1] if len(sys.argv) > 1 else "nvim"
d = "/tmp/日本語テスト/" + "/".join("とても長い日本語のディレクトリ名その%d" % i for i in (1, 2, 3))
os.makedirs(d, exist_ok=True)
path = d + "/メモ.txt"
open(path, "w").write("x\n")

def last_title(cols, extra=()):
    p = subprocess.Popen([nvim, "--clean", "--embed"], stdin=subprocess.PIPE, stdout=subprocess.PIPE)
    u = msgpack.Unpacker(raw=True)  # raw=True: no UTF-8 validation on our side
    calls = [("nvim_ui_attach", [cols, 40, {"ext_linegrid": True, "rgb": True}]),
             ("nvim_command", ["set title"])]
    calls += [("nvim_command", [c]) for c in extra] + [("nvim_command", ["edit " + path])]
    for i, (m, a) in enumerate(calls, 1):
        p.stdin.write(msgpack.packb([0, i, m, a])); p.stdin.flush()
    title, end = b"", time.time() + 1.5
    while time.time() < end:
        if select.select([p.stdout], [], [], 0.1)[0]:
            u.feed(os.read(p.stdout.fileno(), 65536))
            for m in u:
                if m[0] == 2 and m[1] == b"redraw":
                    for ev in m[2]:
                        if ev[0] == b"set_title":
                            title = ev[-1][0]; end = min(end, time.time() + 0.15)
    p.kill()
    return title

def check(t):
    try:
        t.decode("utf-8"); return "ok"
    except UnicodeDecodeError:
        return "INVALID UTF-8"

bad = []
for cols in range(20, 121, 2):
    t = last_title(cols)
    if check(t) != "ok":
        bad.append((cols, t))
print("default titlestring: %d of 51 widths produced invalid UTF-8" % len(bad))
for cols, t in bad[:3]:
    print(" columns=%d len=%d tail=%r" % (cols, len(t), t[-24:]))
for label, extra in [("titlelen=0", ["set titlelen=0"]), ("titlestring=%t", ["set titlestring=%t"])]:
    n = sum(check(last_title(c, extra)) != "ok" for c in range(20, 121, 2))
    print("%-16s %d of 51 invalid" % (label, n))

Expected behavior

The result is always a valid, NUL-terminated string of at most maxwidth cells; set_title always carries valid UTF-8.

Nvim version (nvim -v)

NVIM v0.12.5, Release build (Homebrew)

Vim (not Nvim) behaves the same?

Not tested.

Operating system/version

macOS (Apple Silicon, arm64)

Terminal name/version

n/a (embedded UI via --embed; also reproduced headless with nvim_eval_statusline)

$TERM environment variable

n/a

Installation

homebrew

Additional notes

  • Already fixed on master, by accident? The logic was moved into stl_truncate() by #40369 ("feat(statusline)!: allow %= in item groups, scope %< to item groups", merged 2026-06-27). It ends with **out_p = NUL;, and out_p is advanced by the fill loop, so the bug should be gone there. I could not run a master build; this is from reading the code only. release-0.12 does not contain the change (no **out_p = NUL), so 0.12.x is still affected. Request: backport a minimal fix to release-0.12 (write *trunc_p = NUL after the fill loop), if 0.12.x patch releases are still planned.
  • Workarounds: :set titlelen=0, or :set titlestring=%t, or :set notitle.
  • Related client-side issue (client stops processing after the invalid string): https://github.com/qvacua/vimr/issues/1172
  • Searched for duplicates (titlelen, set_title utf-8, statusline truncate/garbage, nvim_eval_statusline): none found.