Skip to content

addon-unicode-graphemes: trie header is read via DataView(data.buffer) ignoring byteOffset, so a pooled base64 decode silently corrupts widths #6079

Description

@openwong2kim

@xterm/addon-unicode-graphemes decodes its compressed Unicode trie at module-evaluation time and reads the trie header with new DataView(data.buffer), dropping data.byteOffset (src/third-party/unicode-trie.ts — same code in 0.4.0 and 0.5.0-beta.292).

In Node, Buffer.from(str, 'base64') returns a view into an 8 KiB shared pool when the result is small enough to be pooled. data.buffer is then the whole pool, not the trie — so highStart, errorValue and uncompressedLength are read out of whatever bytes happen to precede it.

The pool is at offset 0 only in a process that has not yet allocated a small Buffer. Any prior small allocation shifts it, and then the outcome depends on the garbage found.

Reproduction

One Buffer.from('x') is the whole difference.

// node repro.js clean   -> width 2   (correct)
// node repro.js dirty   -> width 3   (wrong, no error thrown)
if (process.argv[2] === 'dirty') Buffer.from('x');

const { Terminal } = require('@xterm/headless');
const { UnicodeGraphemesAddon } = require('@xterm/addon-unicode-graphemes');

const t = new Terminal({ allowProposedApi: true });
t.loadAddon(new UnicodeGraphemesAddon());
t.unicode.activeVersion = '15-graphemes';

console.log(t._core.unicodeService.getStringCellWidth('👨‍👩‍👧'));
clean  node v24.15.0  ZWJ family width = 2
dirty  node v24.15.0  ZWJ family width = 3   <-- silently wrong

Verified on macOS arm64, @xterm/headless 6.0.0, @xterm/addon-unicode-graphemes 0.4.0.

Three failure modes, decided by what the process allocated first

  • Silently wrong widths. A corrupt highStart that still inflates: nothing is thrown and the addon answers with wrong widths — the case above. This is the worst one, because it defeats the guarantee the addon is adopted for while looking healthy.
  • Error: Data error from tiny-inflate when uncompressedLength reads too small, and the import fails. This is how we first hit it: four test suites whose import graph reaches the addon died on macOS and Windows CI runners while the Linux runner passed the same commit.
  • A hang when it reads too large, on a multi-gigabyte typed-array allocation. Reproduced on Node 22 with a single Buffer.from('x') beforehand.

Which one you get is not stable across Node versions: whether a given decode size is pooled, and at what offset, is an implementation detail that has changed between releases. That is also why this can look like a flaky CI failure rather than a deterministic bug.

Not reachable from the browser build

With nodeIntegration off there is no Buffer global, the addon takes its atob branch, and that always allocates at offset 0. This affects Node consumers — @xterm/headless and anything embedding it.

Suggested fix

Honour the offset when constructing the view, e.g.

new DataView(data.buffer, data.byteOffset, data.byteLength)

and likewise anywhere else .buffer is taken from a possibly-pooled Buffer. Copying into a dedicated ArrayBuffer before parsing would also work but is unnecessary once the offset is respected.

Workaround for consumers

Setting Buffer.poolSize = 0 across the addon's import forces Node to allocate a dedicated ArrayBuffer, after which data.buffer and data describe the same bytes. It has to wrap the import, since the decode happens at module-evaluation time — which makes import order load-bearing in the consuming codebase, so it is a workaround rather than a fix.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions