Reduce serial startup waits before first window (#1823)

* Avoid local scrollback serialization on shutdown

* Reduce serial startup waits before first window
This commit is contained in:
Neil
2026-05-14 01:22:42 -07:00
committed by GitHub
parent ee2b8b861c
commit dbeb89436a
10 changed files with 263 additions and 90 deletions
+1 -1
View File
@@ -560,7 +560,7 @@ function App(): React.JSX.Element {
}
for (const capture of shutdownBufferCaptures.values()) {
try {
capture()
capture({ includeLocalBuffers: false })
} catch {
// Don't let one pane's failure block the rest.
}
@@ -33,6 +33,7 @@ import { useTerminalPaneLifecycle } from './use-terminal-pane-lifecycle'
import { useTerminalPaneContextMenu } from './use-terminal-pane-context-menu'
import { useNotificationDispatch } from './use-notification-dispatch'
import { connectPanePty } from './pty-connection'
import { shouldPreserveTerminalScrollbackBuffers } from '../../../../shared/workspace-session-terminal-buffers'
import {
getFitOverrideForPty,
getPaneIdsForPty,
@@ -905,7 +906,7 @@ export default function TerminalPane({
// Register a capture callback for shutdown. The beforeunload handler in
// App.tsx calls all registered callbacks to serialize terminal buffers.
useEffect(() => {
const captureBuffers = (): void => {
const captureBuffers = (options?: { includeLocalBuffers?: boolean }): void => {
const manager = managerRef.current
const container = containerRef.current
if (!manager || !container) {
@@ -921,14 +922,22 @@ export default function TerminalPane({
// bytes. Without preservation, that empty pass would wipe a known-good
// buffer. Merge prior state in for leaves whose live capture came back
// empty. Same shape as persistLayoutSnapshot.
const existing = useAppStore.getState().terminalLayoutsByTabId[tabId]
const state = useAppStore.getState()
const existing = state.terminalLayoutsByTabId[tabId]
const includeLocalBuffers = options?.includeLocalBuffers ?? true
const shouldCaptureScrollbackBuffers = includeLocalBuffers
? true
: shouldPreserveTerminalScrollbackBuffers(worktreeId, state.repos)
const layout = captureTerminalShutdownLayout({
manager,
container,
expandedPaneId: expandedPaneIdRef.current,
paneTransports: paneTransportsRef.current,
paneTitlesByPaneId: paneTitlesRef.current,
existingLayout: existing
existingLayout: existing,
// Why: beforeunload skips local/floating bytes because session payloads
// immediately prune them; worktree sleep keeps them as defense-in-depth.
captureBuffers: shouldCaptureScrollbackBuffers
})
setTabLayout(tabId, layout)
}
@@ -940,7 +949,7 @@ export default function TerminalPane({
shutdownBufferCaptures.delete(tabId)
}
}
}, [tabId, setTabLayout])
}, [tabId, worktreeId, setTabLayout])
const handleStartRename = useCallback((paneId: number) => {
setRenameValue(paneTitlesRef.current[paneId] ?? '')
@@ -1,3 +1,7 @@
export type ShutdownBufferCaptureOptions = {
includeLocalBuffers?: boolean
}
/** Map of tabId → buffer-capture callback, one per mounted TerminalPane.
* The beforeunload handler in App.tsx invokes every callback to populate
* Zustand with serialized buffers before flushing the session to disk.
@@ -12,4 +16,7 @@
* create a cycle (slice → TerminalPane → store → slice) that breaks the
* Zustand store at module-init time. A leaf module with zero imports
* has no cycle. */
export const shutdownBufferCaptures = new Map<string, () => void>()
export const shutdownBufferCaptures = new Map<
string,
(options?: ShutdownBufferCaptureOptions) => void
>()
@@ -1,4 +1,4 @@
import { beforeAll, describe, expect, it, vi } from 'vitest'
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import type { TerminalLayoutSnapshot } from '../../../../shared/types'
const mocks = vi.hoisted(() => ({
@@ -36,6 +36,10 @@ beforeAll(() => {
;(globalThis as unknown as Record<string, unknown>).HTMLElement = MockHTMLElement
})
beforeEach(() => {
mocks.flushTerminalOutput.mockReset()
})
function mockRootForPane(paneId: number): HTMLDivElement {
const pane = new MockHTMLElement({ classList: ['pane'], dataset: { paneId: String(paneId) } })
return new MockHTMLElement({ firstElementChild: pane }) as unknown as HTMLDivElement
@@ -88,4 +92,40 @@ describe('captureTerminalShutdownLayout', () => {
titlesByLeafId: { 'pane:1': 'build logs' }
})
})
it('skips local shutdown scrollback serialization while preserving layout metadata', async () => {
const { captureTerminalShutdownLayout } = await import('./terminal-shutdown-layout-capture')
const pane = {
id: 1,
terminal: { options: { scrollback: 50_000 } },
serializeAddon: {
serialize: vi.fn(() => 'x'.repeat(512 * 1024))
}
}
const manager = {
getPanes: vi.fn(() => [pane]),
getActivePane: vi.fn(() => pane)
}
const layout = captureTerminalShutdownLayout({
manager: manager as never,
container: mockRootForPane(1),
expandedPaneId: null,
paneTransports: new Map([[1, { getPtyId: vi.fn(() => 'pty-1') }]]),
paneTitlesByPaneId: { 1: 'local shell' },
existingLayout: {
root: null,
activeLeafId: null,
expandedLeafId: null,
buffersByLeafId: { 'pane:1': 'previous-local-scrollback' }
},
captureBuffers: false
})
expect(mocks.flushTerminalOutput).not.toHaveBeenCalled()
expect(pane.serializeAddon.serialize).not.toHaveBeenCalled()
expect(layout.buffersByLeafId).toBeUndefined()
expect(layout.ptyIdsByLeafId).toEqual({ 'pane:1': 'pty-1' })
expect(layout.titlesByLeafId).toEqual({ 'pane:1': 'local shell' })
})
})
@@ -21,6 +21,7 @@ type CaptureTerminalShutdownLayoutArgs = {
paneTransports: ReadonlyMap<number, Pick<PtyTransport, 'getPtyId'>>
paneTitlesByPaneId: Record<number, string>
existingLayout: TerminalLayoutSnapshot | undefined
captureBuffers?: boolean
}
export function captureTerminalShutdownLayout({
@@ -29,41 +30,44 @@ export function captureTerminalShutdownLayout({
expandedPaneId,
paneTransports,
paneTitlesByPaneId,
existingLayout
existingLayout,
captureBuffers = true
}: CaptureTerminalShutdownLayoutArgs): TerminalLayoutSnapshot {
const panes = manager.getPanes()
const buffers: Record<string, string> = {}
for (const pane of panes) {
try {
// Why: non-focused panes may have renderer-throttled PTY bytes queued;
// push them into xterm before taking the shutdown scrollback snapshot.
flushTerminalOutput(pane.terminal)
const leafId = paneLeafId(pane.id)
let scrollback = pane.terminal.options.scrollback ?? 10_000
let serialized = pane.serializeAddon.serialize({ scrollback })
// Cap at 512KB — binary search for largest scrollback that fits.
if (serialized.length > MAX_BUFFER_BYTES && scrollback > 1) {
let lo = 1
let hi = scrollback
let best = ''
while (lo <= hi) {
const mid = Math.floor((lo + hi) / 2)
const attempt = pane.serializeAddon.serialize({ scrollback: mid })
if (attempt.length <= MAX_BUFFER_BYTES) {
best = attempt
lo = mid + 1
} else {
hi = mid - 1
if (captureBuffers) {
for (const pane of panes) {
try {
// Why: non-focused panes may have renderer-throttled PTY bytes queued;
// push them into xterm before taking the shutdown scrollback snapshot.
flushTerminalOutput(pane.terminal)
const leafId = paneLeafId(pane.id)
let scrollback = pane.terminal.options.scrollback ?? 10_000
let serialized = pane.serializeAddon.serialize({ scrollback })
// Cap at 512KB — binary search for largest scrollback that fits.
if (serialized.length > MAX_BUFFER_BYTES && scrollback > 1) {
let lo = 1
let hi = scrollback
let best = ''
while (lo <= hi) {
const mid = Math.floor((lo + hi) / 2)
const attempt = pane.serializeAddon.serialize({ scrollback: mid })
if (attempt.length <= MAX_BUFFER_BYTES) {
best = attempt
lo = mid + 1
} else {
hi = mid - 1
}
}
serialized = best
}
serialized = best
if (serialized.length > 0) {
buffers[leafId] = serialized
}
} catch {
// Serialization failure for one pane should not block others.
}
if (serialized.length > 0) {
buffers[leafId] = serialized
}
} catch {
// Serialization failure for one pane should not block others.
}
}
@@ -74,11 +78,13 @@ export function captureTerminalShutdownLayout({
.map((pane) => [paneLeafId(pane.id), paneTransports.get(pane.id)?.getPtyId() ?? null] as const)
.filter((entry): entry is readonly [string, string] => entry[1] !== null)
const mergedBuffers = mergeCapturedLeafState({
prior: existingLayout?.buffersByLeafId,
fresh: buffers,
currentLeafIds
})
const mergedBuffers = captureBuffers
? mergeCapturedLeafState({
prior: existingLayout?.buffersByLeafId,
fresh: buffers,
currentLeafIds
})
: {}
const mergedPtyIds = mergeCapturedLeafState({
prior: existingLayout?.ptyIdsByLeafId,
fresh: Object.fromEntries(ptyEntries),