Files
orca/tests/e2e/fixtures/opencode-altscreen-live-fixture.cjs
T
NeilandOrca 97cb32c1cc fix(terminal): release an abandoned synchronized-output frame on reveal (STA-2694) (#10907)
* fix(terminal): release an abandoned synchronized-output frame on reveal

Alt-screen agent TUIs (OpenCode/OpenTUI, Codex, grok) bracket every repaint
in `?2026h … ?2026l`. Hiding a pane mid-bracket — which a worktree switch or
cold-park lands on routinely, since these brackets are written many times a
second — leaves xterm's `decPrivateModes.synchronizedOutput` latched.

RenderService.refreshRows checks that latch *before* rendering, so while it
holds, every repaint Orca owns is a no-op: the forced render-pause repaint,
the plain `refresh()` fallback, and the shared glyph-atlas rebuild all render
zero rows while the xterm buffer is perfectly correct. Release the latch at
the two reveal repaint entry points so those repaints actually paint.

Also adds an OpenCode-shaped alt-screen e2e fixture and spec. The existing
inline-TUI convergence spec covers the normal-buffer shape (live block glued
to the bottom, history scrolling into scrollback); this covers the
full-screen alternate-buffer shape, where nothing scrolls and so no row ever
self-heals through the scroll path.

Scope note: xterm arms a 1s watchdog that clears this latch on its own, so
this closes a bounded window rather than the whole STA-2694 report. The e2e
spec passes with and without the production change for that reason; the unit
tests are what pin the behavior. Refs STA-2694.

* fix(terminal): clear the render model on the plain-refocus repaint path

`schedulePaneRevealPresent` — the atlas-preserving path a plain window
refocus takes — only called `terminal.refresh()`. xterm's renderers are
diff-based: `_updateModel` early-continues on any cell whose code/fg/bg/ext
still match the cached model, so a refresh repaints nothing for a pane whose
buffer never changed. When an occluded window loses its canvas contents while
that model stays populated, the refresh skips exactly the cells that went
stale and the pane keeps compositing pre-hide pixels — until a window resize
reallocates the model, which is the repair users find by hand.

Clear the model first (`RenderService.clear()` → renderer `clear()` →
`_clearModel(true)`) so the refresh becomes a guaranteed full repaint. That
drops cached cells and glyph vertices but NOT the texture atlas, which is
shared by every same-config terminal and whose mid-stream wipe re-arms xterm's
page-merge garble race (xterm.js #4480) — the reason this path is
atlas-preserving in the first place.

Also covers the DOM-renderer fallback in `resetWebglTextureAtlas`:
`clearTextureAtlas()` is what invalidated the model on the WebGL path, so a
pane without an addon had nothing invalidate it and hit the same skip.

Scope note: the e2e spec guards buffer/geometry convergence across the
hide/reveal boundaries and adds idle-agent and headful desktop-hide cases, but
it cannot observe a stale canvas — both oracles built for that (canvas-vs-buffer
ink sampling, screenshot-vs-forced-repaint) were proven blind by injecting the
defect, and the spec header documents why. The unit tests pin the ordering and
the atlas-preservation invariant. Refs STA-2694.

Co-authored-by: Orca <help@stably.ai>

* docs(terminal): hand off the STA-2694 reveal-artifact investigation

Records both fixed defects with their xterm mechanisms, the reveal/wake call
graph, why every e2e oracle for a stale canvas was proven blind, how to arm the
in-app render-desync sentinel on real hardware, and the one unverified lead
(dimension staleness) that would explain why a window resize specifically is
the repair users find. Refs STA-2694.

Co-authored-by: Orca <help@stably.ai>

* Revert "fix(terminal): clear the render model on the plain-refocus repaint path"

This reverts commit 0f7ec4458d.

* test(terminal): add a draw-command oracle for reveal repaints, and correct the STA-2694 scope

Every pixel oracle tried for STA-2694 was blind: `drawImage` on a
non-preserveDrawingBuffer WebGL canvas returns a re-rendered copy, and
Playwright's screenshot drives a fresh compositor frame that heals a stale paint
before capture. Reading pixels is self-defeating here — the read triggers the
repaint that hides the bug.

Count the WebGL draw commands instead, by wrapping GlyphRenderer.updateCell and
gl.drawElementsInstanced on the live pane. A draw command cannot be healed after
the fact, so "did the reveal actually repaint?" becomes directly observable.
Teeth-verified: removing releaseAbandonedSynchronizedOutput from
schedulePaneRevealPresent fails the stranded-latch test.

Two findings, both of which change previously-committed claims:

1. The 1s watchdog does NOT bound the synchronized-output defect. It is armed
   only inside `bufferRows`, and `refreshRows` returns at its `_isPaused` check
   first — so while a pane is occluded nothing reaches `bufferRows` and no timer
   is ever pending. A pane hidden mid-`?2026h` holds the latch with no watchdog
   behind it, indefinitely. ed1eaf55f1's "closes a bounded window" scope note was
   wrong; this is the unbounded garble the report describes, and the fix closes
   it. Corrected in the module doc comment.

2. It refutes the diff-based-staleness hypothesis behind 0f7ec4458d (reverted in
   8d5eacecb4). `_updateModel` does early-continue per unchanged cell, but
   `GlyphRenderer.render` then copies vertices for EVERY row up to
   `lineLengths[y]` and issues ONE full-viewport draw — measured identical
   instance counts (562) for a diff-skipped and a model-cleared refresh, with
   updateCell at 0 vs 561. The DOM renderer likewise replaceChildren()s every
   row unconditionally. Clearing the model could not change what reached the
   screen, and `_clearModel(true)` zeroes every glyph vertex while
   `RenderService.clear()` fires no repaint of its own — so it opened a
   blank-viewport window (also asserted here) for no benefit.

Also keeps the idle-agent and headful desktop-hide cases from the reverted
commit, since those were independent of the refuted production change, and
rewrites the alt-screen spec header to point paint questions at this oracle.
Refs STA-2694.

Co-authored-by: Orca <help@stably.ai>

* docs(terminal): rewrite the STA-2694 handoff after the refutation

Records that the garble window is unbounded (the 1s watchdog never arms for an
occluded pane), that the diff-based-staleness hypothesis was refuted by
measurement and reverted, why pixel oracles are structurally blind here, and the
two leads now closed by measurement (dimension staleness, lazy atlas bindings).
Refs STA-2694.

Co-authored-by: Orca <help@stably.ai>

* test(terminal): capture visual proof of the STA-2694 stale paint

The earlier screenshot oracles were blind because they compared a revealed pane
against a repaired one and both ran the same repaint code. Capturing the defect
directly works instead, because the mechanism is self-preserving: while
synchronizedOutput is latched, refreshRows returns before reaching the renderer,
so a compositor frame just re-composites the existing canvas texture and the
stale pixels survive the screenshot rather than being healed by it.

Latch a frame, write a full new frame the pane cannot paint, and capture. The
screenshot comes back byte-identical to the pre-hide one while the buffer holds
the new frame — the buffer/screen divergence users report — and differs after
the reveal repaint runs. Asserts both halves, so it fails if either the defect
stops reproducing or the fix stops repairing it.

Refs STA-2694.

Co-authored-by: Orca <help@stably.ai>

* test(terminal): note where the xterm gate-order double is pinned for real

The unit double encodes RenderService's paused-then-latch gate order, which can
drift on an xterm upgrade. Point at the e2e oracle that pins the same order
against the real renderer, so a future upgrade has a trail to the authoritative
check. Refs STA-2694.

Co-authored-by: Orca <help@stably.ai>

* test(terminal): add a perf budget for the synchronized-output release

releaseAbandonedSynchronizedOutput runs inside resetWebglTextureAtlas, which a
streaming alt-screen TUI can reach through the terminal-output atlas recovery
path — not only on reveal. Measure rather than assert that this costs nothing.

Steady state (a TUI that closes every frame it opens): 200 bracketed frames
produce zero releases, zero extra draw calls, and an unmeasurable early-out
cost. Worst case (every reveal finds a latched frame): 50 latched atlas resets
at 0.08ms each. Both are asserted with headroom, so the guard catches a future
change that makes this scan the buffer per pane rather than flaking on machine
speed. Refs STA-2694.

Co-authored-by: Orca <help@stably.ai>

* test(terminal): address review — drive real code paths, close vacuity gaps

CodeRabbit caught a genuine tautology in the perf budget: it timed a
hand-copied mirror of the early-out rather than the shipped function, so the
assertion would have held even if the real code grew a buffer scan. Driving
resetWebglTextureAtlases instead moved the measured cost from ~0 to ~0.03ms per
call, which is the honest number for the whole recovery; bound re-set to 0.4ms
(10x measured).

Other review fixes:
- Assert the draw counts both perf tests were measuring and logging but never
  checking, so the 'no extra draws' titles now mean something.
- Fail fast when decPrivateModes is unavailable; previously the latched test
  would pass without ever exercising the fix.
- Re-check the latch right after the worktree switch in the mid-frame test: the
  pane is visible until then, so the 1s watchdog can arm and clear it before the
  hide, making the run vacuous.
- Count scheduleRevealPresent invocations instead of returning a literal true,
  so a missing test hook no longer masquerades as a production failure.
- Assert the latch clears on every reveal iteration, not just the last.
- Make the fixture heartbeat write atomic (tmp + rename); writeFileSync
  truncates first, so a reader could see '' and read it as frame 0.
- Relabel assertRevealPixelsNeedNoRepair as the weak secondary check it is; it
  contradicted the file header by calling itself 'the decisive paint assertion'.

Refs STA-2694.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
2026-07-27 16:32:24 -07:00

175 lines
6.5 KiB
JavaScript

// OpenCode/OpenTUI-shaped ALT-SCREEN TUI: a full-screen frame repainted every
// tick inside synchronized-output brackets, with a wide glyph inventory (box
// drawing, emoji, CJK) so each repaint exercises the shared WebGL glyph atlas.
//
// Why a second TUI fixture: codex-inline-live-block-fixture.cjs models the
// INLINE shape (normal buffer, live block glued to the bottom). OpenCode runs
// full-screen on the alternate buffer and repaints absolutely-positioned rows,
// which is the shape that leaves stale/garbled pixels after a hidden stretch —
// nothing scrolls, so no row ever self-heals through the scroll path.
//
// argv[2] = heartbeat file path (latest frame number, rewritten every tick)
// argv[3] = ticks per second (default 16)
// The stream never stops on its own; tests hide/park/reveal around it.
const fs = require('node:fs')
const heartbeatPath = process.argv[2]
const TICKS_PER_SECOND = Math.max(1, Number(process.argv[3]) || 16)
const TICK_MS = Math.max(1, Math.round(1000 / TICKS_PER_SECOND))
// Why a rotating inventory: a single repeated glyph set stays resident in the
// atlas after one rasterization. Rotating forces fresh glyph uploads on most
// frames, which is what grows atlas pages and drives the page-merge path.
const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
const BADGES = ['🟢', '🟡', '🔵', '🟣', '🟠']
const CJK = ['実行中', '生成中', '待機中', '完了', '解析中']
let frame = 0
function rows() {
return process.stdout.rows || 24
}
function cols() {
return process.stdout.columns || 80
}
function bodyRow(index) {
const spin = SPINNER[(frame + index) % SPINNER.length]
const badge = BADGES[(frame + index) % BADGES.length]
const cjk = CJK[(frame + index) % CJK.length]
const bar = '█'.repeat((frame + index) % 12).padEnd(12, '░')
return (
`│ ${spin} ${badge} row ${String(index).padStart(3, '0')} ${cjk} ${bar} ` +
`tok ${String((frame * 7 + index) % 100000).padStart(6, '0')} λ∑√≈∂ `
)
}
// A full-screen absolute repaint: home, then one absolutely-positioned write
// per row. No scrolling, so a row that paints wrong stays wrong.
function fullFrame() {
const width = Math.max(20, Math.min(cols() - 2, 100))
const height = rows()
const lines = [
`╭─ OPENCODE_FRAME_${String(frame).padStart(6, '0')} ${'─'.repeat(Math.max(0, width - 30))}╮`
]
for (let index = 0; index < Math.max(0, height - 3); index += 1) {
lines.push(`${bodyRow(index).slice(0, width).padEnd(width, ' ')}│`)
}
lines.push(
`╰─ status: streaming · OPENCODE_TAIL_${String(frame).padStart(6, '0')} ${'─'.repeat(Math.max(0, width - 46))}╯`
)
let out = ''
for (const [index, line] of lines.entries()) {
out += `\x1b[${index + 1};1H\x1b[K${line}`
}
return out
}
function tick() {
frame += 1
// Synchronized output around every repaint, exactly like OpenTUI.
process.stdout.write(`\x1b[?2026h\x1b[?25l${fullFrame()}\x1b[?25h\x1b[?2026l`)
if (heartbeatPath) {
try {
// Why write-then-rename: writeFileSync truncates before writing, so a
// concurrent reader can see '' and read it as frame 0.
fs.writeFileSync(`${heartbeatPath}.tmp`, String(frame))
fs.renameSync(`${heartbeatPath}.tmp`, heartbeatPath)
} catch {
// heartbeat is best-effort; the stream itself is the product
}
}
}
// OpenCode-shaped startup: capability queries, alt screen, mouse reporting.
process.stdout.write('\x1b[c\x1b[6n\x1b]10;?\x07\x1b]11;?\x07')
process.stdout.write('\x1b[?1049h\x1b[2J\x1b[H')
process.stdout.write('\x1b[?1002h\x1b[?1006h')
let tickTimer = setInterval(tick, TICK_MS)
let frozen = false
// Freeze: stop producing new frames but STAY on the alt screen holding the last
// painted frame. Tests need this to compare pixels — a live stream would differ
// between two shots for legitimate reasons, and quitting would leave a shell
// prompt. A frozen fixture also stops answering SIGWINCH, so only Orca's own
// render path can repair the display, which is what's under test.
//
// `midFrame` models the STA-2694 hazard directly: the TUI opens a synchronized
// frame (`?2026h`), paints it, and is interrupted before writing the closing
// `?2026l`. That is what a hide lands on many times a second in the field, and
// it leaves xterm's synchronizedOutput mode latched — which makes every reveal
// repaint a no-op until something clears it.
function freeze(options) {
if (frozen) {
return
}
frozen = true
clearInterval(tickTimer)
tickTimer = null
if (options && options.midFrame) {
process.stdout.write(`\x1b[?2026h\x1b[?25l${fullFrame()}`)
}
}
// A real TUI repaints on SIGWINCH. Keep that for realism while live — tests must
// converge WITHOUT relying on it, since needing a resize IS the bug under test.
process.stdout.on('resize', () => {
if (frozen) {
return
}
process.stdout.write(`\x1b[?2026h\x1b[?25l${fullFrame()}\x1b[?25h\x1b[?2026l`)
})
process.stdin.resume()
if (process.stdin.isTTY) {
process.stdin.setRawMode(true)
}
let stopping = false
function stop() {
if (stopping) {
return
}
stopping = true
if (tickTimer) {
clearInterval(tickTimer)
}
if (process.stdin.isTTY) {
process.stdin.setRawMode(false)
}
// Why: the e2e sends Ctrl+C while raw mode is active, so Node receives a byte
// instead of SIGINT; explicitly restore modes and terminate the fixture.
process.stdout.write('\x1b[?1002l\x1b[?1006l\x1b[?25h\x1b[?2026l\x1b[?1049l', () =>
process.exit(0)
)
}
// Why a multi-byte sentinel and not a single key: at startup this fixture emits
// DA1/CPR/OSC-10/OSC-11 queries, and the terminal's replies arrive on stdin —
// an OSC color reply ("rgb:ffff/...") contains 'f', so a single-letter trigger
// froze the fixture against its own startup handshake before frame 1.
const FREEZE_SENTINEL = 'ORCA_FREEZE_NOW'
const FREEZE_MID_FRAME_SENTINEL = 'ORCA_FREEZE_MID_FRAME'
const MAX_SENTINEL_LEN = Math.max(FREEZE_SENTINEL.length, FREEZE_MID_FRAME_SENTINEL.length)
let stdinTail = ''
process.stdin.on('data', (chunk) => {
if (chunk.includes(0x03)) {
stop()
return
}
stdinTail = `${stdinTail}${chunk.toString('latin1')}`.slice(-2 * MAX_SENTINEL_LEN)
// Why check mid-frame first: both share the 'ORCA_FREEZE_' prefix, so testing
// the longer sentinel first keeps a partial read from picking the wrong mode.
if (stdinTail.includes(FREEZE_MID_FRAME_SENTINEL)) {
stdinTail = ''
freeze({ midFrame: true })
return
}
if (stdinTail.includes(FREEZE_SENTINEL)) {
stdinTail = ''
freeze()
}
})
process.on('SIGINT', stop)
process.on('SIGTERM', stop)