fix(utf8): stop reading a code point past the end of a sliced string (#13782)

Once V8 optimizes the calling function, `String.prototype.codePointAt` on a
sliced string pairs a trailing high surrogate with the code unit that follows
the SLICE inside its parent, returning a code point the string does not
contain. Every UTF-8 byte scan built on `codePointAt` therefore reported one
byte too many for a prefix slice cut mid-pair, but only after tier-up, which
is what made terminal-stream-byte-length.test.ts fail intermittently on the
same commit.

Read the units explicitly with `charCodeAt`, which stays bounds-correct in
every tier, via a shared `readUtf8CodePointAt`.
This commit is contained in:
Neil
2026-08-11 02:04:10 -07:00
committed by GitHub
parent c8ee48701b
commit 383ae50a35
10 changed files with 184 additions and 28 deletions
@@ -7,9 +7,24 @@ import {
} from './terminal-stream-byte-length'
import { TERMINAL_OUTPUT_BATCH_MAX_BYTES } from '../../../shared/terminal-multiplex-flow-control'
// Byte-for-byte copy of the pre-change implementation (shared/clipboard-text.ts
// Copy of the pre-change implementation (shared/clipboard-text.ts
// measureClipboardTextByteLength), kept here so equivalence is checked against the
// ACTUAL old code path rather than a paraphrase of it.
// ACTUAL old code path rather than a paraphrase of it. The one deliberate deviation is
// `legacyCodePointAt`: raw `String.prototype.codePointAt` reads one code unit past the end
// of a sliced string once V8 optimizes its caller, so the naive copy is not a stable
// reference. See src/shared/utf8-byte-limits.ts (readUtf8CodePointAt).
function legacyCodePointAt(text: string, index: number): number {
const leadUnit = text.charCodeAt(index)
if (leadUnit < 0xd800 || leadUnit > 0xdbff || index + 1 >= text.length) {
return leadUnit
}
const trailUnit = text.charCodeAt(index + 1)
if (trailUnit < 0xdc00 || trailUnit > 0xdfff) {
return leadUnit
}
return (leadUnit - 0xd800) * 0x400 + (trailUnit - 0xdc00) + 0x10000
}
function legacyUtf8ByteLengthForCodePoint(codePoint: number): number {
if (codePoint <= 0x7f) {
return 1
@@ -30,7 +45,7 @@ function legacyMeasure(
const stopAfterBytes = options.stopAfterBytes
let byteLength = 0
for (let index = 0; index < text.length; index += 1) {
const codePoint = text.codePointAt(index) ?? 0
const codePoint = legacyCodePointAt(text, index)
byteLength += legacyUtf8ByteLengthForCodePoint(codePoint)
if (Number.isFinite(stopAfterBytes) && byteLength > (stopAfterBytes ?? 0)) {
return { byteLength, exceededLimit: true }
@@ -128,6 +143,39 @@ const EDGE_STRINGS = [
`${'é'.repeat(500)}\ud800`
]
// Regression for the intermittent "measurement diverged at 13 units" failure: the fuzzers
// build a rope and cut it at a fixed code-unit count, which can split a surrogate pair and
// leave the low half in the parent just past the slice. Optimized `codePointAt` pairs across
// that boundary, so the scan measured one byte too many, but only after the enclosing function
// tiered up, which made the failure look load-dependent. 13 code units is V8's minimum length
// for a sliced string, which is why the divergence started exactly there.
describe('measuring a prefix slice that cuts a surrogate pair in half', () => {
// Kept first in the file so the scan is still specializing on this shape when it tiers up.
it('measures the slice like the encoder does in every JIT tier', () => {
const sliced = 'abcdefghijkl\u{1f600}'.slice(0, 13)
expect(sliced.length).toBe(13)
expect(sliced.charCodeAt(12)).toBe(0xd83d)
// 12 ASCII bytes plus U+FFFD for the orphaned high surrogate.
expect(Buffer.byteLength(sliced, 'utf8')).toBe(15)
const observedByteLengths = new Set<number>()
const observedExceeded = new Set<boolean>()
const observedMeasurements = new Set<string>()
for (let iteration = 0; iteration < 200_000; iteration += 1) {
observedByteLengths.add(terminalStreamByteLength(sliced))
observedExceeded.add(terminalStreamByteLengthExceeds(sliced, 15))
observedMeasurements.add(
JSON.stringify(measureTerminalStreamByteLength(sliced, { stopAfterBytes: 15 }))
)
}
expect([...observedByteLengths]).toEqual([15])
expect([...observedExceeded]).toEqual([false])
expect([...observedMeasurements]).toEqual([
JSON.stringify({ byteLength: 15, exceededLimit: false })
])
})
})
describe('terminal stream byte length equivalence with the legacy code-point scan', () => {
it('matches the legacy total byte length on edge strings', () => {
for (const text of EDGE_STRINGS) {
@@ -1,5 +1,8 @@
import type { Slice } from '@tiptap/pm/model'
import { getUtf8ByteLengthForCodePoint } from '../../../../shared/utf8-byte-limits'
import {
getUtf8ByteLengthForCodePoint,
readUtf8CodePointAt
} from '../../../../shared/utf8-byte-limits'
import {
getRichMarkdownLeafVisibleText,
isRichMarkdownVisibleBlockStart
@@ -90,7 +93,7 @@ function addUtf8BytesWithinLimit(
}
let byteLength = current
for (let index = 0; index < value.length; index += 1) {
const codePoint = value.codePointAt(index) ?? 0
const codePoint = readUtf8CodePointAt(value, index)
byteLength += getUtf8ByteLengthForCodePoint(codePoint)
if (byteLength > limit) {
return { byteLength, exceeded: true }
@@ -5,7 +5,10 @@ import {
makeDiff,
makePatches
} from '@sanity/diff-match-patch'
import { getUtf8ByteLengthForCodePoint } from '../../../../shared/utf8-byte-limits'
import {
getUtf8ByteLengthForCodePoint,
readUtf8CodePointAt
} from '../../../../shared/utf8-byte-limits'
// Why: cap document size in UTF-16 code units (`.length`) since re-parse cost scales with length — the per-commit throwaway TipTap safety re-parse (~50-67ms here) must stay under the 300ms serialize debounce so it can't stall the main thread on slow/SSH hosts.
const RECONCILE_SIZE_CAP_CODE_UNITS = 50_000
@@ -147,10 +150,7 @@ function getUtf8OffsetsAtCodeUnitIndices(
for (const target of targets) {
const boundedTarget = Math.max(0, Math.min(target, text.length))
while (codeUnitIndex < boundedTarget) {
const codePoint = text.codePointAt(codeUnitIndex)
if (codePoint === undefined) {
break
}
const codePoint = readUtf8CodePointAt(text, codeUnitIndex)
byteOffset += getUtf8ByteLengthForCodePoint(codePoint)
codeUnitIndex += codePoint > 0xffff ? 2 : 1
}
@@ -1,7 +1,10 @@
import { BRACKETED_PASTE_END, BRACKETED_PASTE_START } from './terminal-bracketed-paste'
import { TERMINAL_PASTE_CHUNK_MAX_BYTES } from './terminal-paste-limits'
import type { TerminalPastePlan } from './terminal-paste-coordinator'
import { getUtf8ByteLengthForCodePoint } from '../../../../shared/utf8-byte-limits'
import {
getUtf8ByteLengthForCodePoint,
readUtf8CodePointAt
} from '../../../../shared/utf8-byte-limits'
const TERMINAL_PASTE_ESCAPE_CODE_POINT = 0x1b
const TERMINAL_PASTE_INERT_ESCAPE_CODE_POINT = 0x241b
@@ -38,7 +41,7 @@ function* iterateTextByUtf8Bytes(
let chunk = ''
let chunkBytes = 0
for (let index = 0; index < text.length; index += 1) {
const codePoint = text.codePointAt(index) ?? 0
const codePoint = readUtf8CodePointAt(text, index)
const codeUnitLength = codePoint > 0xffff ? 2 : 1
// Why: iterator normalization avoids a full-size copy and keeps CRLF atomic across chunks.
if (
@@ -1,6 +1,9 @@
import { yieldToEventLoop } from '../../../shared/event-loop-yield'
import type { GlobalSettings } from '../../../shared/types'
import { getUtf8ByteLengthForCodePoint } from '../../../shared/utf8-byte-limits'
import {
getUtf8ByteLengthForCodePoint,
readUtf8CodePointAt
} from '../../../shared/utf8-byte-limits'
import {
BRACKETED_PASTE_END,
BRACKETED_PASTE_START,
@@ -110,7 +113,7 @@ export function* iterateAgentDraftPasteContentChunks(
let chunkBytes = 0
for (let index = 0; index < terminalContent.length; index += 1) {
const codePoint = terminalContent.codePointAt(index) ?? 0
const codePoint = readUtf8CodePointAt(terminalContent, index)
const codeUnitLength = codePoint > 0xffff ? 2 : 1
const sanitizedEscape = codePoint === AGENT_DRAFT_PASTE_ESCAPE_CODE_POINT
const sanitized = sanitizedEscape
@@ -150,7 +153,7 @@ function measureSanitizedUtf8ByteLength(
let byteLength = 0
const stopAfterBytes = options.stopAfterBytes
for (let index = 0; index < content.length; index += 1) {
const codePoint = content.codePointAt(index) ?? 0
const codePoint = readUtf8CodePointAt(content, index)
byteLength += getSanitizedUtf8ByteLengthForCodePoint(codePoint)
if (Number.isFinite(stopAfterBytes) && byteLength > (stopAfterBytes ?? 0)) {
return { byteLength, exceededLimit: true }
@@ -166,7 +169,7 @@ async function isSanitizedDraftPasteOverLimit(content: string, maxBytes: number)
let byteLength = 0
let nextYieldAt = AGENT_DRAFT_PASTE_PREFLIGHT_YIELD_CODE_UNITS
for (let index = 0; index < content.length; index += 1) {
const codePoint = content.codePointAt(index) ?? 0
const codePoint = readUtf8CodePointAt(content, index)
byteLength += getSanitizedUtf8ByteLengthForCodePoint(codePoint)
if (byteLength > maxBytes) {
return true
@@ -1,4 +1,7 @@
import { getUtf8ByteLengthForCodePoint } from '../../../shared/utf8-byte-limits'
import {
getUtf8ByteLengthForCodePoint,
readUtf8CodePointAt
} from '../../../shared/utf8-byte-limits'
export const COMMENT_BODY_NONBLANK_SCAN_MAX_BYTES = 64 * 1024
@@ -16,7 +19,7 @@ function getCommentBodyPresence(
let scannedBytes = 0
for (let index = 0; index < body.length; index += 1) {
const codePoint = body.codePointAt(index) ?? 0
const codePoint = readUtf8CodePointAt(body, index)
const codeUnitLength = codePoint > 0xffff ? 2 : 1
scannedBytes += getUtf8ByteLengthForCodePoint(codePoint)
if (scannedBytes > maxScanBytes) {
@@ -1,5 +1,8 @@
import { yieldToEventLoop } from '../../../shared/event-loop-yield'
import { getUtf8ByteLengthForCodePoint } from '../../../shared/utf8-byte-limits'
import {
getUtf8ByteLengthForCodePoint,
readUtf8CodePointAt
} from '../../../shared/utf8-byte-limits'
export type PastePayloadMetadata = {
byteLength: number
@@ -25,7 +28,7 @@ export function measurePastePayloadMetadata(
let previousWasCarriageReturn = false
for (let index = 0; index < text.length; index += 1) {
const codePoint = text.codePointAt(index) ?? 0
const codePoint = readUtf8CodePointAt(text, index)
byteLength += getUtf8ByteLengthForCodePoint(codePoint)
hasControlSequences ||= isPasteControlSequenceCodePoint(codePoint)
if (codePoint === 0x0d) {
@@ -73,7 +76,7 @@ export async function measurePastePayloadMetadataWithYield(
let previousWasCarriageReturn = false
for (let index = 0; index < text.length; index += 1) {
const codePoint = text.codePointAt(index) ?? 0
const codePoint = readUtf8CodePointAt(text, index)
byteLength += getUtf8ByteLengthForCodePoint(codePoint)
hasControlSequences ||= isPasteControlSequenceCodePoint(codePoint)
if (codePoint === 0x0d) {
@@ -110,7 +113,7 @@ export function countPastePayloadLines(text: string): number {
export function hasPastePayloadControlSequence(text: string): boolean {
for (let index = 0; index < text.length; index += 1) {
const codePoint = text.codePointAt(index) ?? 0
const codePoint = readUtf8CodePointAt(text, index)
if (isPasteControlSequenceCodePoint(codePoint)) {
return true
}
+2 -1
View File
@@ -2,6 +2,7 @@ import { yieldToEventLoop } from './event-loop-yield'
import {
getUtf8ByteLengthForCodePoint,
measureUtf8ByteLength,
readUtf8CodePointAt,
type Utf8ByteLengthMeasurement
} from './utf8-byte-limits'
@@ -50,7 +51,7 @@ export async function measureClipboardTextByteLengthWithYield(
let byteLength = 0
for (let index = 0; index < text.length; index += 1) {
const codePoint = text.codePointAt(index) ?? 0
const codePoint = readUtf8CodePointAt(text, index)
byteLength += getUtf8ByteLengthForCodePoint(codePoint)
if (Number.isFinite(stopAfterBytes) && byteLength > (stopAfterBytes ?? 0)) {
return { byteLength, exceededLimit: true }
+73 -1
View File
@@ -1,5 +1,77 @@
import { describe, expect, it } from 'vitest'
import { getUtf8ChunkEndIndex, isUtf8ByteLengthWithinLimit } from './utf8-byte-limits'
import {
clampUtf8TextPrefix,
getUtf8ChunkEndIndex,
isUtf8ByteLengthWithinLimit,
measureUtf8ByteLength,
readUtf8CodePointAt
} from './utf8-byte-limits'
// Once V8 optimizes the calling function, `String.prototype.codePointAt` on a sliced string
// pairs a trailing high surrogate with the code unit that follows the SLICE inside its parent
// (reproduced on Node 24 and 26). A prefix slice cut mid-pair then reads a code point the
// string does not contain, so a scan reports one byte too many — but only after tier-up, which
// is why it surfaced as an intermittent CI failure rather than a deterministic one.
// Build a rope, cut it mid-pair, and hammer the scan so the optimizing tier is under test.
const SLICE_BOUNDARY_PARENT_UNITS = [
0x72, 0x2e6, 0x7b, 0x54, 0xda9b, 0x568, 0x52, 0x26, 0x46, 0xc15b, 0x7, 0x768, 0xd9f6, 0xdcde
]
// V8 only creates a sliced string (rather than copying) at 13 code units or more.
const SLICE_BOUNDARY_UNITS = 13
const TIER_UP_ITERATIONS = 200_000
function buildSliceEndingInLoneHighSurrogate(): string {
let text = ''
for (const unit of SLICE_BOUNDARY_PARENT_UNITS) {
text += String.fromCharCode(unit)
}
return text.slice(0, SLICE_BOUNDARY_UNITS)
}
describe('scanning a prefix slice whose parent continues past the slice', () => {
const sliced = buildSliceEndingInLoneHighSurrogate()
it('is a sliced string that ends in a lone high surrogate', () => {
expect(sliced.length).toBe(SLICE_BOUNDARY_UNITS)
expect(sliced.charCodeAt(SLICE_BOUNDARY_UNITS - 1)).toBe(0xd9f6)
expect(Buffer.byteLength(sliced, 'utf8')).toBe(22)
})
it('reads the trailing lone surrogate without pairing past the end in every JIT tier', () => {
const observed = new Set<number>()
for (let iteration = 0; iteration < TIER_UP_ITERATIONS; iteration += 1) {
observed.add(readUtf8CodePointAt(sliced, SLICE_BOUNDARY_UNITS - 1))
}
expect([...observed]).toEqual([0xd9f6])
})
it('measures the same byte length as the encoder in every JIT tier', () => {
const expected = Buffer.byteLength(sliced, 'utf8')
const observed = new Set<number>()
for (let iteration = 0; iteration < TIER_UP_ITERATIONS; iteration += 1) {
observed.add(measureUtf8ByteLength(sliced).byteLength)
}
expect([...observed]).toEqual([expected])
})
it('does not report an exceeded limit at the true byte length in every JIT tier', () => {
const limit = Buffer.byteLength(sliced, 'utf8')
const observed = new Set<boolean>()
for (let iteration = 0; iteration < TIER_UP_ITERATIONS; iteration += 1) {
observed.add(measureUtf8ByteLength(sliced, { stopAfterBytes: limit }).exceededLimit)
}
expect([...observed]).toEqual([false])
})
it('keeps the whole slice when clamping to its true byte length in every JIT tier', () => {
const limit = Buffer.byteLength(sliced, 'utf8')
const observed = new Set<number>()
for (let iteration = 0; iteration < TIER_UP_ITERATIONS; iteration += 1) {
observed.add(clampUtf8TextPrefix(sliced, limit).length)
}
expect([...observed]).toEqual([SLICE_BOUNDARY_UNITS])
})
})
describe('getUtf8ChunkEndIndex', () => {
it.each([
+24 -4
View File
@@ -8,6 +8,26 @@ export type Utf8TextTail = {
bytes: number
}
/**
* Bounds-safe replacement for `String.prototype.codePointAt`.
*
* Why: once V8 optimizes the calling function, `codePointAt` on a sliced string pairs a
* trailing high surrogate with the code unit that follows the SLICE inside its parent, so a
* prefix slice cut mid-pair reports a code point the string does not contain (and one byte
* too many). `charCodeAt` stays bounds-correct in every tier, so pair the units explicitly.
*/
export function readUtf8CodePointAt(text: string, index: number): number {
const leadUnit = text.charCodeAt(index)
if (leadUnit < 0xd800 || leadUnit > 0xdbff || index + 1 >= text.length) {
return leadUnit
}
const trailUnit = text.charCodeAt(index + 1)
if (trailUnit < 0xdc00 || trailUnit > 0xdfff) {
return leadUnit
}
return (leadUnit - 0xd800) * 0x400 + (trailUnit - 0xdc00) + 0x10000
}
export function measureUtf8ByteLength(
text: string,
options: { stopAfterBytes?: number } = {}
@@ -15,7 +35,7 @@ export function measureUtf8ByteLength(
const stopAfterBytes = options.stopAfterBytes
let byteLength = 0
for (let index = 0; index < text.length; index += 1) {
const codePoint = text.codePointAt(index) ?? 0
const codePoint = readUtf8CodePointAt(text, index)
byteLength += getUtf8ByteLengthForCodePoint(codePoint)
if (Number.isFinite(stopAfterBytes) && byteLength > (stopAfterBytes ?? 0)) {
return { byteLength, exceededLimit: true }
@@ -69,7 +89,7 @@ export function clampUtf8TextPrefix(text: string, maxBytes: number): string {
let bytes = 0
let end = 0
while (end < text.length) {
const codePoint = text.codePointAt(end) ?? 0
const codePoint = readUtf8CodePointAt(text, end)
const codePointBytes = getUtf8ByteLengthForCodePoint(codePoint)
if (bytes + codePointBytes > maxBytes) {
break
@@ -84,7 +104,7 @@ export function getUtf8ChunkEndIndex(text: string, startIndex: number, maxBytes:
let bytes = 0
let endIndex = startIndex
while (endIndex < text.length) {
const codePoint = text.codePointAt(endIndex) ?? 0
const codePoint = readUtf8CodePointAt(text, endIndex)
const codePointBytes = getUtf8ByteLengthForCodePoint(codePoint)
if (bytes > 0 && bytes + codePointBytes > maxBytes) {
break
@@ -123,6 +143,6 @@ function getPreviousUtf8CodePoint(
}
return {
start,
bytes: getUtf8ByteLengthForCodePoint(text.codePointAt(start) ?? codeUnit)
bytes: getUtf8ByteLengthForCodePoint(readUtf8CodePointAt(text, start))
}
}