From 51496dce45dd85a0d001abe6e632b60aa12056ee Mon Sep 17 00:00:00 2001 From: Karn Date: Wed, 19 Aug 2026 17:37:18 +0530 Subject: [PATCH] A reattach stops disarming the modes a live program still holds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every backlog replay ended with stopReporting(), which writes the mouse-tracking and focus-reporting resets into the local emulator. That was aimed at revived sessions, whose dead shell never wrote its own reset — but it fired on every reattach, including one to a live session whose program legitimately holds those modes armed. With Claude Code's renderer that combination is poisonous: it holds the alternate screen plus mouse tracking, and an emulator sitting on the alt buffer with tracking cleared is exactly the state xterm.js answers by turning every wheel tick into arrow keys at the program's stdin. Leave a session, come back, reattach replays, the clear lands — and scrolling is broken until the program's next full redraw happens to re-assert its modes, minutes of arrow-key noise later. Verified against a live ring: the replayed tail held ?1049h and ?1000-1006h with re-assertions hundreds of kilobytes apart. A live session's replay ends at the program's present state, so the right amount of client-side settling is none. The revived case never needed the client's help: the daemon ends every revive preload with settleModes, so the dead shell's reset is already in the replayed bytes. stopReporting and reportsPointer leave the emulator seam with their only caller — reportsPointer existed only to test the reset. Co-Authored-By: Claude Fable 5 --- web/src/components/terminal.test.tsx | 43 ++++++++------------------ web/src/components/terminal.tsx | 27 ++++------------- web/src/emulator/emulator.test.ts | 45 ---------------------------- web/src/emulator/types.ts | 28 ----------------- web/src/emulator/xterm.ts | 28 ----------------- web/src/testing/emulator.ts | 20 ------------- 6 files changed, 19 insertions(+), 172 deletions(-) diff --git a/web/src/components/terminal.test.tsx b/web/src/components/terminal.test.tsx index f48815d..a8a9b53 100644 --- a/web/src/components/terminal.test.tsx +++ b/web/src/components/terminal.test.tsx @@ -186,41 +186,24 @@ describe('Terminal', () => { expect(em.live().text()).toBe('\x1bcfresh') }) - it('stops reporting the pointer once the replayed backlog has drained', () => { - // The bug: a daemon restart replays a snapshot's scrollback, and that - // scrollback carries the mouse-tracking sequence of a program that was - // killed with the daemon and so never wrote its own reset. The emulator - // ends the replay armed, with a brand new shell behind it, and every - // pointer move over the terminal is an SGR report typed at the prompt. + it('leaves a live program its modes once the replayed backlog has drained', () => { + // A replay ends at the live program's present state: mouse tracking in + // the backlog of a running session is not the orphan of a dead shell, it + // is what the program on the other end believes is armed right now. + // Clearing it here desynced this emulator from that program — with + // claude's fullscreen renderer (alternate screen plus tracking) the + // cleared emulator turned every wheel tick into arrow keys at the + // program's stdin until its next full redraw. A revived session needs no + // clearing from this side either: the daemon ends every revive preload + // with settleModes, so a dead shell's reset is already in these bytes. const { sock, em } = mountTerminal((e) => ) act(() => sock.emitControl(attached({ ref: 1, id: 's1', seq: 0, head: 8 }))) - act(() => sock.emitOutput(1, 'backlog!')) - - // After the backlog, never before it: clearing the modes first would be - // undone by the very bytes that set them. - expect(em.live().reportingStops).toEqual([1]) - }) - - it('leaves the modes alone until the whole backlog is in', () => { - const { sock, em } = mountTerminal((e) => ) - - act(() => sock.emitControl(attached({ ref: 1, id: 's1', seq: 0, head: 12 }))) - act(() => sock.emitOutput(1, 'part')) - - expect(em.live().reportingStops).toEqual([]) - }) - - it('says nothing about the modes when there is no backlog to replay', () => { - // A freshly spawned session has head === seq. Nothing was replayed, so - // there is no stale state to answer for, and a program that armed - // tracking on its first line must keep it. - const { sock, em } = mountTerminal((e) => ) - - act(() => sock.emitControl(attached({ ref: 1, id: 's1', seq: 0 }))) act(() => sock.emitOutput(1, '\x1b[?1003h')) - expect(em.live().reportingStops).toEqual([]) + // The backlog and nothing after it: any settle sequence appended here + // would be this client overruling a program that is still running. + expect(em.live().text()).toBe('\x1b[?1003h') }) it('does not reset when the attach is an ordinary continuation', () => { diff --git a/web/src/components/terminal.tsx b/web/src/components/terminal.tsx index d58ab55..cfb3e48 100644 --- a/web/src/components/terminal.tsx +++ b/web/src/components/terminal.tsx @@ -389,19 +389,12 @@ export function Terminal({ // the shell's stdin. head === seq on a fresh spawn opens it immediately. let consumed = 0 let muteUntil = 0 - // Whether a replayed backlog is still arriving under this attachment. - // - // The gate above keeps the emulator's *answers* off the wire while the - // scrollback replays. This is the other half of the same problem: a - // replay also re-runs every mode change in that scrollback, and the - // modes outlive it. A shell killed with the daemon inside a - // mouse-tracking program wrote the sequence that turned tracking on and - // never the one that turns it off, so replaying its snapshot leaves this - // emulator reporting the pointer at a fresh prompt — see stopReporting. - // Turned off the moment the backlog has been consumed, which is where - // the reset goes; false already on a fresh spawn, whose head === seq - // means there is nothing replayed to answer for. - let replaying = false + // The modes a replay re-runs — mouse tracking, focus reporting — are + // deliberately left exactly where the backlog puts them. A live + // session's replay ends at the program's present state, so clearing + // anything here desyncs this emulator from a program that still holds + // those modes armed; and a revived session's preload already ends with + // the daemon's settleModes, so a dead shell's reset is in the bytes. // The attachment's epoch, stepped with every reseed. Each done callback // below closes over the value it was written under: one enqueued under a // previous attachment can fire after the reseed, and its bytes are @@ -878,7 +871,6 @@ export function Terminal({ epoch++ consumed = a.seq muteUntil = a.head - replaying = a.head > a.seq if (a.truncated) emulator.write(RESET) emulator.resize(a.cols, a.rows) tabOsc = a.title @@ -897,13 +889,6 @@ export function Terminal({ emulator.write(bytes, () => { if (e !== epoch) return consumed += bytes.length - // In the done callback and not at frame arrival, for the same - // reason the gate is: this has to land after the parser has read - // the backlog, or the modes it clears are set again behind it. - if (replaying && consumed >= muteUntil) { - replaying = false - emulator.stopReporting() - } }) }), ) diff --git a/web/src/emulator/emulator.test.ts b/web/src/emulator/emulator.test.ts index 48bcb9a..79ae936 100644 --- a/web/src/emulator/emulator.test.ts +++ b/web/src/emulator/emulator.test.ts @@ -241,49 +241,6 @@ describe('Emulator interface', () => { }) }) - it('stops reporting the pointer a replayed program had asked for', async () => { - // The bug this is the floor for: a snapshot's scrollback carries the - // mouse-tracking sequence of a program that died with the daemon, so - // replaying it arms an emulator sitting in front of a fresh shell. From - // there every mouse move is an SGR report typed at the prompt. - const el = document.createElement('div') - document.body.appendChild(el) - const em = createXtermEmulator({ cols: 10, rows: 4 }) - em.attachTo(el) - const seen: string[] = [] - em.onData((b) => seen.push(new TextDecoder().decode(b))) - await settled(em, '\x1b[?1003h\x1b[?1006h\x1b[?1004h') - - expect(em.reportsPointer()).toBe(true) - // Everything the arming itself put on the wire is the bug, not the fix — - // an unfocused terminal answers ESC[?1004h with a focus-out report right - // away, which is exactly the kind of typing-with-nobody-there this - // clears. What matters below is that the clearing adds none of its own. - seen.length = 0 - em.stopReporting() - await settled(em, '') - - expect(em.reportsPointer()).toBe(false) - expect(seen.join('')).toBe('') - em.dispose() - el.remove() - }) - - it('leaves a live program its pointer reporting', async () => { - // stopReporting is aimed at a replay, never at output. A program that - // turns tracking on after the backlog has drained keeps it. - const el = document.createElement('div') - document.body.appendChild(el) - const em = createXtermEmulator({ cols: 10, rows: 4 }) - em.attachTo(el) - em.stopReporting() - await settled(em, '\x1b[?1002h') - - expect(em.reportsPointer()).toBe(true) - em.dispose() - el.remove() - }) - describe('alt-screen scrolling', () => { // Fullscreen TUIs — claude's fullscreen mode, vim, less — live on the // alternate buffer, which keeps no scrollback, so the viewport scroll @@ -442,12 +399,10 @@ describe('Emulator interface', () => { em.dispose() expect(() => em.focus()).not.toThrow() - expect(() => em.stopReporting()).not.toThrow() expect(() => em.setTheme({ background: '#000000' })).not.toThrow() expect(em.contentSize()).toBeNull() expect(() => em.answerQueries(true)).not.toThrow() expect(em.applicationCursorKeys()).toBe(false) - expect(em.reportsPointer()).toBe(false) }) it('attaches to an element with no WebGL context available', () => { diff --git a/web/src/emulator/types.ts b/web/src/emulator/types.ts index b4dbc42..fe40322 100644 --- a/web/src/emulator/types.ts +++ b/web/src/emulator/types.ts @@ -211,34 +211,6 @@ export interface Emulator { * run rather than land in the line editor. */ paste(text: string): void - /** - * Forget the reporting modes a replayed backlog turned on. - * - * A session's scrollback is bytes, not state, so replaying it re-runs - * every mode change the shell ever wrote — including the ones belonging to - * a program that has since exited or been killed with the daemon. Mouse - * tracking and focus reporting are the two that matter, because they are - * the only modes that put bytes on the wire with nobody typing: an armed - * emulator sends an SGR report for every pointer move, and the shell - * behind it receives that as somebody typing "35;61;22M" at the prompt. - * - * Only those two, and deliberately. Application cursor keys and bracketed - * paste are also replayable and also stale, but they change what a - * keystroke means rather than inventing keystrokes, so clearing them - * against a live program would break arrows and pastes in a client that - * had nothing wrong with it. See settleModes in internal/session for the - * wider reset, which runs where there is no live program to break. - * - * Local to this emulator. Nothing reaches the shell. - */ - stopReporting(): void - /** - * Whether this emulator would report pointer movement to the program. - * - * Exists so the reset above can be tested for what it does rather than for - * the bytes it writes. - */ - reportsPointer(): boolean /** * Mount into the DOM. * diff --git a/web/src/emulator/xterm.ts b/web/src/emulator/xterm.ts index c7213ec..07c6d19 100644 --- a/web/src/emulator/xterm.ts +++ b/web/src/emulator/xterm.ts @@ -52,19 +52,6 @@ export const TERMINAL_FONT_FAMILY = */ export const NEWLINE_CHORD_BYTES = '\x1b\r' -/** - * Every mouse protocol off, every mouse encoding off, focus reporting off. - * - * The set is exhaustive on purpose. The protocols (1000 press-only, 1002 - * drag, 1003 any motion) and the encodings (1005 UTF-8, 1006 SGR, 1015 - * urxvt, 1016 SGR-pixels) are separate switches in the terminal, and a - * program may have set any combination of them; clearing the protocol a - * particular program happened to use is how this fix would work on one - * machine and not the next. - */ -const STOP_REPORTING = - '\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1005l\x1b[?1006l\x1b[?1015l\x1b[?1016l\x1b[?1004l' - /** * xterm.js behind the Emulator seam. * @@ -364,21 +351,6 @@ export function createXtermEmulator(opts: XtermOptions = {}): Emulator { term.paste(text) }, - stopReporting() { - if (disposed) return - // Written as output rather than set on xterm's services, because the - // parser is the only supported way in and because it keeps the ordering - // honest: this lands in the stream where the caller put it, so live - // output arriving after it is applied after it. A program that turns - // tracking back on a moment later still gets tracking. - term.write(STOP_REPORTING) - }, - - reportsPointer() { - if (disposed) return false - return term.modes.mouseTrackingMode !== 'none' - }, - attachTo(el: HTMLElement) { term.open(el) // Best-effort GPU rendering; the DOM renderer is a fine fallback, and diff --git a/web/src/testing/emulator.ts b/web/src/testing/emulator.ts index 1c893de..41c3452 100644 --- a/web/src/testing/emulator.ts +++ b/web/src/testing/emulator.ts @@ -29,16 +29,6 @@ export interface FakeEmulator extends Emulator { appCursor: boolean /** Simulate the user typing. */ send(text: string): void - /** - * Where each stopReporting() call landed, as a count of written chunks. - * - * A count rather than a flag because the ordering against the output - * stream is the property worth testing: clearing the modes before a - * replayed backlog has been written would be undone by the backlog. - */ - readonly reportingStops: number[] - /** What reportsPointer() answers; set by hand like measured. */ - pointerReports: boolean /** Every selectWordAt() cell, in order. */ readonly wordPresses: Cell[] /** Every extendSelectionTo() cell, in order. */ @@ -74,7 +64,6 @@ export function createFakeEmulator(opts: FakeEmulatorOptions = {}): FakeEmulator const written: string[] = [] const themes: TerminalTheme[] = [] const queryAnswers: boolean[] = [] - const reportingStops: number[] = [] const wordPresses: Cell[] = [] const extensions: Cell[] = [] const pasted: string[] = [] @@ -83,7 +72,6 @@ export function createFakeEmulator(opts: FakeEmulatorOptions = {}): FakeEmulator written, themes, queryAnswers, - reportingStops, wordPresses, extensions, pasted, @@ -99,7 +87,6 @@ export function createFakeEmulator(opts: FakeEmulatorOptions = {}): FakeEmulator measured: null, onGlass: null, appCursor: false, - pointerReports: false, detector: null, text: () => written.join(''), @@ -156,13 +143,6 @@ export function createFakeEmulator(opts: FakeEmulatorOptions = {}): FakeEmulator self.send(text) }, - stopReporting() { - reportingStops.push(written.length) - mutable(self).pointerReports = false - }, - - reportsPointer: () => self.pointerReports, - attachTo(el: HTMLElement) { mutable(self).mountedOn = el },