fix(term): stop daemon emulator from replying to xterm queries (#1024)

This commit is contained in:
Kelvin Amoaba 2026-04-24 21:05:42 +00:00 committed by GitHub
parent 5315e87a81
commit 0a9187909d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 37 additions and 30 deletions

View File

@ -138,22 +138,6 @@ describe('HeadlessEmulator', () => {
})
})
describe('onData callback', () => {
it('fires onData for terminal query responses', async () => {
const responses: string[] = []
emulator = new HeadlessEmulator({
cols: 80,
rows: 24,
onData: (data) => responses.push(data)
})
// DA1 query — xterm.js will respond with a device attributes string
await emulator.write('\x1b[c')
expect(responses.length).toBeGreaterThan(0)
})
})
describe('terminal modes', () => {
it('tracks bracketed paste mode', async () => {
emulator = new HeadlessEmulator({ cols: 80, rows: 24 })

View File

@ -7,7 +7,6 @@ export type HeadlessEmulatorOptions = {
cols: number
rows: number
scrollback?: number
onData?: (data: string) => void
}
const DEFAULT_SCROLLBACK = 5000
@ -57,9 +56,17 @@ export class HeadlessEmulator {
this.serializer = new SerializeAddon()
this.terminal.loadAddon(this.serializer)
if (opts.onData) {
this.terminal.onData(opts.onData)
}
// Why no onData wiring: this emulator exists purely for state tracking
// (snapshots, cwd, mode flags). It MUST NOT respond to terminal query
// sequences (DA1/DA2, DSR, OSC 10/11/12, DECRPM). The emulator parses
// data in-process synchronously before `handleSubprocessData` forwards
// it to the renderer over IPC, so any reply it emits would land on the
// shell's stdin ahead of the renderer's xterm reply and win the race.
// The renderer is the authoritative responder (it has the real theme,
// cursor position, and paste mode); a daemon-side reply would be a
// double-reply with wrong values. OSC 11 was the visible casualty:
// Claude Code's /theme auto always saw the emulator's default-black
// background regardless of Orca's configured terminal theme.
}
write(data: string): Promise<void> {

View File

@ -156,6 +156,27 @@ describe('Session', () => {
})
})
describe('emulator does not reply to terminal queries', () => {
// Why: daemon emulator parses in-process synchronously — before
// handleSubprocessData forwards bytes to the renderer over IPC — so any
// auto-reply it emits races ahead of the renderer's xterm and clobbers
// it with default-xterm values (no theme, stale cursor). The renderer is
// the authoritative responder; a daemon-side reply to any query is a bug.
it.each([
['OSC 11 background-color', '\x1b]11;?\x07'],
['DA1 device-attributes', '\x1b[c'],
['DSR cursor-position', '\x1b[6n']
])('does not reply to %s query', async (_label, query) => {
createSession({ shellReadySupported: false })
subprocess.simulateData(query)
// xterm.js fires terminal.write's completion callback via a microtask;
// two resolved-promise awaits flush any nested scheduling.
await Promise.resolve()
await Promise.resolve()
expect(subprocess.written).toEqual([])
})
})
describe('shell readiness gating', () => {
it('buffers writes during pending state', () => {
createSession({ shellReadySupported: true })

View File

@ -54,16 +54,11 @@ export class Session {
this.emulator = new HeadlessEmulator({
cols: size.cols,
rows: size.rows,
scrollback: opts.scrollback,
// Why: xterm.js generates query responses (DA1, DSR) asynchronously.
// If the subprocess has already exited, writing to it would hit a dead
// NAPI handle. Check session state before forwarding.
onData: (data) => {
if (this._state === 'exited' || this._disposed) {
return
}
opts.subprocess.write(data)
}
scrollback: opts.scrollback
// No onData wiring: the daemon-side emulator must never reply to
// terminal query sequences. The renderer's xterm is the authoritative
// responder; any daemon reply races ahead via in-process parsing and
// clobbers the renderer's answer. See the comment in HeadlessEmulator.
})
if (opts.shellReadySupported) {