mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix(terminal): skip an escape tail an abandoned hidden-output restore would print (#33905)
* fix(terminal): skip an escape tail an abandoned hidden-output restore would print (#33766) Main drops a hidden pane's renderer delivery after its model ingests the bytes, so the first chunk sent on reveal can begin inside an escape whose start was dropped. When the restore is abandoned, the renderer resets its parser and writes the queued chunks, and xterm printed the tail (';242m', the rest of a Codex OSC title). Main tracks each PTY's escape state over every accepted payload and labels the first delivery after a hidden or pending-cap drop with the escape it resumes inside (gapEscapeState). Only the abandon path strips that continuation; a successful restore keeps every byte so the snapshot's pending escape tail can complete. No label means no change. * fix(terminal): make the escape-continuation scan's switch exhaustive (#33766)
This commit is contained in:
@@ -18,6 +18,11 @@ import { shouldSendInteractiveOutputNow } from './interactive'
|
||||
import { requestDeliveryResyncForGatedPty } from './accounting'
|
||||
import { warnIfDroppingHiddenBytesForVisiblePty } from './debug-snapshot'
|
||||
import { clearFlushTimerIfIdle } from './flush'
|
||||
import {
|
||||
advancePtyEscapeBoundary,
|
||||
markPtyRendererStreamGap,
|
||||
takePtyGapEscapeState
|
||||
} from './pty-gap-escape-state'
|
||||
import type { PtyIpcSession } from '../session'
|
||||
|
||||
export function rendererPtyIsKnownHidden(id: string): boolean {
|
||||
@@ -54,6 +59,7 @@ export function acceptPtyDataForRenderer(
|
||||
if (session.rendererExitingPtyIds.has(payload.id)) {
|
||||
return
|
||||
}
|
||||
const escapeStateBefore = advancePtyEscapeBoundary(payload.id, payload.data)
|
||||
if (shouldDropHiddenRendererPtyData(payload.id, session.getSettings?.())) {
|
||||
const droppedChars = payload.data.length
|
||||
const drop = recordHiddenRendererPtyDataDrop(payload.id, droppedChars)
|
||||
@@ -72,6 +78,7 @@ export function acceptPtyDataForRenderer(
|
||||
if (payload.data.length === 0 && !payload.transformed) {
|
||||
return
|
||||
}
|
||||
const gapEscapeState = takePtyGapEscapeState(payload.id, escapeStateBefore)
|
||||
const containsBackgroundOutput = rendererPtyIsKnownHidden(payload.id)
|
||||
const overflowMarkedBeforeAppend = session.pendingOverflowMarkedPtys.has(payload.id)
|
||||
const pending = appendPendingPtyData(
|
||||
@@ -83,8 +90,12 @@ export function acceptPtyDataForRenderer(
|
||||
preservesSeq,
|
||||
containsBackgroundOutput,
|
||||
rawLength,
|
||||
payload.transformed === true
|
||||
payload.transformed === true,
|
||||
gapEscapeState
|
||||
)
|
||||
if (pending.droppedOutput === true) {
|
||||
markPtyRendererStreamGap(payload.id)
|
||||
}
|
||||
const shouldEmitPendingCapRestoreMarker =
|
||||
pending.droppedOutput === true &&
|
||||
!overflowMarkedBeforeAppend &&
|
||||
@@ -127,7 +138,8 @@ export function acceptPtyDataForRenderer(
|
||||
: {}),
|
||||
...(pending.transformed ? { transformed: true } : {}),
|
||||
...(pending.containsBackgroundOutput === true ? { background: true } : {}),
|
||||
...(pending.droppedOutput === true ? { droppedOutput: true } : {})
|
||||
...(pending.droppedOutput === true ? { droppedOutput: true } : {}),
|
||||
...(pending.gapEscapeState ? { gapEscapeState: pending.gapEscapeState } : {})
|
||||
})
|
||||
} finally {
|
||||
session.updateProducerFlowControl(payload.id)
|
||||
|
||||
@@ -101,7 +101,8 @@ function preparePtyExitForRenderer(
|
||||
remaining.startSeq,
|
||||
remaining.containsBackgroundOutput,
|
||||
remaining.rawLength,
|
||||
remaining.transformed
|
||||
remaining.transformed,
|
||||
remaining.gapEscapeState
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -177,7 +177,8 @@ export function flushPendingData(session: PtyIpcSession): void {
|
||||
pending.startSeq,
|
||||
pending.containsBackgroundOutput,
|
||||
pending.rawLength,
|
||||
pending.transformed
|
||||
pending.transformed,
|
||||
pending.gapEscapeState
|
||||
)
|
||||
)
|
||||
if (!sent) {
|
||||
|
||||
@@ -10,9 +10,13 @@ export function makePtyDataPayload(
|
||||
startSeq: number | undefined,
|
||||
containsBackgroundOutput: boolean | undefined,
|
||||
rawLength = data.length,
|
||||
transformed = false
|
||||
transformed = false,
|
||||
gapEscapeState?: string
|
||||
): PtyDataPayload {
|
||||
const payload: PtyDataPayload = { id, data }
|
||||
if (gapEscapeState) {
|
||||
payload.gapEscapeState = gapEscapeState
|
||||
}
|
||||
if (typeof startSeq === 'number') {
|
||||
payload.seq = startSeq + rawLength
|
||||
}
|
||||
|
||||
@@ -107,7 +107,8 @@ export function appendPendingPtyData(
|
||||
preservesSeq: boolean,
|
||||
containsBackgroundOutput: boolean,
|
||||
rawLength = data.length,
|
||||
transformed = false
|
||||
transformed = false,
|
||||
gapEscapeState?: string
|
||||
): PendingPtyData {
|
||||
// Once dropped, keep bounded query/mode state while the restore sentinel supersedes ordinary output.
|
||||
if (existing?.droppedOutput === true) {
|
||||
@@ -140,7 +141,8 @@ export function appendPendingPtyData(
|
||||
...(typeof startSeq === 'number' ? { startSeq } : {}),
|
||||
...(rawLength !== data.length ? { rawLength } : {}),
|
||||
...(transformed ? { transformed: true } : {}),
|
||||
...(nextContainsBackgroundOutput ? { containsBackgroundOutput: true } : {})
|
||||
...(nextContainsBackgroundOutput ? { containsBackgroundOutput: true } : {}),
|
||||
...(gapEscapeState ? { gapEscapeState } : {})
|
||||
}
|
||||
return dropOversizedPendingPtyData(session, id, pending)
|
||||
}
|
||||
@@ -150,7 +152,9 @@ export function appendPendingPtyData(
|
||||
...(!preservesSeq || existing.transformed || transformed
|
||||
? { rawLength: existingRawLength + rawLength, transformed: true as const }
|
||||
: {}),
|
||||
...(nextContainsBackgroundOutput ? { containsBackgroundOutput: true } : {})
|
||||
...(nextContainsBackgroundOutput ? { containsBackgroundOutput: true } : {}),
|
||||
// Describes the batch start; a gap inside an existing batch has no start to label.
|
||||
...(existing.gapEscapeState ? { gapEscapeState: existing.gapEscapeState } : {})
|
||||
}
|
||||
if (typeof existing.startSeq === 'number') {
|
||||
next.startSeq = existing.startSeq
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/** Label the first delivery after a renderer-stream gap with the escape it resumes inside, so an abandoned restore can skip that tail (#33766). */
|
||||
import { advanceTerminalEscapeBoundary } from '../../../../shared/terminal-partial-escape-tail'
|
||||
|
||||
// Escape-parser state at the end of each PTY's accepted output, delivered or dropped.
|
||||
const escapeBoundaryByPty = new Map<string, string>()
|
||||
const gapPendingPtys = new Set<string>()
|
||||
|
||||
/** Advance over an accepted payload; returns the state in effect before it. */
|
||||
export function advancePtyEscapeBoundary(id: string, data: string): string {
|
||||
const before = escapeBoundaryByPty.get(id) ?? ''
|
||||
const after = advanceTerminalEscapeBoundary(before, data)
|
||||
if (after) {
|
||||
escapeBoundaryByPty.set(id, after)
|
||||
} else {
|
||||
escapeBoundaryByPty.delete(id)
|
||||
}
|
||||
return before
|
||||
}
|
||||
|
||||
export function markPtyRendererStreamGap(id: string): void {
|
||||
gapPendingPtys.add(id)
|
||||
}
|
||||
|
||||
/** The open escape a delivery resumes inside, once per gap; undefined when the stream resumes clean. */
|
||||
export function takePtyGapEscapeState(id: string, stateBefore: string): string | undefined {
|
||||
if (!gapPendingPtys.delete(id)) {
|
||||
return undefined
|
||||
}
|
||||
return stateBefore || undefined
|
||||
}
|
||||
|
||||
export function clearPtyGapEscapeState(id: string): void {
|
||||
escapeBoundaryByPty.delete(id)
|
||||
gapPendingPtys.delete(id)
|
||||
}
|
||||
|
||||
export function _resetPtyGapEscapeStateForTest(): void {
|
||||
escapeBoundaryByPty.clear()
|
||||
gapPendingPtys.clear()
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
/** Drop hidden renderer delivery after model ingestion; reveal restores a snapshot, while sidecar interest keeps raw bytes flowing. */
|
||||
import type { GlobalSettings } from '../../../../shared/global-settings-types'
|
||||
import {
|
||||
_resetPtyGapEscapeStateForTest,
|
||||
clearPtyGapEscapeState,
|
||||
markPtyRendererStreamGap
|
||||
} from './pty-gap-escape-state'
|
||||
|
||||
export type HiddenPtyDeliveryGateSettings = Pick<
|
||||
GlobalSettings,
|
||||
@@ -104,6 +109,7 @@ export function recordHiddenRendererPtyDataDrop(
|
||||
): { shouldEmitRestoreMarker: boolean; hiddenDropEpisode: number } {
|
||||
droppedHiddenDeliveryChars += chars
|
||||
droppedHiddenDeliveryChunks += 1
|
||||
markPtyRendererStreamGap(id)
|
||||
const latch = droppedSinceHiddenPtys.get(id)
|
||||
if (latch?.state === 'unrevealed') {
|
||||
return { shouldEmitRestoreMarker: false, hiddenDropEpisode: latch.episode }
|
||||
@@ -131,6 +137,7 @@ export function clearHiddenRendererPtyDeliveryState(id: string): void {
|
||||
runtimeOwnedHiddenRendererPtys.delete(id)
|
||||
deliveryInterestRendererPtys.delete(id)
|
||||
droppedSinceHiddenPtys.delete(id)
|
||||
clearPtyGapEscapeState(id)
|
||||
}
|
||||
|
||||
export type HiddenRendererPtyDeliveryDebug = {
|
||||
@@ -159,5 +166,6 @@ export function _resetHiddenRendererPtyDeliveryGateForTest(): void {
|
||||
runtimeOwnedHiddenRendererPtys.clear()
|
||||
deliveryInterestRendererPtys.clear()
|
||||
droppedSinceHiddenPtys.clear()
|
||||
_resetPtyGapEscapeStateForTest()
|
||||
resetHiddenRendererPtyDeliveryDebugCounters()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { setupPtyIpcSuite } from '../../pty-ipc-test-harness'
|
||||
import { registerPtyHandlers } from '../register-handlers'
|
||||
|
||||
vi.mock('electron', () => import('../../pty-ipc-mock-registry').then((m) => m.electronModuleMock()))
|
||||
vi.mock('fs', () => import('../../pty-ipc-mock-registry').then((m) => m.fsModuleMock()))
|
||||
vi.mock('node-pty', () => import('../../pty-ipc-mock-registry').then((m) => m.nodePtyModuleMock()))
|
||||
vi.mock('node:child_process', async (importOriginal) =>
|
||||
(await import('../../pty-ipc-mock-registry')).childProcessModuleMock(await importOriginal())
|
||||
)
|
||||
vi.mock('../../../opencode/hook-service', () =>
|
||||
import('../../pty-ipc-mock-registry').then((m) => m.openCodeHookServiceModuleMock())
|
||||
)
|
||||
vi.mock('../../../mimo/hook-service', () =>
|
||||
import('../../pty-ipc-mock-registry').then((m) => m.mimoHookServiceModuleMock())
|
||||
)
|
||||
vi.mock('../../../agent-hooks/server', () =>
|
||||
import('../../pty-ipc-mock-registry').then((m) => m.agentHookServerModuleMock())
|
||||
)
|
||||
vi.mock('../../../pi/titlebar-extension-service', () =>
|
||||
import('../../pty-ipc-mock-registry').then((m) => m.piTitlebarExtensionModuleMock())
|
||||
)
|
||||
vi.mock('../../../pwsh', () =>
|
||||
import('../../pty-ipc-mock-registry').then((m) => m.pwshModuleMock())
|
||||
)
|
||||
vi.mock('../../../wsl', async (importOriginal) =>
|
||||
(await import('../../pty-ipc-mock-registry')).wslModuleMock(await importOriginal())
|
||||
)
|
||||
vi.mock('../../../telemetry/client', () =>
|
||||
import('../../pty-ipc-mock-registry').then((m) => m.telemetryClientModuleMock())
|
||||
)
|
||||
vi.mock('../../../telemetry/classify-error', () =>
|
||||
import('../../pty-ipc-mock-registry').then((m) => m.classifyErrorModuleMock())
|
||||
)
|
||||
vi.mock('../../../cli/linux-terminal-orca-cli-shim', () =>
|
||||
import('../../pty-ipc-mock-registry').then((m) => m.linuxCliShimModuleMock())
|
||||
)
|
||||
vi.mock('../../../memory/pty-registry', () =>
|
||||
import('../../pty-ipc-mock-registry').then((m) => m.ptyRegistryModuleMock())
|
||||
)
|
||||
vi.mock('../../../agent-hooks/migration-unsupported-pty-state', () =>
|
||||
import('../../pty-ipc-mock-registry').then((m) => m.migrationUnsupportedPtyModuleMock())
|
||||
)
|
||||
vi.mock('../../../codex/codex-pane-account-registry', () =>
|
||||
import('../../pty-ipc-mock-registry').then((m) => m.codexPaneAccountRegistryModuleMock())
|
||||
)
|
||||
vi.mock('../../../codex/codex-state-db-backfill-recovery', () =>
|
||||
import('../../pty-ipc-mock-registry').then((m) => m.codexBackfillRecoveryModuleMock())
|
||||
)
|
||||
// The observable daemon provider adopts the spawn's sessionId as the PTY id.
|
||||
const PTY_ID = 'daemon-session'
|
||||
|
||||
describe('the first delivery after a hidden drop (#33766)', () => {
|
||||
const {
|
||||
handlers,
|
||||
mainWindow,
|
||||
installObservableDaemonTestProvider,
|
||||
getPtySetHiddenRendererPtyListener
|
||||
} = setupPtyIpcSuite()
|
||||
|
||||
async function hideDropAndResume(dropped: string, resumed: string) {
|
||||
vi.useFakeTimers()
|
||||
let seq = 0
|
||||
const runtime = {
|
||||
setPtyController: vi.fn(),
|
||||
registerPty: vi.fn(),
|
||||
noteTerminalSpawnCommand: vi.fn(),
|
||||
onPtySpawned: vi.fn(),
|
||||
onPtyExit: vi.fn(),
|
||||
onPtyData: vi.fn((_id: string, _data: string, _at: number, chars: number) => (seq += chars)),
|
||||
getPtyOutputSequence: vi.fn(() => seq),
|
||||
hasRemoteTerminalViewSubscriber: vi.fn(() => false),
|
||||
createPreAllocatedTerminalHandle: vi.fn(() => null),
|
||||
registerPreAllocatedHandleForPty: vi.fn()
|
||||
}
|
||||
const daemon = installObservableDaemonTestProvider()
|
||||
try {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the suite's window and runtime stubs cover every member registerPtyHandlers reads.
|
||||
registerPtyHandlers(mainWindow as never, runtime as never)
|
||||
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, sessionId: PTY_ID })
|
||||
const id = PTY_ID
|
||||
const setHidden = getPtySetHiddenRendererPtyListener()
|
||||
setHidden(null, { id, hidden: true })
|
||||
daemon.emitData(id, dropped)
|
||||
vi.advanceTimersByTime(50)
|
||||
setHidden(null, { id, hidden: false })
|
||||
mainWindow.webContents.send.mockClear()
|
||||
daemon.emitData(id, resumed)
|
||||
vi.advanceTimersByTime(50)
|
||||
const delivered = mainWindow.webContents.send.mock.calls
|
||||
.filter(([channel]) => channel === 'pty:data')
|
||||
.map(([, payload]): Record<string, unknown> => payload)
|
||||
return { delivered, seq }
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
}
|
||||
|
||||
it.each([
|
||||
[
|
||||
'an OSC title',
|
||||
'done\x1b]0;Sequent',
|
||||
'ial refactoring PRs | cleanup-dumb-exports\x07> prompt',
|
||||
'\x1b]'
|
||||
],
|
||||
['an SGR sequence', 'diff \x1b[38;5', ';242m+ line', '\x1b['],
|
||||
['a CSI whose ESC alone was dropped', 'diff \x1b', '[38;5;242m+ line', '\x1b']
|
||||
])(
|
||||
'labels the first delivery after a drop that split %s, without trimming it',
|
||||
async (_name, dropped, resumed, gapEscapeState) => {
|
||||
const { delivered, seq } = await hideDropAndResume(dropped, resumed)
|
||||
// A restore whose snapshot carries the open escape still needs every continuation byte.
|
||||
expect(delivered).toEqual([
|
||||
{ id: expect.any(String), data: resumed, seq, rawLength: resumed.length, gapEscapeState }
|
||||
])
|
||||
}
|
||||
)
|
||||
|
||||
it('leaves a delivery after a clean drop boundary unlabeled, including a bell after text', async () => {
|
||||
const { delivered } = await hideDropAndResume('hidden\x1b[0m', 'build done\x07')
|
||||
expect(delivered.map((payload) => payload.data).join('')).toBe('build done\x07')
|
||||
expect(delivered.some((payload) => 'gapEscapeState' in payload)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -7,6 +7,8 @@ export type PendingPtyData = {
|
||||
rawLength?: number
|
||||
transformed?: true
|
||||
containsBackgroundOutput?: boolean
|
||||
/** Escape the stream was inside when `data` begins, set only right after a delivery gap. */
|
||||
gapEscapeState?: string
|
||||
droppedOutput?: true
|
||||
droppedMode2031Data?: string
|
||||
droppedMode2031ScanState?: Mode2031ReplyScanState
|
||||
|
||||
@@ -28,6 +28,8 @@ export type PtyDataPayload = {
|
||||
transformed?: boolean
|
||||
background?: boolean
|
||||
droppedOutput?: boolean
|
||||
/** Escape left open by bytes dropped just before this chunk; lets an abandoned restore skip its tail. */
|
||||
gapEscapeState?: string
|
||||
}
|
||||
|
||||
export type RendererPtyDeliveryAccounting = {
|
||||
|
||||
@@ -251,6 +251,7 @@ export type PtyApi = {
|
||||
transformed?: boolean
|
||||
background?: boolean
|
||||
droppedOutput?: boolean
|
||||
gapEscapeState?: string
|
||||
}) => void
|
||||
) => () => void
|
||||
onReplay: (callback: (data: { id: string; data: string }) => void) => () => void
|
||||
|
||||
@@ -49,6 +49,7 @@ export const ptyStreamAndSerializationApi = {
|
||||
transformed?: boolean
|
||||
background?: boolean
|
||||
droppedOutput?: boolean
|
||||
gapEscapeState?: string
|
||||
}) => void
|
||||
): (() => void) => {
|
||||
const listener = (
|
||||
@@ -61,6 +62,7 @@ export const ptyStreamAndSerializationApi = {
|
||||
transformed?: boolean
|
||||
background?: boolean
|
||||
droppedOutput?: boolean
|
||||
gapEscapeState?: string
|
||||
}
|
||||
) => callback(data)
|
||||
ipcRenderer.on('pty:data', listener)
|
||||
|
||||
+85
-2
@@ -1,7 +1,7 @@
|
||||
import type * as React from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { RESET_AFTER_BYTE_GAP } from '../../../../shared/terminal-mode-reset-profiles'
|
||||
import { flushAsyncTicks, createDeferred } from './pty-connection-test-async'
|
||||
import { flushAsyncTicks, createDeferred, renderHeadlessBuffer } from './pty-connection-test-async'
|
||||
import {
|
||||
createMockTransport,
|
||||
createPane,
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type MockTransport
|
||||
} from './pty-connection-test-pane-fixtures'
|
||||
import { buildPaneConnectionDeps, type PaneConnectionDeps } from './pty-connection-test-deps'
|
||||
import type { PtyDataMeta } from './pty-dispatcher'
|
||||
import { createInitialStoreState } from './pty-connection-test-store-fixtures'
|
||||
import type { StoreState } from './pty-connection-test-store-state'
|
||||
import {
|
||||
@@ -153,6 +154,7 @@ type PaneDrive = {
|
||||
deps: PaneConnectionDeps
|
||||
disposable: { dispose: () => void }
|
||||
deliver: (data: string, seq: number) => void
|
||||
deliverWithMeta?: (data: string, meta: PtyDataMeta) => void
|
||||
setVisible: (visible: boolean) => void
|
||||
setOutputPaused: (paused: boolean, dropsReported?: boolean) => void
|
||||
reportPausedOutputDropped: () => void
|
||||
@@ -528,7 +530,7 @@ describe('hidden-output restore loss disclosure (STA-4869)', () => {
|
||||
// Gate local hidden output to mirror the host pause exercised by remote routes.
|
||||
const transport = createMockTransport(LOCAL_PTY_ID)
|
||||
const captured: {
|
||||
current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null
|
||||
current: ((data: string, meta?: PtyDataMeta) => void) | null
|
||||
} = { current: null }
|
||||
transport.connect.mockImplementation(
|
||||
async ({ callbacks }: { callbacks?: ConnectCallbacks }) => {
|
||||
@@ -551,6 +553,7 @@ describe('hidden-output restore loss disclosure (STA-4869)', () => {
|
||||
deps,
|
||||
disposable,
|
||||
deliver: (data, seq) => captured.current?.(data, { seq, rawLength: data.length }),
|
||||
deliverWithMeta: (data, meta) => captured.current?.(data, meta),
|
||||
setVisible: (visible) => {
|
||||
deps.isVisibleRef.current = visible
|
||||
},
|
||||
@@ -625,6 +628,86 @@ describe('hidden-output restore loss disclosure (STA-4869)', () => {
|
||||
expectDisclosedOrRecovered(disclosure)
|
||||
drive.disposable.dispose()
|
||||
})
|
||||
|
||||
// #33766: main labels the first chunk after its hidden drop with the escape the drop left open.
|
||||
async function abandonAfterResumedChunk(resumed: string, gapEscapeState?: string) {
|
||||
vi.mocked(window.api.pty.getMainBufferSnapshot).mockResolvedValue(null)
|
||||
const drive = await connectLocalHiddenPane()
|
||||
vi.useFakeTimers()
|
||||
drive.deliver(HIDDEN_FLOOD, HIDDEN_FLOOD.length)
|
||||
drive.setVisible(true)
|
||||
const seq = HIDDEN_FLOOD.length + resumed.length
|
||||
drive.deliverWithMeta?.(resumed, {
|
||||
seq,
|
||||
rawLength: resumed.length,
|
||||
...(gapEscapeState ? { gapEscapeState } : {})
|
||||
})
|
||||
await flushAsyncTicks(20)
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
await flushAsyncTicks(20)
|
||||
}
|
||||
drive.disposable.dispose()
|
||||
vi.useRealTimers()
|
||||
return (await renderHeadlessBuffer(drive.writtenChunks(), 120, 8)).join('\n')
|
||||
}
|
||||
|
||||
it.each([
|
||||
[
|
||||
'an OSC title',
|
||||
'\x1b]',
|
||||
'ial refactoring PRs | cleanup-dumb-exports\x07> prompt',
|
||||
'ial refactoring'
|
||||
],
|
||||
['an SGR sequence', '\x1b[', ';5;242m> prompt', ';242m']
|
||||
])(
|
||||
'skips the tail of %s main says the gap left open',
|
||||
async (_name, gapEscapeState, resumed, tail) => {
|
||||
const screen = await abandonAfterResumedChunk(resumed, gapEscapeState)
|
||||
expect({
|
||||
banner: screen.includes(BANNER_FRAGMENT),
|
||||
prompt: screen.includes('> prompt'),
|
||||
tail: screen.includes(tail)
|
||||
}).toEqual({ banner: true, prompt: true, tail: false })
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps a labeled continuation when the snapshot at the gap carries the open escape', async () => {
|
||||
// The snapshot predates the resumed chunk, so its pending escape needs every continuation byte.
|
||||
vi.mocked(window.api.pty.getMainBufferSnapshot).mockResolvedValue({
|
||||
data: 'history\r\n',
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
seq: HIDDEN_FLOOD.length,
|
||||
pendingEscapeTailAnsi: '\x1b[38;5'
|
||||
})
|
||||
const drive = await connectLocalHiddenPane()
|
||||
vi.useFakeTimers()
|
||||
drive.deliver(HIDDEN_FLOOD, HIDDEN_FLOOD.length)
|
||||
drive.setVisible(true)
|
||||
const resumed = ';242m+ line'
|
||||
drive.deliverWithMeta?.(resumed, {
|
||||
seq: HIDDEN_FLOOD.length + resumed.length,
|
||||
rawLength: resumed.length,
|
||||
gapEscapeState: '\x1b['
|
||||
})
|
||||
await allowRecoveryWindow()
|
||||
drive.disposable.dispose()
|
||||
vi.useRealTimers()
|
||||
const written = drive.writtenChunks().join('')
|
||||
const screen = (await renderHeadlessBuffer(drive.writtenChunks(), 120, 8)).join('\n')
|
||||
expect({
|
||||
continuationWritten: written.includes('\x1b[38;5') && written.includes(resumed),
|
||||
line: screen.includes('+ line'),
|
||||
tail: screen.includes(';242m'),
|
||||
banner: screen.includes(BANNER_FRAGMENT)
|
||||
}).toEqual({ continuationWritten: true, line: true, tail: false, banner: false })
|
||||
})
|
||||
|
||||
it('keeps unlabeled output that ends in a bell', async () => {
|
||||
const screen = await abandonAfterResumedChunk('build done\x07')
|
||||
expect(screen.includes('build done')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// The PTY-swap arm requires a prior successful snapshot, which resets its abandon budget; a deferred repaint must keep any subsequent gap recoverable.
|
||||
|
||||
+14
-1
@@ -1,4 +1,5 @@
|
||||
import { cancelScheduledHiddenOutputRestore } from '../hidden-output-restore-scheduler'
|
||||
import { skipOpenEscapeContinuation } from '../../../../../shared/terminal-partial-escape-tail'
|
||||
import { shouldWritePtyOutputForeground } from './foreground-output-scan'
|
||||
|
||||
import type { ConnectPanePtySession } from './connect-pane-pty-session'
|
||||
@@ -76,11 +77,23 @@ export function bindAbandonHiddenOutputRestore(session: ConnectPanePtySession):
|
||||
return
|
||||
}
|
||||
const replayedSeq = typeof replayingSnapshot?.seq === 'number' ? replayingSnapshot.seq : null
|
||||
// The reset above aborted any open escape, so a chunk main labels as resuming inside one must skip its tail (#33766).
|
||||
const followsByteGap = !opts.quiet || !opts.preserveReplayState
|
||||
let openEscape = ''
|
||||
let pendingData = ''
|
||||
for (const chunk of pendingChunks) {
|
||||
const sliced =
|
||||
replayedSeq === null ? chunk.data : session.getChunkDataAfterSnapshot(chunk, replayedSeq)
|
||||
pendingData += sliced ?? chunk.data
|
||||
let data = sliced ?? chunk.data
|
||||
if (followsByteGap && chunk.gapEscapeState && data === chunk.data) {
|
||||
openEscape = chunk.gapEscapeState
|
||||
}
|
||||
if (openEscape) {
|
||||
const skipped = skipOpenEscapeContinuation(openEscape, data)
|
||||
data = skipped.rest
|
||||
openEscape = skipped.stillOpen
|
||||
}
|
||||
pendingData += data
|
||||
}
|
||||
if (replayingSnapshot && replayedSeq !== null) {
|
||||
session.setRestoredSnapshotBaseline(
|
||||
|
||||
+3
@@ -47,6 +47,9 @@ export function bindHiddenOutputRestoreChunk(session: ConnectPanePtySession): vo
|
||||
if (typeof meta?.rawLength === 'number') {
|
||||
pending.rawLength = meta.rawLength
|
||||
}
|
||||
if (meta?.gapEscapeState) {
|
||||
pending.gapEscapeState = meta.gapEscapeState
|
||||
}
|
||||
session.hiddenOutputRestorePendingChunks.push(pending)
|
||||
session.hiddenOutputRestorePendingChars += data.length
|
||||
session.armHiddenOutputRestoreForegroundDeadline()
|
||||
|
||||
@@ -20,6 +20,7 @@ export function bindLiveDataCallback(session: ConnectPanePtySession): void {
|
||||
if (streamGeneration !== session.transportStreamGeneration) {
|
||||
return
|
||||
}
|
||||
const arrivedData = data
|
||||
if (session.deferredReattachLiveData !== null) {
|
||||
const ackCredit = takeCurrentTerminalDeliveryCredit()
|
||||
session.deferredReattachLiveData.enqueue({
|
||||
@@ -156,7 +157,11 @@ export function bindLiveDataCallback(session: ConnectPanePtySession): void {
|
||||
if (pendingForegroundQuery?.statefulQueryData) {
|
||||
session.queueLiveChunkDuringRestore(pendingForegroundQuery.statefulQueryData)
|
||||
}
|
||||
session.queueLiveChunkDuringRestore(orderedRendererData, rendererMeta)
|
||||
session.queueLiveChunkDuringRestore(
|
||||
orderedRendererData,
|
||||
// The gap label describes the chunk's original first byte only.
|
||||
orderedRendererData === arrivedData ? rendererMeta : withoutGapEscapeState(rendererMeta)
|
||||
)
|
||||
session.requestHiddenOutputRestoreIfNeeded()
|
||||
} else if (session.hiddenOutputRestoreInFlight) {
|
||||
session.hiddenOutputRestoreNeeded = true
|
||||
@@ -187,3 +192,12 @@ export function bindLiveDataCallback(session: ConnectPanePtySession): void {
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function withoutGapEscapeState(meta: PtyDataMeta | undefined): PtyDataMeta | undefined {
|
||||
if (!meta?.gapEscapeState) {
|
||||
return meta
|
||||
}
|
||||
const next = { ...meta }
|
||||
delete next.gapEscapeState
|
||||
return next
|
||||
}
|
||||
|
||||
+1
@@ -2,4 +2,5 @@ export type PendingHiddenOutputRestoreChunk = {
|
||||
data: string
|
||||
seq?: number
|
||||
rawLength?: number
|
||||
gapEscapeState?: string
|
||||
}
|
||||
|
||||
@@ -54,6 +54,8 @@ export type PtyDataMeta = {
|
||||
background?: boolean
|
||||
/** Main dropped this PTY's buffered output at the pending cap; repaint from the main-owned snapshot, not the live stream. */
|
||||
droppedOutput?: boolean
|
||||
/** Escape main's dropped bytes left open where this chunk begins; only an abandoned restore uses it. */
|
||||
gapEscapeState?: string
|
||||
}
|
||||
|
||||
/** Sidecar PTY-data observers, invoked AFTER the primary handler so a side-effect-only watcher can't delay xterm rendering. */
|
||||
@@ -116,6 +118,7 @@ function handleDispatchedPtyData(payload: {
|
||||
transformed?: boolean
|
||||
background?: boolean
|
||||
droppedOutput?: boolean
|
||||
gapEscapeState?: string
|
||||
}): void {
|
||||
let meta: PtyDataMeta | undefined
|
||||
if (typeof payload.seq === 'number') {
|
||||
@@ -138,6 +141,10 @@ function handleDispatchedPtyData(payload: {
|
||||
meta ??= {}
|
||||
meta.droppedOutput = true
|
||||
}
|
||||
if (typeof payload.gapEscapeState === 'string' && payload.gapEscapeState.length > 0) {
|
||||
meta ??= {}
|
||||
meta.gapEscapeState = payload.gapEscapeState
|
||||
}
|
||||
const chars = payload.rawLength ?? payload.data.length
|
||||
const dispatch = (): void => {
|
||||
if (isPtyDataHandlerShutdownPending(payload.id)) {
|
||||
|
||||
@@ -157,6 +157,81 @@ function scanPartialEscapeTail(stream: string, stateOnly: boolean): string {
|
||||
return stateOnly ? STATE_PREFIX[state] : state === 'ground' ? '' : stream.slice(start)
|
||||
}
|
||||
|
||||
const STATE_BY_PREFIX: Record<string, ScanState> = {
|
||||
'\x1b': 'esc',
|
||||
'\x1b ': 'escIntermediate',
|
||||
'\x1b[': 'csi',
|
||||
'\x1b]': 'osc',
|
||||
'\x1bP': 'string'
|
||||
}
|
||||
|
||||
/** Chars of chunk that finish the escape left open by advanceTerminalEscapeBoundary's statePrefix; a new ESC ends it unconsumed. -1 when it stays open. */
|
||||
export function openEscapeContinuationLength(statePrefix: string, chunk: string): number {
|
||||
let state: ScanState = STATE_BY_PREFIX[statePrefix] ?? 'ground'
|
||||
for (let i = 0; i < chunk.length; i++) {
|
||||
if (state === 'ground') {
|
||||
return i
|
||||
}
|
||||
const code = chunk.charCodeAt(i)
|
||||
if (code === CAN || code === SUB) {
|
||||
return i + 1
|
||||
}
|
||||
switch (state) {
|
||||
case 'esc':
|
||||
if (code === ESC) {
|
||||
return i
|
||||
}
|
||||
state = stateAfterEscByte(code)
|
||||
if (state === 'ground') {
|
||||
return i + 1
|
||||
}
|
||||
break
|
||||
case 'escIntermediate':
|
||||
if (code === ESC) {
|
||||
return i
|
||||
}
|
||||
if (code >= 0x30 && code <= 0x7e) {
|
||||
return i + 1
|
||||
}
|
||||
break
|
||||
case 'csi':
|
||||
if (code === ESC) {
|
||||
return i
|
||||
}
|
||||
if (code >= 0x40 && code <= 0x7e) {
|
||||
return i + 1
|
||||
}
|
||||
break
|
||||
case 'osc':
|
||||
case 'string':
|
||||
if (code === BEL && state === 'osc') {
|
||||
return i + 1
|
||||
}
|
||||
// Keep an ESC that may start a new sequence; if it was a split ST, its backslash later completes a no-op ST.
|
||||
if (code === ESC) {
|
||||
return chunk.charCodeAt(i + 1) === 0x5c ? i + 2 : i
|
||||
}
|
||||
break
|
||||
// Unreachable from a statePrefix (both map to 'esc'); an ESC inside a string either finishes ST or starts a kept sequence.
|
||||
case 'oscEsc':
|
||||
case 'stringEsc':
|
||||
return code === 0x5c ? i + 1 : i
|
||||
}
|
||||
}
|
||||
return state === 'ground' ? chunk.length : -1
|
||||
}
|
||||
|
||||
/** Drop the part of chunk that continues the escape open in statePrefix; stillOpen carries it when the chunk ends first. */
|
||||
export function skipOpenEscapeContinuation(
|
||||
statePrefix: string,
|
||||
chunk: string
|
||||
): { rest: string; stillOpen: string } {
|
||||
const length = openEscapeContinuationLength(statePrefix, chunk)
|
||||
return length === -1
|
||||
? { rest: '', stillOpen: advanceTerminalEscapeBoundary(statePrefix, chunk) }
|
||||
: { rest: chunk.slice(length), stillOpen: '' }
|
||||
}
|
||||
|
||||
/** Advance the incremental tail; abandon it past the cap. */
|
||||
export function advancePartialEscapeTail(pendingTail: string, chunk: string): string {
|
||||
// Only ESC leaves ground, so no tail + no ESC avoids concatenation and the full chunk walk.
|
||||
|
||||
Reference in New Issue
Block a user