diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 6e09c9a1bd1..84da1894188 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -534,13 +534,16 @@ describe('registerPtyHandlers', () => { function getPtyAckDataListener(): ( event: unknown, - args: { id: string; charCount: number } + args: { id: string; charCount?: number; processedChars?: number } ) => void { const ackCall = onMock.mock.calls.find((call: unknown[]) => call[0] === 'pty:ackData') if (!ackCall) { throw new Error('missing pty:ackData listener') } - return ackCall[1] as (event: unknown, args: { id: string; charCount: number }) => void + return ackCall[1] as ( + event: unknown, + args: { id: string; charCount?: number; processedChars?: number } + ) => void } function getPtySetActiveRendererPtyListener(): ( @@ -6936,9 +6939,7 @@ describe('registerPtyHandlers', () => { expect(mainWindow.webContents.send).toHaveBeenCalledTimes(32) vi.advanceTimersByTime(1) expect(mainWindow.webContents.send).toHaveBeenCalledTimes(32) - // Why: the only remaining timer is the ack-stall watchdog armed by the - // gate-blocked flush; the flush loop itself stopped rescheduling. - expect(vi.getTimerCount()).toBe(1) + expect(vi.getTimerCount()).toBe(0) expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ pendingPtyCount: 1, pendingChars: 88 * 1024, @@ -6994,15 +6995,44 @@ describe('registerPtyHandlers', () => { } }) - const ACK_STALL_WATCHDOG_WARNING = - '[pty] ack stall watchdog: resetting in-flight delivery counters' + const DELIVERY_RESYNC_UNANSWERED_WARNING = + '[pty] delivery resync probe unanswered — renderer IPC unresponsive' - function countAckStallWarnings(warnSpy: { mock: { calls: unknown[][] } }): number { - return warnSpy.mock.calls.filter((call) => call[0] === ACK_STALL_WATCHDOG_WARNING).length + function countResyncUnansweredWarnings(warnSpy: { mock: { calls: unknown[][] } }): number { + return warnSpy.mock.calls.filter((call) => call[0] === DELIVERY_RESYNC_UNANSWERED_WARNING) + .length } - /** Saturates one PTY to its 512 KiB in-flight cap so the next flush attempt - * is gate-blocked and arms the ack-stall watchdog. Leaves 88 KiB pending. */ + function getPtyDataSendCalls(): unknown[][] { + return mainWindow.webContents.send.mock.calls.filter( + (call: unknown[]) => call[0] === 'pty:data' + ) + } + + function getDeliveryResyncProbeCalls(): unknown[][] { + return mainWindow.webContents.send.mock.calls.filter( + (call: unknown[]) => call[0] === 'pty:requestDeliveryResync' + ) + } + + function getDeliveryResyncResponseListener(): ( + event: unknown, + args: { requestId: number; processedCharsByPty: Record } + ) => void { + const responseCall = onMock.mock.calls.find( + (call: unknown[]) => call[0] === 'pty:deliveryResyncResponse' + ) + if (!responseCall) { + throw new Error('missing pty:deliveryResyncResponse listener') + } + return responseCall[1] as ( + event: unknown, + args: { requestId: number; processedCharsByPty: Record } + ) => void + } + + /** Saturates one PTY to its 512 KiB in-flight cap so delivery is fully + * gated for that PTY. Leaves 88 KiB pending and no timers scheduled. */ async function spawnAndSaturateRendererDeliveryGate( mockProc: ReturnType ): Promise<{ id: string }> { @@ -7021,54 +7051,38 @@ describe('registerPtyHandlers', () => { return spawnResult } - it('resets the renderer delivery gate after gate-blocked sends see no ACKs for the stall window', async () => { + it('self-heals lost ACKs when a later cumulative ACK arrives', async () => { vi.useFakeTimers() - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) const mockProc = createMockProc() spawnMock.mockReturnValue(mockProc.proc) try { - await spawnAndSaturateRendererDeliveryGate(mockProc) - expect(mainWindow.webContents.send).toHaveBeenCalledTimes(32) - expect(countAckStallWarnings(warnSpy)).toBe(0) - - vi.advanceTimersByTime(9_999) - expect(countAckStallWarnings(warnSpy)).toBe(0) - expect(mainWindow.webContents.send).toHaveBeenCalledTimes(32) - - vi.advanceTimersByTime(1) - expect(countAckStallWarnings(warnSpy)).toBe(1) - expect(warnSpy).toHaveBeenCalledWith( - ACK_STALL_WATCHDOG_WARNING, - expect.objectContaining({ - rendererInFlightChars: 512 * 1024, - rendererInFlightPtyCount: 1, - pendingPtyCount: 1 - }) - ) - // Why: the reset must reopen the gate so the held pendingData delivers - // without any renderer ACK ever arriving. - vi.runOnlyPendingTimers() - expect(mainWindow.webContents.send).toHaveBeenCalledTimes(33) - - for (let index = 0; index < 5; index++) { - vi.advanceTimersByTime(1) - } + const spawnResult = await spawnAndSaturateRendererDeliveryGate(mockProc) + expect(getPtyDataSendCalls()).toHaveLength(32) expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ - pendingPtyCount: 0, - pendingChars: 0, - rendererInFlightChars: 88 * 1024 + rendererInFlightChars: 512 * 1024, + rendererInFlightPtyCount: 1 }) - expect(countAckStallWarnings(warnSpy)).toBe(1) + + // Every per-chunk ACK was lost, but the next ACK carries the renderer's + // full cumulative total — the debt clears without any timer or reset. + const ackData = getPtyAckDataListener() + ackData(null, { id: spawnResult.id, processedChars: 512 * 1024 }) + + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererInFlightChars: 0, + rendererInFlightPtyCount: 0 + }) + + vi.runOnlyPendingTimers() + expect(getPtyDataSendCalls()).toHaveLength(33) } finally { - warnSpy.mockRestore() vi.useRealTimers() } }) - it('does not fire the ack-stall watchdog while renderer ACKs keep arriving', async () => { + it('applies cumulative ACKs idempotently and ignores stale reordered totals', async () => { vi.useFakeTimers() - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) const mockProc = createMockProc() spawnMock.mockReturnValue(mockProc.proc) @@ -7076,16 +7090,206 @@ describe('registerPtyHandlers', () => { const spawnResult = await spawnAndSaturateRendererDeliveryGate(mockProc) const ackData = getPtyAckDataListener() - // Healthy heavy load: delivery stays gate-blocked for far longer than - // the stall window, but ACKs keep trickling in and restart it each time. - for (let round = 0; round < 3; round++) { - vi.advanceTimersByTime(5_000) - ackData(null, { id: spawnResult.id, charCount: 16 * 1024 }) - vi.advanceTimersByTime(2) - } - vi.advanceTimersByTime(5_000) + ackData(null, { id: spawnResult.id, processedChars: 256 * 1024 }) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererInFlightChars: 256 * 1024, + maxRendererInFlightCharsByPty: 256 * 1024 + }) - expect(countAckStallWarnings(warnSpy)).toBe(0) + // Replayed duplicate credits nothing further. + ackData(null, { id: spawnResult.id, processedChars: 256 * 1024 }) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererInFlightChars: 256 * 1024 + }) + + // A stale reordered total can never move accounting backwards. + ackData(null, { id: spawnResult.id, processedChars: 128 * 1024 }) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererInFlightChars: 256 * 1024 + }) + } finally { + vi.useRealTimers() + } + }) + + it('tolerates mixed legacy delta and cumulative ACK payloads', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + const spawnResult = await spawnAndSaturateRendererDeliveryGate(mockProc) + const ackData = getPtyAckDataListener() + + // Legacy delta shape (no processedChars) still credits per chunk. + ackData(null, { id: spawnResult.id, charCount: 16 * 1024 }) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererInFlightChars: 496 * 1024 + }) + + // A cumulative total then supersedes without double-crediting the delta. + ackData(null, { id: spawnResult.id, processedChars: 512 * 1024 }) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererInFlightChars: 0, + rendererInFlightPtyCount: 0 + }) + } finally { + vi.useRealTimers() + } + }) + + it('forwards only newly acknowledged cumulative bytes to provider ACK backpressure', async () => { + vi.useFakeTimers() + const acknowledgeDataEvent = vi.fn() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + setLocalPtyProvider({ + spawn: vi.fn(async () => ({ id: 'cumulative-pty' })), + write: vi.fn(), + resize: vi.fn(), + shutdown: vi.fn(), + sendSignal: vi.fn(), + getCwd: vi.fn(), + getInitialCwd: vi.fn(), + clearBuffer: vi.fn(), + acknowledgeDataEvent, + hasChildProcesses: vi.fn(), + getForegroundProcess: vi.fn(), + serialize: vi.fn(), + revive: vi.fn(), + onData: vi.fn((callback) => { + mockProc.proc.onData((data: string) => callback({ id: 'cumulative-pty', data })) + return () => {} + }), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}), + listProcesses: vi.fn(async () => []), + attach: vi.fn(), + getDefaultShell: vi.fn(), + getProfiles: vi.fn() + } as never) + registerPtyHandlers(mainWindow as never) + const ackData = getPtyAckDataListener() + mainWindow.webContents.send.mockClear() + + mockProc.emitData('remote-output') + vi.advanceTimersByTime(8) + + // Why: cumulative totals are clamped to what main actually sent, and a + // replayed total must credit SSH/relay flow control with zero, not + // duplicate bytes. + ackData(null, { id: 'cumulative-pty', processedChars: 1024 }) + ackData(null, { id: 'cumulative-pty', processedChars: 1024 }) + + expect(acknowledgeDataEvent).toHaveBeenNthCalledWith( + 1, + 'cumulative-pty', + 'remote-output'.length + ) + expect(acknowledgeDataEvent).toHaveBeenNthCalledWith(2, 'cumulative-pty', 0) + } finally { + vi.useRealTimers() + } + }) + + it('probes for a delivery resync when data arrives for a fully gated PTY and reconciles on reply', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + const spawnResult = await spawnAndSaturateRendererDeliveryGate(mockProc) + + mockProc.emitData('stuck-output') + expect(getDeliveryResyncProbeCalls()).toHaveLength(1) + const probePayload = getDeliveryResyncProbeCalls()[0]![1] as { requestId: number } + + // Only one probe may be outstanding at a time. + mockProc.emitData('still-stuck') + expect(getDeliveryResyncProbeCalls()).toHaveLength(1) + + const respondDeliveryResync = getDeliveryResyncResponseListener() + respondDeliveryResync(null, { + requestId: probePayload.requestId, + processedCharsByPty: { [spawnResult.id]: 512 * 1024 } + }) + + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererInFlightChars: 0, + rendererInFlightPtyCount: 0 + }) + + // The reconciled gate lets the held pendingData flush again. + vi.advanceTimersByTime(8) + expect(getPtyDataSendCalls()).toHaveLength(33) + } finally { + vi.useRealTimers() + } + }) + + it('ignores resync replies with stale request ids', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + const spawnResult = await spawnAndSaturateRendererDeliveryGate(mockProc) + mockProc.emitData('stuck-output') + const probePayload = getDeliveryResyncProbeCalls()[0]![1] as { requestId: number } + + const respondDeliveryResync = getDeliveryResyncResponseListener() + respondDeliveryResync(null, { + requestId: probePayload.requestId + 41, + processedCharsByPty: { [spawnResult.id]: 512 * 1024 } + }) + + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererInFlightChars: 512 * 1024 + }) + } finally { + vi.useRealTimers() + } + }) + + it('clears an unanswered resync probe, warns once per silent streak, and never mutates counters', async () => { + vi.useFakeTimers() + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + await spawnAndSaturateRendererDeliveryGate(mockProc) + + mockProc.emitData('stuck-output') + expect(getDeliveryResyncProbeCalls()).toHaveLength(1) + + vi.advanceTimersByTime(4_999) + expect(countResyncUnansweredWarnings(warnSpy)).toBe(0) + + vi.advanceTimersByTime(1) + expect(countResyncUnansweredWarnings(warnSpy)).toBe(1) + expect(warnSpy).toHaveBeenCalledWith( + DELIVERY_RESYNC_UNANSWERED_WARNING, + expect.objectContaining({ + rendererInFlightChars: 512 * 1024, + pendingPtyCount: 1 + }) + ) + // No blind reset: counters and pending output are untouched. + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererInFlightChars: 512 * 1024, + pendingChars: 88 * 1024 + 'stuck-output'.length + }) + expect(getPtyDataSendCalls()).toHaveLength(32) + + // The cleared flag lets the next gated arrival probe again, but a + // still-silent renderer does not spam a second warn. + mockProc.emitData('still-stuck') + expect(getDeliveryResyncProbeCalls()).toHaveLength(2) + vi.advanceTimersByTime(5_000) + expect(countResyncUnansweredWarnings(warnSpy)).toBe(1) expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ rendererInFlightChars: 512 * 1024 }) @@ -7095,34 +7299,7 @@ describe('registerPtyHandlers', () => { } }) - it('disarms the ack-stall watchdog when an ACK arrives and restarts the stall window', async () => { - vi.useFakeTimers() - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const mockProc = createMockProc() - spawnMock.mockReturnValue(mockProc.proc) - - try { - const spawnResult = await spawnAndSaturateRendererDeliveryGate(mockProc) - const ackData = getPtyAckDataListener() - - vi.advanceTimersByTime(9_000) - ackData(null, { id: spawnResult.id, charCount: 16 * 1024 }) - - // Past the original 10s deadline: the ACK disarmed the first timer. - vi.advanceTimersByTime(2_000) - expect(countAckStallWarnings(warnSpy)).toBe(0) - - // No further ACKs while still gate-blocked: the re-armed watchdog fires - // one full window after the next gate-blocked send. - vi.advanceTimersByTime(9_000) - expect(countAckStallWarnings(warnSpy)).toBe(1) - } finally { - warnSpy.mockRestore() - vi.useRealTimers() - } - }) - - it('cleans up the ack-stall watchdog when the window is destroyed', async () => { + it('clears resync probe state when the window is destroyed', async () => { vi.useFakeTimers() const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) const mockProc = createMockProc() @@ -7142,16 +7319,16 @@ describe('registerPtyHandlers', () => { for (let index = 0; index < 32; index++) { vi.advanceTimersByTime(1) } - expect(destroyableWindow.webContents.send).toHaveBeenCalledTimes(32) - expect(vi.getTimerCount()).toBe(1) + mockProc.emitData('stuck-output') + // Pending flush timer plus the outstanding probe's hygiene timeout. + expect(vi.getTimerCount()).toBe(2) destroyed = true mockProc.emitData('post-destroy output') expect(vi.getTimerCount()).toBe(0) vi.advanceTimersByTime(60_000) - expect(countAckStallWarnings(warnSpy)).toBe(0) - expect(destroyableWindow.webContents.send).toHaveBeenCalledTimes(32) + expect(countResyncUnansweredWarnings(warnSpy)).toBe(0) } finally { warnSpy.mockRestore() vi.useRealTimers() @@ -7282,9 +7459,7 @@ describe('registerPtyHandlers', () => { vi.advanceTimersByTime(1) } expect(mainWindow.webContents.send).toHaveBeenCalledTimes(512) - // Why: the ack-stall watchdog stays armed while bulk delivery is - // gate-blocked; no flush timer remains scheduled. - expect(vi.getTimerCount()).toBe(1) + expect(vi.getTimerCount()).toBe(0) writeListener(mainWindowIpcEvent, { id: interactiveSpawn.id, @@ -7314,7 +7489,10 @@ describe('registerPtyHandlers', () => { data: 'a' }) interactiveProc.emitData(reserveChunk) - expect(mainWindow.webContents.send).toHaveBeenCalledTimes(529) + // Why: the reserve-exhausted send stays gated, and the fully gated + // arrival now also emits one delivery resync probe (not pty:data). + expect(getPtyDataSendCalls()).toHaveLength(529) + expect(getDeliveryResyncProbeCalls()).toHaveLength(1) } finally { vi.useRealTimers() } @@ -7397,11 +7575,16 @@ describe('registerPtyHandlers', () => { setActiveRendererPty(null, { id: spawns[activeIndex]!.id, active: true }) vi.advanceTimersByTime(8) - expect(mainWindow.webContents.send).toHaveBeenCalledTimes(513) - expect(mainWindow.webContents.send).toHaveBeenNthCalledWith(513, 'pty:data', { - id: spawns[activeIndex]!.id, - data: 'active-output' - }) + // Why: the fully gated arrival also emits one delivery resync probe, so + // count pty:data sends rather than raw webContents.send calls. + expect(getPtyDataSendCalls()).toHaveLength(513) + expect(getPtyDataSendCalls()[512]).toEqual([ + 'pty:data', + { + id: spawns[activeIndex]!.id, + data: 'active-output' + } + ]) expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ activeRendererPtyCount: 1, pendingPtyCount: procs.length - 1, diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index c364e52c975..38dc4722705 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -1289,6 +1289,7 @@ export function registerPtyHandlers( ipcMain.removeAllListeners('pty:write') ipcMain.removeAllListeners('pty:ackColdRestore') ipcMain.removeAllListeners('pty:ackData') + ipcMain.removeAllListeners('pty:deliveryResyncResponse') ipcMain.removeAllListeners('pty:serializeBuffer:response') // Configure the local provider with app-specific hooks. @@ -1374,12 +1375,19 @@ export function registerPtyHandlers( } const pendingData = new Map() - const rendererInFlightCharsByPty = new Map() + // Why: TCP-style cumulative delivery accounting. Relative in-flight counters + // make every lost ACK a permanent debt; monotonic sent/acked totals self-heal + // as soon as any later ACK (or resync reply) reports the renderer's full + // processed count. + type RendererPtyDeliveryAccounting = { sentChars: number; ackedChars: number } + const rendererDeliveryAccountingByPty = new Map() const trustedTerminalHandleEnv = new Set() let flushTimer: ReturnType | null = null let rendererInFlightTotalChars = 0 - let ackStallWatchdogTimer: ReturnType | null = null - let ackGateBlockedSinceMs: number | null = null + let deliveryResyncRequestSerial = 0 + let deliveryResyncOutstandingRequestId: number | null = null + let deliveryResyncTimer: ReturnType | null = null + let deliveryResyncUnansweredWarnLogged = false let lastAckReceivedAtMs: number | null = null const PTY_BATCH_INTERVAL_MS = 8 const PTY_BATCH_DRAIN_CONTINUE_MS = 1 @@ -1391,13 +1399,10 @@ export function registerPtyHandlers( // Why: active panes need a bounded lane through old hidden bulk output so a // keystroke redraw can reach the renderer before every background ACK lands. const PTY_RENDERER_ACTIVE_PTY_IN_FLIGHT_RESERVE_CHARS = 512 * 1024 - // Why: renderer ACKs can be lost across a system suspend, pinning the - // in-flight counters at the cap and silently gating every PTY (old and new) - // forever. Healthy renderers ACK continuously even under heavy load, so a - // full stall window with gate-blocked sends and zero ACKs means the - // outstanding ACKs are gone. A false fire only over-delivers transiently — - // the very next gating cycle bounds delivery again. - const PTY_ACK_STALL_WATCHDOG_MS = 10_000 + // Why: request/response hygiene only — this timeout never mutates delivery + // state. It clears the outstanding-probe flag so a later gated arrival can + // probe again, and logs once per silent streak for field diagnosis. + const PTY_DELIVERY_RESYNC_TIMEOUT_MS = 5_000 // Why: keep the immediate path bounded to keystroke-sized TUI redraws; // large output and non-interactive output must still use the batcher. const INTERACTIVE_OUTPUT_WINDOW_MS = 100 @@ -1410,12 +1415,9 @@ export function registerPtyHandlers( let peakMaxRendererInFlightCharsByPty = 0 let ackGatedFlushSkipCount = 0 - function getMaxMapValue(values: Iterable): number { - let max = 0 - for (const value of values) { - max = Math.max(max, value) - } - return max + function getRendererInFlightCharsForPty(id: string): number { + const accounting = rendererDeliveryAccountingByPty.get(id) + return accounting ? accounting.sentChars - accounting.ackedChars : 0 } function readCurrentPtyRendererDeliveryDebugSnapshot(): PtyRendererDeliveryDebugSnapshot { @@ -1426,13 +1428,22 @@ export function registerPtyHandlers( pendingChars += chars maxPendingCharsByPty = Math.max(maxPendingCharsByPty, chars) } + let rendererInFlightPtyCount = 0 + let maxRendererInFlightCharsByPty = 0 + for (const accounting of rendererDeliveryAccountingByPty.values()) { + const inFlight = accounting.sentChars - accounting.ackedChars + if (inFlight > 0) { + rendererInFlightPtyCount++ + } + maxRendererInFlightCharsByPty = Math.max(maxRendererInFlightCharsByPty, inFlight) + } return { pendingPtyCount: pendingData.size, pendingChars, maxPendingCharsByPty, - rendererInFlightPtyCount: rendererInFlightCharsByPty.size, + rendererInFlightPtyCount, rendererInFlightChars: rendererInFlightTotalChars, - maxRendererInFlightCharsByPty: getMaxMapValue(rendererInFlightCharsByPty.values()), + maxRendererInFlightCharsByPty, activeRendererPtyCount: activeRendererPtys.size, flushScheduled: flushTimer !== null, peakPendingChars, @@ -1527,68 +1538,86 @@ export function registerPtyHandlers( const ptyLimit = PTY_RENDERER_IN_FLIGHT_HIGH_WATER_CHARS + (options.interactive === true ? PTY_RENDERER_ACTIVE_PTY_IN_FLIGHT_RESERVE_CHARS : 0) - return ( - (rendererInFlightCharsByPty.get(id) ?? 0) < ptyLimit && - rendererInFlightTotalChars < totalLimit + return getRendererInFlightCharsForPty(id) < ptyLimit && rendererInFlightTotalChars < totalLimit + } + + // Why: max-merge on cumulative totals is idempotent and reorder-tolerant — + // a replayed or out-of-order ACK can never double-credit, and a lost ACK + // self-heals when any later ACK reports the full processed count. Returns + // the newly acknowledged delta so provider (SSH/daemon) backpressure is only + // credited for bytes main actually tracked in flight, never negative. + function applyCumulativeAck(id: string, processedChars: number): number { + const accounting = rendererDeliveryAccountingByPty.get(id) + if (!accounting) { + return 0 + } + // Clamped to sentChars so a corrupt payload cannot drive in-flight negative. + const nextAckedChars = Math.min( + accounting.sentChars, + Math.max(accounting.ackedChars, processedChars) ) + const acknowledged = nextAckedChars - accounting.ackedChars + accounting.ackedChars = nextAckedChars + rendererInFlightTotalChars = Math.max(0, rendererInFlightTotalChars - acknowledged) + return acknowledged } - function disarmAckStallWatchdog(): void { - ackGateBlockedSinceMs = null - if (ackStallWatchdogTimer) { - clearTimeout(ackStallWatchdogTimer) - ackStallWatchdogTimer = null + function clearDeliveryResyncProbe(): void { + deliveryResyncOutstandingRequestId = null + if (deliveryResyncTimer) { + clearTimeout(deliveryResyncTimer) + deliveryResyncTimer = null } } - // Why: armed lazily on the first gate-blocked send and disarmed by every - // renderer ACK, so the watchdog never fires under healthy heavy load and - // costs nothing while delivery is unblocked. - function noteAckGateBlockedSend(): void { - if (ackStallWatchdogTimer) { + // Why: event-triggered verified-state recovery. Data arriving for a fully + // gated PTY is the deterministic signal that delivery may be stuck on lost + // ACKs (e.g. dropped across a system suspend); ask the renderer for its + // authoritative processed totals instead of resetting on a wall-clock guess. + function requestDeliveryResyncForGatedPty(): void { + if (deliveryResyncOutstandingRequestId !== null || mainWindow.isDestroyed()) { return } - ackGateBlockedSinceMs = Date.now() - ackStallWatchdogTimer = setTimeout(resetDeliveryGateAfterAckStall, PTY_ACK_STALL_WATCHDOG_MS) - ackStallWatchdogTimer.unref?.() - } - - function resetDeliveryGateAfterAckStall(): void { - ackStallWatchdogTimer = null - const gateBlockedSinceMs = ackGateBlockedSinceMs - ackGateBlockedSinceMs = null - if (mainWindow.isDestroyed() || mainWindow.webContents.isDestroyed?.() === true) { - return - } - // Why: reaching here means zero ACKs arrived for the full stall window - // (any ACK disarms the timer). Only reset while delivery is still blocked - // on unacked bytes, so an idle-but-healthy renderer is never touched. - if (rendererInFlightTotalChars === 0 || pendingData.size === 0) { - return - } - console.warn('[pty] ack stall watchdog: resetting in-flight delivery counters', { - gateBlockedForMs: gateBlockedSinceMs === null ? null : Date.now() - gateBlockedSinceMs, - msSinceLastAck: lastAckReceivedAtMs === null ? null : Date.now() - lastAckReceivedAtMs, - rendererInFlightChars: rendererInFlightTotalChars, - rendererInFlightPtyCount: rendererInFlightCharsByPty.size, - pendingPtyCount: pendingData.size - }) - rendererInFlightCharsByPty.clear() - rendererInFlightTotalChars = 0 - recordPtyRendererDeliveryPressure() - schedulePendingDataFlush(0) + deliveryResyncRequestSerial += 1 + const requestId = deliveryResyncRequestSerial + deliveryResyncOutstandingRequestId = requestId + deliveryResyncTimer = setTimeout(() => { + if (deliveryResyncOutstandingRequestId !== requestId) { + return + } + clearDeliveryResyncProbe() + // Why: no state mutation on timeout — a renderer that cannot answer has + // dead IPC, and only a reload cures that. Log once per silent streak so + // field diagnosis is captured without spamming every probe cycle. + if (deliveryResyncUnansweredWarnLogged) { + return + } + deliveryResyncUnansweredWarnLogged = true + console.warn('[pty] delivery resync probe unanswered — renderer IPC unresponsive', { + msSinceLastAck: lastAckReceivedAtMs === null ? null : Date.now() - lastAckReceivedAtMs, + ...readCurrentPtyRendererDeliveryDebugSnapshot() + }) + }, PTY_DELIVERY_RESYNC_TIMEOUT_MS) + deliveryResyncTimer.unref?.() + mainWindow.webContents.send('pty:requestDeliveryResync', { requestId }) } resetRendererInFlightDeliveryGate = () => { - disarmAckStallWatchdog() - rendererInFlightCharsByPty.clear() + clearDeliveryResyncProbe() + deliveryResyncUnansweredWarnLogged = false + rendererDeliveryAccountingByPty.clear() rendererInFlightTotalChars = 0 recordPtyRendererDeliveryPressure() } function sendPtyDataToRenderer(id: string, payload: PtyDataPayload): void { const charCount = getPtyPayloadCharCount(payload) - rendererInFlightCharsByPty.set(id, (rendererInFlightCharsByPty.get(id) ?? 0) + charCount) + const accounting = rendererDeliveryAccountingByPty.get(id) + if (accounting) { + accounting.sentChars += charCount + } else { + rendererDeliveryAccountingByPty.set(id, { sentChars: charCount, ackedChars: 0 }) + } rendererInFlightTotalChars += charCount recordPtyRendererDeliveryPressure() mainWindow.webContents.send('pty:data', payload) @@ -1677,9 +1706,9 @@ export function registerPtyHandlers( function flushPendingData(): void { flushTimer = null if (mainWindow.isDestroyed()) { - disarmAckStallWatchdog() + clearDeliveryResyncProbe() pendingData.clear() - rendererInFlightCharsByPty.clear() + rendererDeliveryAccountingByPty.clear() rendererInFlightTotalChars = 0 recordPtyRendererDeliveryPressure() return @@ -1690,7 +1719,6 @@ export function registerPtyHandlers( break } if (!canSendPtyDataToRenderer(id, { interactive: activeRendererPtys.has(id) })) { - noteAckGateBlockedSend() continue } pendingData.delete(id) @@ -1782,9 +1810,11 @@ export function registerPtyHandlers( interactiveOutputCharsByPty.delete(payload.id) rendererInFlightTotalChars = Math.max( 0, - rendererInFlightTotalChars - (rendererInFlightCharsByPty.get(payload.id) ?? 0) + rendererInFlightTotalChars - getRendererInFlightCharsForPty(payload.id) ) - rendererInFlightCharsByPty.delete(payload.id) + // Why: the renderer also drops its cumulative total on pty:exit, so a + // reused id restarts aligned at zero on both sides. + rendererDeliveryAccountingByPty.delete(payload.id) recordPtyRendererDeliveryPressure() mainWindow.webContents.send('pty:exit', payload) } @@ -1839,9 +1869,9 @@ export function registerPtyHandlers( clearTimeout(flushTimer) flushTimer = null } - disarmAckStallWatchdog() + clearDeliveryResyncProbe() pendingData.clear() - rendererInFlightCharsByPty.clear() + rendererDeliveryAccountingByPty.clear() rendererInFlightTotalChars = 0 recordPtyRendererDeliveryPressure() return @@ -1873,7 +1903,7 @@ export function registerPtyHandlers( // terminal output already handed to the renderer. The reserve is // bounded, and the per-PTY cap still prevents an active TUI runaway. if (!canSendPtyDataToRenderer(payload.id, { interactive: true })) { - noteAckGateBlockedSend() + requestDeliveryResyncForGatedPty() pendingData.set(payload.id, pending) recordPtyRendererDeliveryPressure() return @@ -1894,6 +1924,13 @@ export function registerPtyHandlers( } pendingData.set(payload.id, pending) recordPtyRendererDeliveryPressure() + // Why: probe on data arrival, not on flush skips — new output for a + // fully gated PTY is the moment stuck delivery becomes observable. + if ( + !canSendPtyDataToRenderer(payload.id, { interactive: activeRendererPtys.has(payload.id) }) + ) { + requestDeliveryResyncForGatedPty() + } if (!flushTimer) { schedulePendingDataFlush(PTY_BATCH_INTERVAL_MS) } @@ -3509,27 +3546,60 @@ export function registerPtyHandlers( // Why: renderer ACKs bound main→renderer terminal delivery without stopping // PTY ingestion. Agent/status consumers still see every chunk through the // provider/runtime path while background renderer writes wait their turn. - ipcMain.on('pty:ackData', (_event, args: { id: string; charCount: number }) => { - lastAckReceivedAtMs = Date.now() - // Why: any renderer ACK proves the ACK channel is alive; the stall window - // restarts from the next gate-blocked send. - disarmAckStallWatchdog() - const charCount = Number.isFinite(args.charCount) ? Math.max(0, args.charCount) : 0 - const current = rendererInFlightCharsByPty.get(args.id) ?? 0 - const acknowledged = Math.min(current, charCount) - const next = Math.max(0, current - charCount) - rendererInFlightTotalChars = Math.max(0, rendererInFlightTotalChars - acknowledged) - if (next === 0) { - rendererInFlightCharsByPty.delete(args.id) - } else { - rendererInFlightCharsByPty.set(args.id, next) + ipcMain.on( + 'pty:ackData', + (_event, args: { id: string; charCount?: number; processedChars?: number }) => { + lastAckReceivedAtMs = Date.now() + // Why: a live ACK channel means a future unanswered probe is a fresh + // diagnostic event, not a continuation of the last silent streak. + deliveryResyncUnansweredWarnLogged = false + let acknowledged = 0 + if (typeof args.processedChars === 'number' && Number.isFinite(args.processedChars)) { + acknowledged = applyCumulativeAck(args.id, Math.max(0, args.processedChars)) + } else { + // Why: tolerate legacy per-chunk delta payloads — dev hot-reload can + // pair an old renderer with a new main. Keyed by field presence. + const accounting = rendererDeliveryAccountingByPty.get(args.id) + const delta = Number.isFinite(args.charCount) ? Math.max(0, args.charCount ?? 0) : 0 + acknowledged = accounting ? applyCumulativeAck(args.id, accounting.ackedChars + delta) : 0 + } + tryGetProviderForPty(args.id)?.acknowledgeDataEvent(args.id, acknowledged) + recordPtyRendererDeliveryPressure() + if (pendingData.size > 0 && !flushTimer) { + schedulePendingDataFlush(0) + } } - tryGetProviderForPty(args.id)?.acknowledgeDataEvent(args.id, acknowledged) - recordPtyRendererDeliveryPressure() - if (pendingData.size > 0 && !flushTimer) { - schedulePendingDataFlush(0) + ) + + ipcMain.on( + 'pty:deliveryResyncResponse', + (_event, args: { requestId: number; processedCharsByPty: Record }) => { + if ( + deliveryResyncOutstandingRequestId === null || + args?.requestId !== deliveryResyncOutstandingRequestId + ) { + return + } + clearDeliveryResyncProbe() + deliveryResyncUnansweredWarnLogged = false + // Why: max-merge — the renderer's cumulative totals are authoritative + // for what it processed; reconciling them drains exactly the in-flight + // debt left by lost ACKs, nothing more. + for (const [id, processedChars] of Object.entries(args.processedCharsByPty ?? {})) { + if (typeof processedChars !== 'number' || !Number.isFinite(processedChars)) { + continue + } + const acknowledged = applyCumulativeAck(id, Math.max(0, processedChars)) + if (acknowledged > 0) { + tryGetProviderForPty(id)?.acknowledgeDataEvent(id, acknowledged) + } + } + recordPtyRendererDeliveryPressure() + if (pendingData.size > 0 && !flushTimer) { + schedulePendingDataFlush(0) + } } - }) + ) ipcMain.removeAllListeners('pty:setActiveRendererPty') ipcMain.on('pty:setActiveRendererPty', (_event, args: { id: string; active: boolean }) => { diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 0d37aa624ef..db8158eceff 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1152,7 +1152,12 @@ export type PreloadApi = { signal: (id: string, signal: string) => void kill: (id: string, opts?: { keepHistory?: boolean }) => Promise ackColdRestore: (id: string) => void - ackData: (id: string, charCount: number) => void + ackData: (id: string, charCount: number, processedChars?: number) => void + onDeliveryResyncRequest: (callback: (payload: { requestId: number }) => void) => () => void + respondDeliveryResync: (payload: { + requestId: number + processedCharsByPty: Record + }) => void setActiveRendererPty: (id: string, active: boolean) => void setRendererPtyVisible: (id: string, visible: boolean) => void hasChildProcesses: (id: string) => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index eff112c63e0..5a9bebd0df3 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -815,8 +815,28 @@ const api = { ackColdRestore: (id: string): void => { ipcRenderer.send('pty:ackColdRestore', { id }) }, - ackData: (id: string, charCount: number): void => { - ipcRenderer.send('pty:ackData', { id, charCount }) + /** charCount is the legacy per-chunk delta; processedChars is the + * cumulative per-pty total (self-healing under lost ACK messages). */ + ackData: (id: string, charCount: number, processedChars?: number): void => { + ipcRenderer.send('pty:ackData', { + id, + charCount, + ...(typeof processedChars === 'number' ? { processedChars } : {}) + }) + }, + /** Main asks for the renderer's cumulative processed totals when terminal + * delivery looks stuck on lost ACKs. */ + onDeliveryResyncRequest: (callback: (payload: { requestId: number }) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, payload: { requestId: number }) => + callback(payload) + ipcRenderer.on('pty:requestDeliveryResync', listener) + return () => ipcRenderer.removeListener('pty:requestDeliveryResync', listener) + }, + respondDeliveryResync: (payload: { + requestId: number + processedCharsByPty: Record + }): void => { + ipcRenderer.send('pty:deliveryResyncResponse', payload) }, setActiveRendererPty: (id: string, active: boolean): void => { ipcRenderer.send('pty:setActiveRendererPty', { id, active }) diff --git a/src/renderer/src/components/terminal-pane/pty-dispatcher-delivery-resync.test.ts b/src/renderer/src/components/terminal-pane/pty-dispatcher-delivery-resync.test.ts new file mode 100644 index 00000000000..7febefe5126 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-dispatcher-delivery-resync.test.ts @@ -0,0 +1,92 @@ +// Why: pins the renderer half of the delivery-resync protocol — the singleton +// dispatcher must answer main's probe with the cumulative processed totals +// that back its ACKs, and must drop a PTY's total once it exits. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +describe('pty dispatcher delivery resync', () => { + const originalWindow = (globalThis as { window?: typeof window }).window + + let dataCallback: + | ((payload: { id: string; data: string; rawLength?: number; background?: boolean }) => void) + | null = null + let exitCallback: ((payload: { id: string; code: number }) => void) | null = null + let resyncRequestCallback: ((payload: { requestId: number }) => void) | null = null + const ackDataMock = vi.fn() + const respondDeliveryResyncMock = vi.fn() + + beforeEach(() => { + vi.resetModules() + dataCallback = null + exitCallback = null + resyncRequestCallback = null + ackDataMock.mockClear() + respondDeliveryResyncMock.mockClear() + ;(globalThis as { window: typeof window }).window = { + ...originalWindow, + api: { + ...originalWindow?.api, + pty: { + ...originalWindow?.api?.pty, + ackData: ackDataMock, + onData: vi.fn( + ( + cb: (payload: { + id: string + data: string + rawLength?: number + background?: boolean + }) => void + ) => { + dataCallback ??= cb + return () => {} + } + ), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn((cb: (payload: { id: string; code: number }) => void) => { + exitCallback ??= cb + return () => {} + }), + onDeliveryResyncRequest: vi.fn((cb: (payload: { requestId: number }) => void) => { + resyncRequestCallback ??= cb + return () => {} + }), + respondDeliveryResync: respondDeliveryResyncMock + } + } + } as unknown as typeof window + }) + + afterEach(() => { + if (originalWindow) { + ;(globalThis as { window: typeof window }).window = originalWindow + } else { + delete (globalThis as { window?: typeof window }).window + } + }) + + it('answers delivery resync probes with cumulative totals and drops exited PTYs', async () => { + const { ensurePtyDispatcher } = await import('./pty-dispatcher') + ensurePtyDispatcher() + + dataCallback?.({ id: 'pty-1', data: 'hello' }) + dataCallback?.({ id: 'pty-1', data: 'world!!', rawLength: 7 }) + dataCallback?.({ id: 'pty-2', data: 'abc' }) + + expect(ackDataMock).toHaveBeenNthCalledWith(1, 'pty-1', 5, 5) + expect(ackDataMock).toHaveBeenNthCalledWith(2, 'pty-1', 7, 12) + expect(ackDataMock).toHaveBeenNthCalledWith(3, 'pty-2', 3, 3) + + resyncRequestCallback?.({ requestId: 7 }) + expect(respondDeliveryResyncMock).toHaveBeenCalledWith({ + requestId: 7, + processedCharsByPty: { 'pty-1': 12, 'pty-2': 3 } + }) + + exitCallback?.({ id: 'pty-1', code: 0 }) + resyncRequestCallback?.({ requestId: 8 }) + expect(respondDeliveryResyncMock).toHaveBeenLastCalledWith({ + requestId: 8, + processedCharsByPty: { 'pty-2': 3 } + }) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-dispatcher-pi-routing.test.ts b/src/renderer/src/components/terminal-pane/pty-dispatcher-pi-routing.test.ts index 4f8c4da2e24..b93adeeefd4 100644 --- a/src/renderer/src/components/terminal-pane/pty-dispatcher-pi-routing.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-dispatcher-pi-routing.test.ts @@ -91,7 +91,7 @@ describe('dispatcher → transport → onTitleChange for Pi spinner', () => { dispatcherCallback?.({ id: 'pty-pi', data: 'chunk', rawLength: 10, background: true } as never) expect(handler).toHaveBeenCalledWith('chunk', { rawLength: 10, background: true }) - expect(window.api.pty.ackData).toHaveBeenCalledWith('pty-pi', 10) + expect(window.api.pty.ackData).toHaveBeenCalledWith('pty-pi', 10, 10) ptyDataHandlers.delete('pty-pi') }) @@ -285,7 +285,7 @@ describe('dispatcher → transport → onTitleChange for Pi spinner', () => { dispatcherCallback?.({ id: 'pty-early', data: 'setup starts here\r\n', rawLength: 19 }) expect(onData).not.toHaveBeenCalled() - expect(window.api.pty.ackData).toHaveBeenCalledWith('pty-early', 19) + expect(window.api.pty.ackData).toHaveBeenCalledWith('pty-early', 19, 19) resolveSpawn({ id: 'pty-early' }) await connectPromise diff --git a/src/renderer/src/components/terminal-pane/pty-dispatcher.ts b/src/renderer/src/components/terminal-pane/pty-dispatcher.ts index c7f31344c55..dae4fc52fb8 100644 --- a/src/renderer/src/components/terminal-pane/pty-dispatcher.ts +++ b/src/renderer/src/components/terminal-pane/pty-dispatcher.ts @@ -6,7 +6,12 @@ * and the eager-buffer reconnection logic share. */ import { TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT } from '../../../../shared/terminal-scrollback-limits' -import { ackPtyData, exposeE2eTerminalPtyAckGate } from './terminal-pty-ack-gate' +import { + ackPtyData, + clearProcessedPtyCharTotal, + exposeE2eTerminalPtyAckGate, + getProcessedPtyCharTotals +} from './terminal-pty-ack-gate' import { clampUtf8Tail, type EagerBufferChunk } from './pty-eager-buffer-clamp' import { bufferPreHandlerPtyData, @@ -151,6 +156,9 @@ export function ensurePtyDispatcher(): void { ptyReplayHandlers.get(payload.id)?.(payload.data) }) window.api.pty.onExit((payload) => { + // Why: main drops its delivery accounting for this pty on exit; drop the + // cumulative total too so a reused id restarts at zero on both sides. + clearProcessedPtyCharTotal(payload.id) const handler = ptyExitHandlers.get(payload.id) if (handler) { clearPreHandlerPtyState(payload.id) @@ -167,6 +175,15 @@ export function ensurePtyDispatcher(): void { } } }) + // Why: main probes when delivery looks stuck on lost ACKs (data arriving + // for a fully gated PTY). Replying with the cumulative processed totals + // lets main reconcile verified state instead of resetting blindly. + window.api.pty.onDeliveryResyncRequest?.((payload) => { + window.api.pty.respondDeliveryResync?.({ + requestId: payload.requestId, + processedCharsByPty: getProcessedPtyCharTotals() + }) + }) } export function subscribeToPtyExit(ptyId: string, watcher: (code: number) => void): () => void { diff --git a/src/renderer/src/components/terminal-pane/terminal-pty-ack-gate.test.ts b/src/renderer/src/components/terminal-pane/terminal-pty-ack-gate.test.ts new file mode 100644 index 00000000000..d61de3065cd --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pty-ack-gate.test.ts @@ -0,0 +1,50 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/e2e-config', () => ({ e2eConfig: { exposeStore: false } })) + +describe('terminal-pty-ack-gate cumulative totals', () => { + const ackDataMock = vi.fn() + + beforeEach(() => { + // Why: the cumulative totals are module state; a fresh module per test + // mirrors a fresh renderer page (renderer lifecycle reset). + vi.resetModules() + ackDataMock.mockClear() + ;(window as unknown as { api: unknown }).api = { pty: { ackData: ackDataMock } } + }) + + afterEach(() => { + delete (window as unknown as { api?: unknown }).api + }) + + async function loadAckGate() { + return await import('./terminal-pty-ack-gate') + } + + it('sends monotonic cumulative totals alongside per-chunk deltas', async () => { + const { ackPtyData, getProcessedPtyCharTotals } = await loadAckGate() + + ackPtyData('pty-a', 5) + ackPtyData('pty-a', 7) + ackPtyData('pty-b', 3) + + expect(ackDataMock).toHaveBeenNthCalledWith(1, 'pty-a', 5, 5) + expect(ackDataMock).toHaveBeenNthCalledWith(2, 'pty-a', 7, 12) + expect(ackDataMock).toHaveBeenNthCalledWith(3, 'pty-b', 3, 3) + expect(getProcessedPtyCharTotals()).toEqual({ 'pty-a': 12, 'pty-b': 3 }) + }) + + it('clears a PTY total so a reused id restarts from zero on both sides', async () => { + const { ackPtyData, clearProcessedPtyCharTotal, getProcessedPtyCharTotals } = + await loadAckGate() + + ackPtyData('pty-a', 9) + clearProcessedPtyCharTotal('pty-a') + + expect(getProcessedPtyCharTotals()).toEqual({}) + + ackPtyData('pty-a', 4) + expect(ackDataMock).toHaveBeenLastCalledWith('pty-a', 4, 4) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-pty-ack-gate.ts b/src/renderer/src/components/terminal-pane/terminal-pty-ack-gate.ts index bea658434be..3b88e84ea8d 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pty-ack-gate.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pty-ack-gate.ts @@ -18,13 +18,26 @@ type E2eTerminalPtyAckGateWindow = Window & { const e2eTerminalAckGatePtyIds = new Set() const e2eTerminalAckGateHeldChars = new Map() +// Why: monotonic per-PTY totals of processed chars, mirrored to main as +// TCP-style cumulative ACKs so a lost ACK message never becomes permanent +// in-flight debt. Cleared on pty:exit so a reused id restarts aligned with +// main's fresh accounting; a renderer reload resets it with the page. +const processedPtyCharTotals = new Map() + +function sendPtyAck(ptyId: string, chars: number): void { + const processedChars = (processedPtyCharTotals.get(ptyId) ?? 0) + chars + processedPtyCharTotals.set(ptyId, processedChars) + // Why: keep the legacy per-chunk delta alongside the cumulative total so an + // older main (dev hot-reload mix) still credits deltas. + window.api.pty.ackData?.(ptyId, chars, processedChars) +} function releaseE2eTerminalAckGate(): void { const held = Array.from(e2eTerminalAckGateHeldChars.entries()) e2eTerminalAckGatePtyIds.clear() e2eTerminalAckGateHeldChars.clear() for (const [ptyId, chars] of held) { - window.api.pty.ackData?.(ptyId, chars) + sendPtyAck(ptyId, chars) } } @@ -58,9 +71,19 @@ export function exposeE2eTerminalPtyAckGate(): void { } export function ackPtyData(ptyId: string, chars: number): void { + // Why: held e2e-gate chars stay out of the cumulative total too, so a + // delivery-resync probe cannot leak them past the simulated backpressure. if (e2eTerminalAckGatePtyIds.has(ptyId)) { e2eTerminalAckGateHeldChars.set(ptyId, (e2eTerminalAckGateHeldChars.get(ptyId) ?? 0) + chars) return } - window.api.pty.ackData?.(ptyId, chars) + sendPtyAck(ptyId, chars) +} + +export function getProcessedPtyCharTotals(): Record { + return Object.fromEntries(processedPtyCharTotals) +} + +export function clearProcessedPtyCharTotal(ptyId: string): void { + processedPtyCharTotals.delete(ptyId) } diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 1d3d4629a71..22ffaa18c39 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -2544,6 +2544,8 @@ function createPtyApi(): NonNullable['pty']> { kill: () => Promise.resolve(), ackColdRestore: () => {}, ackData: () => {}, + onDeliveryResyncRequest: () => noopUnsubscribe, + respondDeliveryResync: () => {}, setActiveRendererPty: () => {}, setRendererPtyVisible: () => {}, hasChildProcesses: () => Promise.resolve(false),