Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 13 additions & 30 deletions web/src/components/terminal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => <Terminal sessionId="s1" createEmulator={e.create} />)

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) => <Terminal sessionId="s1" createEmulator={e.create} />)

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) => <Terminal sessionId="s1" createEmulator={e.create} />)

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', () => {
Expand Down
27 changes: 6 additions & 21 deletions web/src/components/terminal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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()
}
})
}),
)
Expand Down
45 changes: 0 additions & 45 deletions web/src/emulator/emulator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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', () => {
Expand Down
28 changes: 0 additions & 28 deletions web/src/emulator/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
28 changes: 0 additions & 28 deletions web/src/emulator/xterm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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
Expand Down
20 changes: 0 additions & 20 deletions web/src/testing/emulator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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[] = []
Expand All @@ -83,7 +72,6 @@ export function createFakeEmulator(opts: FakeEmulatorOptions = {}): FakeEmulator
written,
themes,
queryAnswers,
reportingStops,
wordPresses,
extensions,
pasted,
Expand All @@ -99,7 +87,6 @@ export function createFakeEmulator(opts: FakeEmulatorOptions = {}): FakeEmulator
measured: null,
onGlass: null,
appCursor: false,
pointerReports: false,
detector: null,

text: () => written.join(''),
Expand Down Expand Up @@ -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
},
Expand Down