diff --git a/src/main/daemon/terminal-shell-lifecycle-scanner-app-zone.test.ts b/src/main/daemon/terminal-shell-lifecycle-scanner-app-zone.test.ts new file mode 100644 index 00000000000..391832e9f9f --- /dev/null +++ b/src/main/daemon/terminal-shell-lifecycle-scanner-app-zone.test.ts @@ -0,0 +1,89 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { TerminalShellLifecycleScanner } from './terminal-shell-lifecycle-scanner' +import { TerminalShellRecoveryBarrier } from './terminal-shell-recovery-barrier' + +// Real OMP 18.10.3 PTY bytes for one prompt: each user bubble ends with `B C D;0` (#10923). +const OMP_TRANSCRIPT = readFileSync( + join(__dirname, '../runtime/__fixtures__/omp-user-bubble-osc133-zone.txt'), + 'utf8' +) +const A = '\x1b]133;A\x07' +const B = '\x1b]133;B\x07' +const C = '\x1b]133;C\x07' +const D0 = '\x1b]133;D;0\x07' +// The shell's prompt and the typed command that launched OMP. +const SHELL_LAUNCH = `${A}$ ${B}omp\r\n${C}` +// OMP arms modifyOtherKeys, an input mode a command must disarm before its `D`. +const ARM_MODIFY_OTHER_KEYS = '\x1b[>4;2m' + +function triggers(chunks: string[]): number[] { + const scanner = new TerminalShellLifecycleScanner() + const ends: number[] = [] + let offset = 0 + for (const chunk of chunks) { + let rest = chunk + let restOffset = offset + while (rest.length > 0) { + const events = scanner.scan(rest) + if (events.uncleanDeathTriggerEnd === undefined) { + break + } + ends.push(restOffset + events.uncleanDeathTriggerEnd) + restOffset += events.uncleanDeathTriggerEnd + rest = rest.slice(events.uncleanDeathTriggerEnd) + } + offset += chunk.length + } + return ends +} + +function splitEvery(text: string, size: number): string[] { + const chunks: string[] = [] + for (let index = 0; index < text.length; index += size) { + chunks.push(text.slice(index, index + size)) + } + return chunks +} + +describe('an OSC 133 zone a running app closes for itself', () => { + it.each([1, 3, 7, 64, SHELL_LAUNCH.length + OMP_TRANSCRIPT.length])( + "does not read OMP's bubble markers as its death, in %i-char chunks", + (size) => { + expect(triggers(splitEvery(SHELL_LAUNCH + OMP_TRANSCRIPT, size))).toEqual([]) + } + ) + + it('still repairs after the app dies with its input mode armed', () => { + const stream = `${SHELL_LAUNCH}${ARM_MODIFY_OTHER_KEYS}${OMP_TRANSCRIPT}Killed\r\n${D0}` + expect(triggers([stream])).toEqual([stream.length]) + }) + + it.each([ + ['output separates the C from the D', `${B}${C}x${D0}`], + ['the typed command separates the B from the C', `${B}ls\r\n${C}${D0}`], + ['the D reports a failure', `${B}${C}\x1b]133;D;1\x07`] + ])('still reads a D as the end of the command when %s', (_label, zone) => { + const stream = `${SHELL_LAUNCH}${ARM_MODIFY_OTHER_KEYS}${zone}` + expect(triggers([stream])).toEqual([stream.length]) + }) + + it('passes OMP output through the recovery barrier without holding it for a process probe', () => { + const released: string[] = [] + const confirmShellForeground = vi.fn(async () => false) + const barrier = new TerminalShellRecoveryBarrier({ + confirmShellForeground, + release: (emission) => released.push(emission.data), + isAlive: () => true + }) + let seq = 0 + for (const data of splitEvery(SHELL_LAUNCH + OMP_TRANSCRIPT, 4096)) { + barrier.accept({ data, rawStartSeq: seq, rawEndSeq: seq + data.length, transformed: false }) + seq += data.length + } + expect(confirmShellForeground).not.toHaveBeenCalled() + expect(released.join('')).toBe(SHELL_LAUNCH + OMP_TRANSCRIPT) + barrier.dispose() + }) +}) diff --git a/src/main/daemon/terminal-shell-lifecycle-scanner.ts b/src/main/daemon/terminal-shell-lifecycle-scanner.ts index 6874d7b1161..a71ce8e1c87 100644 --- a/src/main/daemon/terminal-shell-lifecycle-scanner.ts +++ b/src/main/daemon/terminal-shell-lifecycle-scanner.ts @@ -1,4 +1,5 @@ import { buildProcessBoundaryGround } from '../../shared/terminal-mode-reset-profiles' +import { closesAppOsc133Zone } from '../../shared/terminal-osc133-command-finished' import type { TerminalOwner } from '../../shared/terminal-owner' import { TerminalArmedInputModes } from './terminal-armed-input-modes' @@ -60,6 +61,8 @@ export class TerminalShellLifecycleScanner { private generationState = 0 private altActive = false private commandEnteredAlternateScreen = false + // OSC 133 markers seen back to back since the last other output. + private adjacentMarkers = '' private readonly inputModes = new TerminalArmedInputModes() get owner(): TerminalOwner | undefined { @@ -109,6 +112,9 @@ export class TerminalShellLifecycleScanner { // with no escape introducer must not pay the regex pass. Split sequences // stay correct because a partial one always left a non-empty scanTail. if (this.scanTail.length === 0 && !chunk.includes('\x1b') && !chunk.includes('\x9b')) { + if (chunk.length > 0) { + this.adjacentMarkers = '' + } return events } const previousTailLength = this.scanTail.length @@ -117,8 +123,13 @@ export class TerminalShellLifecycleScanner { // The shared regex is safe because scan() is synchronous and never re-enters. SEQUENCE_RE.lastIndex = 0 let match: RegExpExecArray | null + let outputEnd = 0 while ((match = SEQUENCE_RE.exec(input)) !== null) { const oscPayload = match[1] + const markersBefore = match.index > outputEnd ? '' : this.adjacentMarkers + outputEnd = match.index + match[0].length + this.adjacentMarkers = + oscPayload === undefined ? '' : (markersBefore + oscPayload.slice(0, 1)).slice(-2) if (match[0] === '\x1bc') { this.revoke() this.inputModes.reset() @@ -137,7 +148,8 @@ export class TerminalShellLifecycleScanner { if (marker === 'A') { this.inputModes.markPrompt() } - if (marker !== 'D') { + // Why: an app closing its own zone is still running, so its `D` proves no exit (#10923). + if (marker !== 'D' || closesAppOsc133Zone(markersBefore, oscPayload)) { continue } // An alternate screen or a command's input mode still up at command-finished @@ -208,6 +220,9 @@ export class TerminalShellLifecycleScanner { } } } + if (input.length - this.scanTail.length > outputEnd) { + this.adjacentMarkers = '' + } return events } diff --git a/src/shared/terminal-osc133-command-finished.ts b/src/shared/terminal-osc133-command-finished.ts index ab3d5a25055..bfa816a6cd9 100644 --- a/src/shared/terminal-osc133-command-finished.ts +++ b/src/shared/terminal-osc133-command-finished.ts @@ -50,6 +50,16 @@ function findPrefixCarry(data: string): string { return '' } +/** + * TUIs such as OMP end each prompt bubble with `B C D;0`, closing their own zone while they keep + * running (#10923). A shell's `C` follows the typed command line, never its own prompt's `B` + * directly. `adjacentMarkers` holds the markers seen back to back before this one, e.g. 'BC'. + */ +export function closesAppOsc133Zone(adjacentMarkers: string, payload: string): boolean { + const [sequence, exitCode] = payload.split(';') + return sequence === 'D' && exitCode === '0' && adjacentMarkers.endsWith('BC') +} + export type Osc133CommandFinishedScanner = { /** Feed one raw PTY chunk; fires once per complete OSC 133;D sequence. */ scan: (data: string) => void @@ -76,10 +86,8 @@ export function createOsc133CommandFinishedScanner( const handleOsc133 = (payload: string, endInChunk: number): void => { const [sequence, exitCode] = payload.split(';') - // Why: TUIs such as OMP end each prompt bubble with `B C D;0`, closing their own zone while - // they keep running (#10923). Read as the command's exit, that `D` retired a live agent's - // status. A shell's `C` follows the typed command line, never its own prompt's `B` directly. - const closesAppZone = sequence === 'D' && exitCode === '0' && adjacentMarkers.endsWith('BC') + // Why: read as the command's exit, that `D` retired a live agent's status. + const closesAppZone = closesAppOsc133Zone(adjacentMarkers, payload) adjacentMarkers = (adjacentMarkers + sequence.slice(0, 1)).slice(-2) if (closesAppZone) { return