mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 00:03:46 +00:00
* 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 commit0f7ec4458d. * 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 behind0f7ec4458d(reverted in8d5eacecb4). `_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>
175 lines
6.5 KiB
JavaScript
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)
|