fix(terminal): disarm stale TUI modes when a pane confirms return to shell (#9608)
A TUI killed hard (SIGKILL, OOM, crash) never restores the modes it armed. When its parent shell survives, the emulator keeps mouse tracking, focus reporting and Kitty keyboard flags on: every pointer move over the pane lands as typed SGR motion reports at the prompt, and the doomed process burns CPU parsing the motion firehose while it lives. Orca's existing mode cleanups all hang off dead-PTY paths (hibernation kill, daemon reattach), so an agent dying under a live shell crossed none of them. Fire POST_REPLAY_REATTACH_RESET at the pane-foreground-agent tracker's confirmed return-to-shell transition, next to the sibling stale-title cleanup. That transition is gated on a real foreground-process read rather than the bare OSC 133;D, because a full-screen agent's nested command shells leak their own D onto the main PTY. The write goes through the replay guard so xterm's auto replies cannot leak to the shell as input. The reused constant already excludes ?2004l, so the bracketed-paste protection the live shell re-arms at its prompt is preserved.
This commit is contained in:
parent
17fc40eae6
commit
c0734f039d
|
|
@ -5905,6 +5905,88 @@ describe('connectPanePty', () => {
|
|||
expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('alt-enter')
|
||||
})
|
||||
|
||||
it('disarms stale TUI modes in the emulator after a confirmed return to shell', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('bash')
|
||||
const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null }
|
||||
const ptyId = 'pty-stale-mode-disarm'
|
||||
const transport = createMockTransport(ptyId)
|
||||
transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
|
||||
dataCallbackRef.current = callbacks.onData ?? null
|
||||
return { id: ptyId }
|
||||
})
|
||||
transportFactoryQueue.push(transport)
|
||||
const paneKey = makePaneKey('tab-1', LEAF_1)
|
||||
const pane = createPane(1)
|
||||
const { writes } = captureCallbackTerminalWrites(pane)
|
||||
|
||||
connectPanePty(
|
||||
pane as never,
|
||||
createManager(1) as never,
|
||||
createDeps({ isVisibleRef: { current: false } }) as never
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(20)
|
||||
await flushAsyncTicks()
|
||||
mockStoreState.agentLaunchConfigByPaneKey[paneKey] = {
|
||||
launchConfig: { agentArgs: '', agentEnv: {} },
|
||||
identity: { agentType: 'droid' }
|
||||
}
|
||||
|
||||
// A SIGKILLed agent emits no mode teardown; only the shell's next prompt
|
||||
// mark arrives. The bare 133;D must not disarm yet — the foreground read
|
||||
// has not confirmed the agent is gone.
|
||||
dataCallbackRef.current?.('\x1b]133;D;0\x07')
|
||||
expect(writes.some((w) => w.includes(POST_REPLAY_REATTACH_RESET))).toBe(false)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(350)
|
||||
await flushAsyncTicks()
|
||||
|
||||
const disarm = writes.find((w) => w.includes(POST_REPLAY_REATTACH_RESET))
|
||||
expect(disarm).toBeDefined()
|
||||
// The live shell re-arms bracketed paste at its prompt before the disarm
|
||||
// fires; the reset must not strip it.
|
||||
expect(disarm).not.toContain('\x1b[?2004l')
|
||||
})
|
||||
|
||||
it('keeps armed modes while the agent still owns the foreground after a leaked 133;D', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('droid')
|
||||
const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null }
|
||||
const ptyId = 'pty-stale-mode-live-agent'
|
||||
const transport = createMockTransport(ptyId)
|
||||
transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
|
||||
dataCallbackRef.current = callbacks.onData ?? null
|
||||
return { id: ptyId }
|
||||
})
|
||||
transportFactoryQueue.push(transport)
|
||||
const paneKey = makePaneKey('tab-1', LEAF_1)
|
||||
const pane = createPane(1)
|
||||
const { writes } = captureCallbackTerminalWrites(pane)
|
||||
|
||||
connectPanePty(
|
||||
pane as never,
|
||||
createManager(1) as never,
|
||||
createDeps({ isVisibleRef: { current: false } }) as never
|
||||
)
|
||||
await vi.advanceTimersByTimeAsync(20)
|
||||
await flushAsyncTicks()
|
||||
mockStoreState.agentLaunchConfigByPaneKey[paneKey] = {
|
||||
launchConfig: { agentArgs: '', agentEnv: {} },
|
||||
identity: { agentType: 'droid' }
|
||||
}
|
||||
|
||||
// A full-screen agent's nested command shell leaks a 133;D onto the main
|
||||
// PTY; the confirming read republishes the live agent, so its armed
|
||||
// mouse/kitty modes must survive untouched.
|
||||
dataCallbackRef.current?.('\x1b]133;D;0\x07')
|
||||
await vi.advanceTimersByTimeAsync(350 + 1200 + 6000)
|
||||
await flushAsyncTicks()
|
||||
|
||||
expect(writes.some((w) => w.includes(POST_REPLAY_REATTACH_RESET))).toBe(false)
|
||||
})
|
||||
|
||||
it('retires stale routing after unavailable command-finish reads without asserting shell', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
|
|
|
|||
|
|
@ -2010,6 +2010,12 @@ export function connectPanePty(
|
|||
hasKnownAgentIdentity: paneHasKnownAgentIdentity,
|
||||
onConfirmedShellForeground: (reason) => {
|
||||
clearStaleAgentTabTitleOnConfirmedShell()
|
||||
// Why: a hard-killed agent leaves mouse/focus/kitty modes armed, and the
|
||||
// surviving shell then receives pointer moves as typed SGR reports; the
|
||||
// replay guard keeps xterm's auto-replies from leaking to the shell.
|
||||
replayIntoTerminal(pane, deps.replayingPanesRef, POST_REPLAY_REATTACH_RESET, {
|
||||
shouldRefreshViewportSynchronously: shouldRefreshForegroundSynchronously
|
||||
})
|
||||
if (reason === 'visible-pty') {
|
||||
useAppStore.getState().clearAgentLaunchConfig(cacheKey)
|
||||
return
|
||||
|
|
|
|||
Loading…
Reference in New Issue