fix(terminal): prevent resize from bricking the parser mid inline-image decode

Merged
KI
4mo ago
+337-107

Problem

Terminals were crashing with:

Error: improper continuation due to previous async handler, giving up parsing
    at ur.parse (addon-webgl…)        ← EscapeSequenceParser.parse
    at _r.flushSync (…)                ← WriteBuffer.flushSync
    at vr.resize (…)                   ← CoreTerminal.resize
    at l.fit (terminal-parking…)       ← FitAddon.fit
    at measureAndResize (terminal-runtime…)
    at updateRuntimeAppearance (…)
    at TerminalRuntimeRegistryImpl.updateAppearance (…)
    at commitHookEffectListMount (react…)

This isn't a recoverable error: when it throws, xterm sets the parser to FAIL, so every subsequent write throws too — the terminal is bricked until reload.

Root cause

@xterm/addon-image registers async parser handlers for inline images. iTerm2 (OSC 1337) and Kitty (APC) decode via createImageBitmap(...).then(...), which keeps the escape-sequence parser paused while the bitmap decodes. (SIXEL's handler is synchronous and never triggers this.)

During that paused window, any resize → FitAddon.fit() → terminal.resize(), and xterm's CoreTerminal.resize() unconditionally calls WriteBuffer.flushSync(), which re-enters parser.parse() synchronously. The parser detects an improper continuation and throws + permanently fails. flushSync is documented as "unreliable with async parser handlers."

The reported trace is the mount path: right after createRuntime replays the serialized scrollback (which can contain an inline image), the mount effect fires updateAppearance → fit while that image is still decoding.

Fix

Never resize while a write is still being parsed.

  • parser-idle-gate.ts (new, xterm-free pure logic): wrapWrite decorates the terminal's write to count in-flight writes — xterm fires a write's callback only after that chunk and its async handlers fully parse, so pending === 0 means the parser is back in GROUND. runWhenParserIdle runs the resize immediately when nothing is pending, or parks it (latest-wins) and flushes from the settling write's callback on a microtask, so the eventual flushSync never runs while a handler is paused — nor re-entrantly inside xterm's own write loop.
  • terminal-runtime.ts: install the wrap once in createRuntime (before any write), so every write — live firehose, buffer restore, status notices — is counted and nothing can bypass it. measureAndResize gates the fit on the runtime's gate.
  • terminal-runtime-registry.ts: drop the now-redundant post-fit dimension check (dimensions can settle asynchronously; the onResize callback covers it).
  • v1 terminal (helpers.ts, v1-terminal-cache.ts, useTerminalLifecycle.ts): same gating for the legacy terminal, which loads the same ImageAddon — wrap xterm.write at creation, route every fit (attach, ResizeObserver, font-settle, appearance, post-attach backend sync) through the gate, and cancel parked work on detach/dispose. updateAppearance now reports dimension changes via callback instead of a synchronous return, since a gated fit can run after the call returns.

Full inline-image support (iTerm2/Kitty/SIXEL) is retained.

Testing

  • parser-idle-gate.test.ts: runs-when-idle, defers-until-drain, runs-once, coalesces, preserves caller callback.
  • Mutation-checked: forcing a synchronous resize (the bricking behavior) fails the defer/coalesce tests.
  • bun run typecheck and Biome clean.

Open in Stage

Summary by cubic

Prevents terminal crashes when a resize lands during inline image decoding by deferring fits until the parser is idle. This removes “improper continuation” errors and keeps inline images (iTerm2/Kitty/SIXEL) working.

  • Bug Fixes
    • Gate resizes on parser idleness and cancel parked work on detach/dispose; wrap terminal.write to count in‑flight parses from @xterm/addon-image and defer fit() until write callbacks unwind (latest‑wins, microtask flush, re‑park when a new write arrives before the flush).
    • Route all fits through the gate in measureAndResize and the v1 cache; only call onResize when size changes, preserve scroll position; remove redundant post‑fit dimension check in the registry and the extra host‑visibility guard before measureAndResize; drop dead v1 lastCols/lastRows; font‑settle refits now report via the gated callback.
    • Add unit tests for the gate (runs‑when‑idle, defers, coalesces, cancels, preserves callbacks, keeps gate closed until callback unwinds, re‑parks on drain→microtask races).

Summary by CodeRabbit

  • New Features
    • Improved terminal resizing and refitting by deferring fit/refresh and coordinating it with terminal write activity until the terminal parser is idle.
    • Coalesces multiple idle-gated resize requests so only the latest queued action runs, including during rapid updates.
  • Bug Fixes
    • Prevents resizing/fit during in-flight terminal writes (including nested write scenarios) to reduce flicker and inconsistent updates.
    • Ensures callbacks for resize notifications fire only when dimensions actually change, and pending work is cancelled on detach/dispose.
  • Tests
    • Added automated coverage for idle-gated execution, coalescing, cancellation, and write-drain timing.