fix(terminal): release xterm's DEC 2026 render hold instead of waiting out its 1s timeout (#23920)

* fix(terminal): release xterm's DEC 2026 render hold instead of waiting out its 1s timeout

xterm paints nothing while DEC mode 2026 (synchronized output) is open and only
force-flushes after 1000ms. Codex wraps every draw in mode 2026, so any byte gap
or chunk split that loses the closing \x1b[?2026l freezes the pane for a full
second and then repaints in one burst.

Orca never emitted \x1b[?2026l anywhere, and three paths could destroy a TUI's:
the per-PTY pending cap drops buffered output wholesale (mode 2031 was already
salvaged there, 2026 was not), main sliced pending data at a blind 16KB offset
that can land inside an open frame or sever the 8-byte marker, and the renderer's
backlog warnings replace a queued tail that may hold the close.

- salvage the 2026 latch across dropped output, mirroring the existing 2031
  salvage, and append the release on both delivery sites
- ground 2026 in RESET_AFTER_BYTE_GAP and the replay baseline, and in both
  backlog warnings, so every drop path is self-healing
- make main's 16KB flush split frame-aware instead of a blind byte offset
- lift the synchronized-output scanner into shared/ so main and the renderer
  use one implementation

Closing a frame early costs one premature repaint; leaving it open costs a
second of blank screen, so the asymmetry favours always closing.

Also adds the reproduction this needed: the pre-existing typing bench observes
the xterm BUFFER, which the parser fills while rendering is held, so it scored
these freezes as fast echoes.

* fix(terminal): stop the renderer's queue drain cutting inside an open DEC 2026 frame

takeQueuedChunk sliced a queued chunk at a blind byte offset to fit the 16KB
coalescing budget, which can strand a frame's closing \x1b[?2026l in the residual
until a later drain. Same defect as main's flush split, same fix: reuse the
frame-aware split helper.

Usually masked because the drain coalesces adjacent chunks and reassembles what
main split, but not when the budget boundary falls inside a frame.

* fix(relay): keep the SSH path's bounded slice outside an open DEC 2026 frame

pty-handler split pending output at a byte offset with a surrogate-pair guard but
no synchronized-output awareness, so a frame straddling the 16KB wire slice had
its closing \x1b[?2026l stranded in the remainder — the same defect just fixed on
the local path, on the path AGENTS.md requires us to consider.

Placed before the surrogate guard so that guard keeps the final say, and floored
at 2 so frame alignment can never walk a healthy slice into the guard's
decrement and then into the chunkChars <= 0 pause-and-retry path.

Also drops a dead `splitAt === 0` branch in takeQueuedChunk: both callers pass a
positive limit and the helper never returns 0 for one.

The two new split tests were each confirmed to fail without their fix.

* test(terminal): sweep the DEC 2026 split helper over escape-sequence shapes and every limit

Covers OSC 52, DCS, repeated open/close markers and limits 1..len+3, asserting the
result never exceeds the limit, never reaches 0, and stays byte-exact. Also pins
that a buffer beginning inside an open frame degrades to the blind offset rather
than doing something worse, and documents that callers do not thread latch state.

* fix(terminal): ground DEC 2026 on the daemon slice, the recovery replays, and the process boundary

Four more sites could strand the latch, found by sweeping every path that drops,
splits, or replays terminal bytes.

- daemon-stream-data-batcher: the 64KB bulk-write slice used a surrogate-only
  clamp, and its remainder is HELD until 'drain' — "seconds for multi-MB
  backlogs" per the file's own note. A frame straddling that boundary parked its
  \x1b[?2026l behind the hold, blanking the pane past xterm's 1s timeout once per
  frame for as long as the backlog lasted. This is the default daemon-backed pane
  path, so it is the one users actually hit. The new
  clampToSafeBulkWriteSplitIndex frame-aligns first and surrogate-clamps last,
  and lives in daemon-stream-data-split alongside the policy it belongs to.
- replay-data-drain and remote-runtime-terminal-binary-snapshots wrote a bare
  \x1b[2J\x1b[3J\x1b[H, which does not clear mode 2026 — so on the SSH/remote
  reconnect path, the very event most likely to sever a frame, the whole replay
  could paint nothing.
- ipc-pty-attach: trimIncompleteTerminalControlTail can cut a half-written
  \x1b[?2026l while its opening marker survives in the replayed prefix.
- PROCESS_BOUNDARY_GROUND: the "process that armed these modes is gone" ground
  omitted 2026, the last unexplained gap in that file. A disable, so it still
  satisfies the recovery barrier's ownership scan (only ?25h may be an enable).

Recovery-path expectations updated where they pin the emitted bytes. Deliberately
NOT touched: apply-reattach-payload and ssh-snapshot-prepaint already ground via
buildSnapshotReplayPrologue.

Still unfixed, deferred with reason: terminal-output-frame-chunks.ts splits the
remote wire on accumulated UTF-8 byte width and needs a different shape than the
char-index helper; desktop clients reassemble in main's pending buffer, so the
exposure is mobile/web only.

* fix(terminal): emit the DEC 2026 release before the mode-2031 tail, and stop claiming the drop path writes it

Two corrections from adversarial review of the earlier commits.

1. Ordering bug I introduced. getDroppedMode2031RendererData ends with
   `state.tail`, which extractPrivateModeScanTail deliberately retains as an
   INCOMPLETE private-mode sequence so the next chunk can resolve it. Appending the
   2026 release after it put an ESC behind a dangling CSI, aborting it and silently
   losing whatever mode spanned the drop boundary. The release now goes first.

2. The drop-path release does not reach xterm in the dominant case, and the comment
   now says so instead of implying otherwise. live-data-callback's droppedOutput
   branch discards `data` and salvages only queries
   (salvageRendererQueriesFromDiscardedRestoreData handles CPR/DA1/OSC colour;
   \x1b[?2026l is not a query), so for hidden panes and visible panes outside
   foreground-restore backpressure the synthesized release was dropped. The grounded
   snapshot replay releases the latch instead.

   I tried writing it through writePtyOutputToXterm there and reverted: it consumes
   the pending hidden-output snapshot and broke
   pty-connection-hidden-snapshot-resize-signals ("re-restores a skipped alt frame"),
   so the release rides the restore rather than perturbing that state machine.
   Residual gap, documented: a cap-dropped pane whose restore never arrives.

The salvage is still load-bearing on the fall-through path, so it stays.

* fix(terminal): release DEC 2026 on the reattach clears, floor the split, and correct the freeze framing

Remaining findings from adversarial review.

- apply-reattach-payload's three bare-clear branches (:63 daemon snapshot, :229
  relay replay, :269 cold restore) had no release anywhere in their sequence: I
  checked all seven POST_REPLAY_* profiles reachable via chooseReattachReplayReset
  and none contains \x1b[?2026l. Only the buildMainModelSnapshotReplayWrites branch
  was grounded, so covering the streamed replay path and not the main reattach path
  was inconsistent. Verified no production code matches these clear strings — the
  three test updates are mock equality, and each was confirmed to fail without the
  source change.
- clampToSafeBulkWriteSplitIndex could return 0 (('\u{1F600}aaaa', 1) — alignment
  returns 1, the surrogate clamp decrements to 0), which would leave a zero-length
  slice that never shifts the batcher's queue entry and spin its drain loop.
  Unreachable from today's only caller, but it is exported with an unstated
  precondition. Floored at 1.
- Frame alignment could halve per-PTY flush throughput: main re-queues the
  remainder with eligibleRound = round + 1, so the shortfall cannot be refilled in
  the same round, and aligned size is floor(W/F)*F — 50% worst case in the 8-16KB
  band, which is exactly the full-screen redraw burst that reaches the pending cap.
  Alignment is now rejected below half the window, preferring throughput and
  letting the reset profiles release the latch.

Framing corrected throughout: bufferRows records a row range and clears nothing, so
the pane freezes on its last painted frame — it does not go blank. The real trade is
"stale but coherent for <=1s" versus "immediate partial frame", and
RESET_AFTER_BYTE_GAP (written alone, with no repaint behind it in the same write) is
the one site that can newly flash a partial frame. Said so at the constant instead
of implying the release is free.

* fix(terminal): rename the shape-flagged symbols the anti-slop audit rejects

CI's anti-slop gate rejects "shape" in symbol names as structural rather than
domain language: `shapes` -> `outputSamples`, and
`writeCodexShapedEchoProbeScript`/`codexShapedEchoProbeScript` ->
`writeCodexEchoProbeScript`/`codexEchoProbeScript`.
This commit is contained in:
Neil
2026-09-29 20:27:30 -07:00
committed by GitHub
parent 33351b0085
commit fb52c0602a
34 changed files with 1275 additions and 106 deletions
@@ -53,7 +53,7 @@ describe('terminal mode reset profiles', () => {
// Why: the one reset for a process boundary (cold-restore seed, proven crash).
it('pins the process boundary ground', () => {
expect(PROCESS_BOUNDARY_GROUND).toBe(
'\x1b[<99u\x1b[=0u\x1b7\x1b[?1049l\x1b[?9l\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1016l\x1b[?1005l\x1b[?1015l\x1b[?1004l\x1b[?2004l\x1b[?1l\x1b[?66l\x1b[?25h\x1b[0 q\x1b[<99u\x1b[=0u\x1b[0m\x1b7'
'\x1b[?2026l\x1b[<99u\x1b[=0u\x1b7\x1b[?1049l\x1b[?9l\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1016l\x1b[?1005l\x1b[?1015l\x1b[?1004l\x1b[?2004l\x1b[?1l\x1b[?66l\x1b[?25h\x1b[0 q\x1b[<99u\x1b[=0u\x1b[0m\x1b7'
)
})
+20 -3
View File
@@ -58,6 +58,21 @@ export const POST_REPLAY_LIVE_AGENT_SNAPSHOT_RESET = RESET_TERMINAL_CURSOR_STYLE
// writes the clipboard.
export const ABORT_TRUNCATED_CONTROL_STRING = '\x18'
// Trade-off, stated because it is not free: the pane was FROZEN on its last
// coherent frame, not blank (bufferRows records a row range and clears nothing).
// Releasing the latch where no repaint follows in the same write — RESET_AFTER_BYTE_GAP
// is written alone — can flash a partial frame in place of that coherent one. A byte
// gap already means the stream is damaged and a restore follows, so a stale frame that
// outlives the damage is the worse option.
// Why this is grounded everywhere a byte gap or a repaint happens: xterm renders
// NOTHING while DEC 2026 is open and only force-flushes after 1000ms, so a gap
// that swallowed a TUI's closing \x1b[?2026l leaves the pane blank for a full
// second per frame — and Orca is otherwise incapable of closing a latch it
// opened. Unlike the modes deliberately left ungrounded below, a snapshot never
// re-asserts 2026, and closing a frame early costs one premature repaint against
// a second of frozen, increasingly stale output.
export const RELEASE_SYNCHRONIZED_OUTPUT = '\x1b[?2026l'
// Why the DECSC first: xterm's `?1049l` runs restoreCursor() even on the normal
// buffer, so saving in place keeps the cursor put there; on the alt buffer the
// save lands in the alt register and `?1049l` restores the shell's position.
@@ -78,7 +93,9 @@ const SHOW_CURSOR = '\x1b[?25h'
*/
export function buildProcessBoundaryGround(opts: { keepFocusReporting: boolean }): string {
const focus = opts.keepFocusReporting ? '' : RESET_FOCUS_REPORTING
return `${RESET_KITTY_KEYBOARD_PROTOCOL}${LEAVE_ALTERNATE_SCREEN_KEEPING_NORMAL_CURSOR}${RESET_MOUSE_REPORTING}${RESET_LEGACY_MOUSE_ENCODINGS}${focus}${RESET_BRACKETED_PASTE}${RESET_APPLICATION_CURSOR_AND_KEYPAD}${SHOW_CURSOR}${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}${RESET_GRAPHIC_RENDITION}${SAVE_GROUNDED_CURSOR}`
// RELEASE_SYNCHRONIZED_OUTPUT first: the process that opened a 2026 frame is
// gone, so nothing will ever close it, and xterm stops repainting until it does.
return `${RELEASE_SYNCHRONIZED_OUTPUT}${RESET_KITTY_KEYBOARD_PROTOCOL}${LEAVE_ALTERNATE_SCREEN_KEEPING_NORMAL_CURSOR}${RESET_MOUSE_REPORTING}${RESET_LEGACY_MOUSE_ENCODINGS}${focus}${RESET_BRACKETED_PASTE}${RESET_APPLICATION_CURSOR_AND_KEYPAD}${SHOW_CURSOR}${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}${RESET_GRAPHIC_RENDITION}${SAVE_GROUNDED_CURSOR}`
}
export const PROCESS_BOUNDARY_GROUND = buildProcessBoundaryGround({ keepFocusReporting: false })
@@ -87,7 +104,7 @@ export const PROCESS_BOUNDARY_GROUND = buildProcessBoundaryGround({ keepFocusRep
// queued chunks instead of repainting. Parser + pen only — a live TUI keeps
// writing here and owns its charset and margins. Not DECSTR: xterm's soft reset
// wipes the kitty flags agents negotiate only at startup.
export const RESET_AFTER_BYTE_GAP = `${ABORT_TRUNCATED_CONTROL_STRING}${RESET_GRAPHIC_RENDITION}`
export const RESET_AFTER_BYTE_GAP = `${ABORT_TRUNCATED_CONTROL_STRING}${RELEASE_SYNCHRONIZED_OUTPUT}${RESET_GRAPHIC_RENDITION}`
// The baseline a serialized snapshot assumes it lands on: SerializeAddon diffs
// cells against DEFAULT attributes and emits no charset at all.
@@ -100,7 +117,7 @@ export const RESET_AFTER_BYTE_GAP = `${ABORT_TRUNCATED_CONTROL_STRING}${RESET_GR
// resetting is unilateral. `enacs=\E(B\E)0` (screen/tmux/vt100 terminfo)
// designates G1 once at init and then uses bare SO/SI, so grounding G1 would
// render a live app's box drawing as letters.
const REPLAY_BASELINE_TERMINAL_RESET = `${RESET_GRAPHIC_RENDITION}\x0f\x1b(B\x1b[?6l\x1b[?7h\x1b[?45l\x1b[4l`
const REPLAY_BASELINE_TERMINAL_RESET = `${RELEASE_SYNCHRONIZED_OUTPUT}${RESET_GRAPHIC_RENDITION}\x0f\x1b(B\x1b[?6l\x1b[?7h\x1b[?45l\x1b[4l`
// Buffer-scoped: margins live on the xterm buffer, and `?1049` neither carries
// them across nor clears them unless it actually swaps.
@@ -0,0 +1,125 @@
import { describe, expect, it } from 'vitest'
import {
INITIAL_SYNCHRONIZED_OUTPUT_LATCH_STATE,
SYNCHRONIZED_OUTPUT_END_SEQUENCE,
SYNCHRONIZED_OUTPUT_START_SEQUENCE,
advanceDroppedSynchronizedOutputLatch,
resolveSynchronizedOutputSafeSplit,
scanSynchronizedOutput
} from './terminal-synchronized-output-scan'
const OPEN = SYNCHRONIZED_OUTPUT_START_SEQUENCE
const CLOSE = SYNCHRONIZED_OUTPUT_END_SEQUENCE
describe('advanceDroppedSynchronizedOutputLatch', () => {
it('releases the latch when the dropped span ended mid-frame', () => {
// The close was inside the bytes the renderer will never receive, so xterm
// would paint nothing until its 1000ms forced flush.
const result = advanceDroppedSynchronizedOutputLatch(
`${OPEN}rows`,
INITIAL_SYNCHRONIZED_OUTPUT_LATCH_STATE
)
expect(result.state.active).toBe(true)
expect(result.data).toBe(CLOSE)
})
it('emits nothing when the dropped span closed its own frame', () => {
const result = advanceDroppedSynchronizedOutputLatch(
`${OPEN}rows${CLOSE}`,
INITIAL_SYNCHRONIZED_OUTPUT_LATCH_STATE
)
expect(result.state.active).toBe(false)
expect(result.data).toBe('')
})
it('carries an open latch across successive dropped chunks', () => {
const first = advanceDroppedSynchronizedOutputLatch(
`${OPEN}a`,
INITIAL_SYNCHRONIZED_OUTPUT_LATCH_STATE
)
const second = advanceDroppedSynchronizedOutputLatch('b', first.state)
expect(second.state.active).toBe(true)
expect(second.data).toBe(CLOSE)
const third = advanceDroppedSynchronizedOutputLatch(CLOSE, second.state)
expect(third.state.active).toBe(false)
expect(third.data).toBe('')
})
it('stitches a close marker split across dropped chunks', () => {
const head = CLOSE.slice(0, 4)
const tail = CLOSE.slice(4)
const first = advanceDroppedSynchronizedOutputLatch(
`${OPEN}rows${head}`,
INITIAL_SYNCHRONIZED_OUTPUT_LATCH_STATE
)
expect(first.state.active).toBe(true)
const second = advanceDroppedSynchronizedOutputLatch(tail, first.state)
expect(second.state.active).toBe(false)
expect(second.data).toBe('')
})
})
describe('resolveSynchronizedOutputSafeSplit', () => {
it('returns the whole length when it already fits', () => {
expect(resolveSynchronizedOutputSafeSplit('abc', 16)).toBe(3)
})
it('splits after a completed frame rather than inside the next one', () => {
const data = `${OPEN}aaaa${CLOSE}${OPEN}bbbbbbbbbb${CLOSE}`
const limit = data.indexOf('bbb')
const splitAt = resolveSynchronizedOutputSafeSplit(data, limit)
// Everything delivered must leave the latch closed.
expect(scanSynchronizedOutput(data.slice(0, splitAt), '', false).active).toBe(false)
expect(splitAt).toBe(`${OPEN}aaaa${CLOSE}`.length)
})
it('never severs the close marker itself', () => {
const data = `${OPEN}aaaa${CLOSE}tail`
// Limit lands in the middle of the 8-byte close sequence.
const limit = `${OPEN}aaaa`.length + 4
const splitAt = resolveSynchronizedOutputSafeSplit(data, limit)
expect(splitAt).toBeLessThanOrEqual(limit)
expect(data.slice(0, splitAt).endsWith('\x1b')).toBe(false)
// The remainder must still contain a complete, parseable close.
expect(data.slice(splitAt)).toContain(CLOSE)
})
it('falls back to the limit when one frame is longer than the window', () => {
const data = `${OPEN}${'x'.repeat(100)}${CLOSE}`
expect(resolveSynchronizedOutputSafeSplit(data, 20)).toBe(20)
})
it('degrades to the plain limit when the buffer starts inside a frame', () => {
// Callers do not thread prior latch state, so a remainder that begins inside
// an already-open frame is scanned as if closed. It must never be WORSE than
// the blind offset it replaced: same boundary, byte-exact.
const data = `${'z'.repeat(40)}${CLOSE}${'q'.repeat(40)}`
const limit = 20
const splitAt = resolveSynchronizedOutputSafeSplit(data, limit, '', true)
const naive = resolveSynchronizedOutputSafeSplit(data, limit)
// With the real prior state it can only do better or the same.
expect(splitAt).toBeLessThanOrEqual(limit)
expect(naive).toBeLessThanOrEqual(limit)
expect(data.slice(0, naive) + data.slice(naive)).toBe(data)
})
it('never returns past the limit or breaks byte-exactness across many shapes', () => {
const outputSamples = [
`${OPEN}${'a'.repeat(50)}${CLOSE}`,
`${'a'.repeat(50)}${CLOSE}${'b'.repeat(50)}`,
`${OPEN}${OPEN}${'a'.repeat(30)}${CLOSE}${CLOSE}`,
`${'a'.repeat(30)}\x1b]52;c;SGVsbG8=\x07${'b'.repeat(30)}`,
`${'a'.repeat(30)}\x1bP0;1|payload\x1b\\${'b'.repeat(30)}`,
CLOSE.repeat(10),
`${OPEN.repeat(10)}tail`
]
for (const data of outputSamples) {
for (let limit = 1; limit <= data.length + 3; limit++) {
const splitAt = resolveSynchronizedOutputSafeSplit(data, limit)
expect(splitAt).toBeGreaterThan(0)
expect(splitAt).toBeLessThanOrEqual(Math.min(limit, data.length))
expect(data.slice(0, splitAt) + data.slice(splitAt)).toBe(data)
}
}
})
})
@@ -0,0 +1,164 @@
/**
* DEC mode 2026 (synchronized output) latch tracking.
*
* Why this module is shared: like terminal-mode-reset-profiles, the latch is a
* terminal-protocol contract rather than a renderer concern. xterm stops
* repainting while the latch is open and force-flushes only after a 1000ms
* timeout, so whichever side of the PTY relay last touched a pane's bytes has
* to know whether it left a frame open — main's drop paths included, not just
* the renderer's foreground coalescer.
*/
export const SYNCHRONIZED_OUTPUT_START_SEQUENCE = '\x1b[?2026h'
export const SYNCHRONIZED_OUTPUT_END_SEQUENCE = '\x1b[?2026l'
export const SYNCHRONIZED_OUTPUT_MARKER_TAIL_CHARS = SYNCHRONIZED_OUTPUT_START_SEQUENCE.length - 1
export type SynchronizedOutputScan = {
/** A start marker landed inside THIS chunk. */
started: boolean
/** An end marker landed inside THIS chunk. */
ended: boolean
/** Latch state after the chunk: true means a frame is still open. */
active: boolean
markerTail: string
}
// Why the carried tail: a PTY relay can split \x1b[?2026l across chunks; scanning the raw
// chunk alone left the foreground DEC 2026 latch stuck open so every later chunk was
// held instead of coalesced, freezing the visible pane (#8754).
export function scanSynchronizedOutput(
data: string,
markerTail: string,
wasActive: boolean
): SynchronizedOutputScan {
const scanData = markerTail ? `${markerTail}${data}` : data
const currentChunkStartIndex = scanData.length - data.length
let active = wasActive
let started = false
let ended = false
let startIndex = scanData.indexOf(SYNCHRONIZED_OUTPUT_START_SEQUENCE)
let endIndex = scanData.indexOf(SYNCHRONIZED_OUTPUT_END_SEQUENCE)
// Each marker search advances independently, so a missing counterpart is scanned only once.
while (startIndex !== -1 || endIndex !== -1) {
if (endIndex !== -1 && (startIndex === -1 || endIndex < startIndex)) {
active = false
if (endIndex + SYNCHRONIZED_OUTPUT_END_SEQUENCE.length > currentChunkStartIndex) {
ended = true
}
endIndex = scanData.indexOf(
SYNCHRONIZED_OUTPUT_END_SEQUENCE,
endIndex + SYNCHRONIZED_OUTPUT_END_SEQUENCE.length
)
continue
}
active = true
if (startIndex + SYNCHRONIZED_OUTPUT_START_SEQUENCE.length > currentChunkStartIndex) {
started = true
}
startIndex = scanData.indexOf(
SYNCHRONIZED_OUTPUT_START_SEQUENCE,
startIndex + SYNCHRONIZED_OUTPUT_START_SEQUENCE.length
)
}
return {
started,
ended,
active,
// Why length-1: a full marker can never hide in the tail, so no marker is counted twice.
markerTail: scanData.slice(-SYNCHRONIZED_OUTPUT_MARKER_TAIL_CHARS)
}
}
export type SynchronizedOutputLatchState = {
markerTail: string
active: boolean
}
export const INITIAL_SYNCHRONIZED_OUTPUT_LATCH_STATE: SynchronizedOutputLatchState = {
markerTail: '',
active: false
}
/**
* Advances the latch across bytes the renderer will never receive.
*
* Returns the sequence that must ride along with whatever IS delivered so the
* pane is not left mid-frame: a drop that swallowed the closing marker would
* otherwise leave xterm painting nothing until its 1000ms forced flush, once
* per frame, for as long as the condition lasts. Closing a frame early only
* costs one premature repaint; leaving it open costs a full second of blank
* screen, so the asymmetry favours always closing.
*/
export function advanceDroppedSynchronizedOutputLatch(
data: string,
previous: SynchronizedOutputLatchState
): { data: string; state: SynchronizedOutputLatchState } {
const scan = scanSynchronizedOutput(data, previous.markerTail, previous.active)
return {
data: scan.active ? SYNCHRONIZED_OUTPUT_END_SEQUENCE : '',
state: { markerTail: scan.markerTail, active: scan.active }
}
}
/**
* Chooses a split point that does not leave the delivered half inside an open
* DEC 2026 frame.
*
* A blind byte-offset split puts `\x1b[?2026h` in one chunk and its
* `\x1b[?2026l` in the next, so xterm stops repainting until the remainder is
* delivered on a later flush — behind every other pane's output — or until its
* 1000ms forced flush. It can also sever the 8-byte marker itself.
*
* Returns the largest length <= `limit` that ends outside an open frame, or
* `limit` when no such point exists (a frame genuinely longer than the window;
* the latch release still rides along via the reset profiles).
*
* KNOWN LIMITATION: no caller threads `markerTail`/`wasActive`, so a buffer that
* begins INSIDE an already-open frame is scanned as if closed. That degrades to
* the blind offset this replaced — never worse, and byte-exact either way — but
* it means cross-chunk alignment is best-effort. Threading per-PTY latch state
* through the split sites would close it.
*/
export function resolveSynchronizedOutputSafeSplit(
data: string,
limit: number,
markerTail = '',
wasActive = false
): number {
if (data.length <= limit) {
return data.length
}
// Step 1: never hand over a severed marker. If an ESC near the boundary cannot
// have completed by `limit`, cut before it instead.
let candidate = limit
const guardStart = Math.max(0, limit - SYNCHRONIZED_OUTPUT_MARKER_TAIL_CHARS)
const escapeIndex = data.lastIndexOf('\x1b', limit - 1)
if (
escapeIndex >= guardStart &&
limit - escapeIndex < SYNCHRONIZED_OUTPUT_START_SEQUENCE.length
) {
candidate = escapeIndex
}
// Step 2: the delivered half must not end inside an open frame.
if (!scanSynchronizedOutput(data.slice(0, candidate), markerTail, wasActive).active) {
return candidate > 0 ? candidate : limit
}
// Fall back to the last frame close that ENDS at or before the candidate.
const lastClose = data.lastIndexOf(
SYNCHRONIZED_OUTPUT_END_SEQUENCE,
Math.max(0, candidate - SYNCHRONIZED_OUTPUT_END_SEQUENCE.length)
)
if (lastClose === -1) {
// One frame is longer than the window; deliver the window and let the
// reset profiles release the latch.
return candidate > 0 ? candidate : limit
}
const aligned = lastClose + SYNCHRONIZED_OUTPUT_END_SEQUENCE.length
// Why the floor: a caller that cannot refill the shortfall in the same round
// (main's flush re-queues the remainder with eligibleRound = round + 1) would
// lose up to half its per-PTY throughput when frames land just past the
// midpoint. Below the floor, prefer throughput and let the reset profiles
// release the latch.
return aligned * 2 >= limit ? aligned : candidate > 0 ? candidate : limit
}