perf: share synthetic title spinner timer (#2596)

This commit is contained in:
Neil 2026-05-21 21:52:26 -07:00 committed by GitHub
parent cef5570188
commit c1dcdb41e1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 204 additions and 36 deletions

View File

@ -87,6 +87,11 @@ import {
shouldRecoverRendererAfterProcessGone,
type ExpectedTeardownScope
} from './crash-reporting/process-gone-classification'
import {
advanceSyntheticTitleSpinnerEntries,
type SyntheticTitleSpinnerEntry
} from './synthetic-title-spinner'
import { shouldSendSyntheticTitleFrame } from './synthetic-title-visibility'
import { isCrashReportReason } from '../shared/crash-reporting'
import { KeybindingService } from './keybindings/keybinding-service'
@ -422,16 +427,17 @@ function openMainWindow(): BrowserWindow {
// window recreations instead of stacking on top of stale listeners.
agentHookServer.setListener(null)
setMigrationUnsupportedPtyListener(null)
// Why: any running synthesized-title spinner intervals would fire into a
// destroyed webContents; stop them all here instead of deferring to
// per-pane teardown, which may never run for restored-but-never-torn-down
// panes when the window goes away. stopSyntheticTitleSpinner deletes only
// the current entry, which the Map iterator handles safely.
for (const paneKey of syntheticTitleSpinnerByPaneKey.keys()) {
stopSyntheticTitleSpinner(paneKey)
}
// Why: any running synthesized-title spinner timer would fire into a
// destroyed webContents; stop it here instead of deferring to per-pane
// teardown, which may never run for restored-but-never-torn-down panes
// when the window goes away.
stopAllSyntheticTitleSpinners()
})
mainWindow = window
window.on('show', resumeSyntheticTitleSpinnerTimer)
window.on('restore', resumeSyntheticTitleSpinnerTimer)
window.on('hide', stopSyntheticTitleSpinnerTimer)
window.on('minimize', stopSyntheticTitleSpinnerTimer)
agentHookServer.setListener(
({ paneKey, tabId, worktreeId, connectionId, payload, receivedAt, stateStartedAt }) => {
if (mainWindow?.isDestroyed()) {
@ -634,8 +640,9 @@ const SYNTHETIC_TITLE_PROFILES: Record<string, SyntheticTitleProfile> = {
const syntheticTitleSpinnerByPaneKey = new Map<
string,
{ timer: ReturnType<typeof setInterval>; frame: number; profile: SyntheticTitleProfile }
SyntheticTitleSpinnerEntry<SyntheticTitleProfile>
>()
let syntheticTitleSpinnerTimer: ReturnType<typeof setInterval> | null = null
type ServeOptions = {
json: boolean
@ -748,29 +755,107 @@ function installServeSignalHandlers(): void {
process.once('SIGTERM', quit)
}
// Why: on PTY teardown the paneKey→ptyId mapping is dropped, so the spinner
// interval would keep firing but sendSyntheticTitle would no-op forever.
// Stop the interval explicitly so the process doesn't carry a timer per dead
// pane.
// Why: on PTY teardown the paneKey mapping is dropped, so the spinner tick
// would keep firing but sendSyntheticTitle would no-op forever. Drop the
// entry explicitly so the shared timer shuts down once no panes are active.
registerPaneKeyTeardownListener((paneKey) => {
stopSyntheticTitleSpinner(paneKey)
})
function sendSyntheticTitle(ptyId: string, data: string): void {
function sendSyntheticTitle(ptyId: string, data: string, options: { force?: boolean } = {}): void {
if (!mainWindow || mainWindow.isDestroyed()) {
return
}
// Why: repeated working-spinner frames are decorative and can arrive every
// 80ms per agent. Final/permission frames are forced because they drive BEL.
if (
!shouldSendSyntheticTitleFrame({
force: options.force === true,
windowVisible: isSyntheticTitleWindowVisible()
})
) {
return
}
mainWindow.webContents.send('pty:data', { id: ptyId, data })
}
function isSyntheticTitleWindowVisible(): boolean {
return (
mainWindow !== null &&
!mainWindow.isDestroyed() &&
mainWindow.isVisible() &&
!mainWindow.isMinimized()
)
}
function canSendDecorativeSyntheticTitle(): boolean {
return shouldSendSyntheticTitleFrame({
force: false,
windowVisible: isSyntheticTitleWindowVisible()
})
}
function stopSyntheticTitleSpinner(paneKey: string): void {
const entry = syntheticTitleSpinnerByPaneKey.get(paneKey)
if (entry) {
clearInterval(entry.timer)
syntheticTitleSpinnerByPaneKey.delete(paneKey)
if (syntheticTitleSpinnerByPaneKey.delete(paneKey)) {
stopSyntheticTitleSpinnerTimerIfIdle()
}
}
function stopAllSyntheticTitleSpinners(): void {
syntheticTitleSpinnerByPaneKey.clear()
stopSyntheticTitleSpinnerTimer()
}
function stopSyntheticTitleSpinnerTimer(): void {
if (!syntheticTitleSpinnerTimer) {
return
}
clearInterval(syntheticTitleSpinnerTimer)
syntheticTitleSpinnerTimer = null
}
function stopSyntheticTitleSpinnerTimerIfIdle(): void {
if (syntheticTitleSpinnerByPaneKey.size === 0) {
stopSyntheticTitleSpinnerTimer()
}
}
function tickSyntheticTitleSpinners(): void {
if (!canSendDecorativeSyntheticTitle()) {
stopSyntheticTitleSpinnerTimer()
return
}
const ticks = advanceSyntheticTitleSpinnerEntries({
entries: syntheticTitleSpinnerByPaneKey,
frameCount: SPINNER_FRAMES.length,
getPtyIdForPaneKey
})
for (const tick of ticks) {
sendSyntheticTitle(
tick.ptyId,
`\x1b]0;${SPINNER_FRAMES[tick.frame]} ${tick.profile.workingLabel}\x07`
)
}
stopSyntheticTitleSpinnerTimerIfIdle()
}
function ensureSyntheticTitleSpinnerTimer(): void {
if (
syntheticTitleSpinnerTimer ||
syntheticTitleSpinnerByPaneKey.size === 0 ||
!canSendDecorativeSyntheticTitle()
) {
return
}
// Why: a single process timer covers all synthesized title spinners; per-pane
// intervals multiplied idle wakeups when several retained agents were working.
syntheticTitleSpinnerTimer = setInterval(tickSyntheticTitleSpinners, SPINNER_INTERVAL_MS)
}
function resumeSyntheticTitleSpinnerTimer(): void {
ensureSyntheticTitleSpinnerTimer()
}
function driveSyntheticTitleFromHook(
paneKey: string,
state: string,
@ -794,23 +879,8 @@ function driveSyntheticTitleFromHook(
existing.profile = profile
return
}
const timer = setInterval(() => {
const ptyIdNow = getPtyIdForPaneKey(paneKey)
if (!ptyIdNow) {
stopSyntheticTitleSpinner(paneKey)
return
}
const cur = syntheticTitleSpinnerByPaneKey.get(paneKey)
if (!cur) {
return
}
cur.frame = (cur.frame + 1) % SPINNER_FRAMES.length
sendSyntheticTitle(
ptyIdNow,
`\x1b]0;${SPINNER_FRAMES[cur.frame]} ${cur.profile.workingLabel}\x07`
)
}, SPINNER_INTERVAL_MS)
syntheticTitleSpinnerByPaneKey.set(paneKey, { timer, frame, profile })
syntheticTitleSpinnerByPaneKey.set(paneKey, { frame, profile })
ensureSyntheticTitleSpinnerTimer()
return
}
// Why: leaving the spinner running after a `blocked`/`waiting`/`done` event
@ -826,7 +896,7 @@ function driveSyntheticTitleFromHook(
stopSyntheticTitleSpinner(paneKey)
const label =
state === 'blocked' || state === 'waiting' ? profile.permissionLabel : profile.idleLabel
sendSyntheticTitle(ptyId, `\x1b]0;${label}\x07\x07`)
sendSyntheticTitle(ptyId, `\x1b]0;${label}\x07\x07`, { force: true })
}
app.whenReady().then(async () => {

View File

@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest'
import {
advanceSyntheticTitleSpinnerEntries,
type SyntheticTitleSpinnerEntry
} from './synthetic-title-spinner'
describe('advanceSyntheticTitleSpinnerEntries', () => {
it('advances live panes in one shared tick and wraps frames', () => {
const entries = new Map<string, SyntheticTitleSpinnerEntry<{ label: string }>>([
['pane-a', { frame: 1, profile: { label: 'A' } }],
['pane-b', { frame: 2, profile: { label: 'B' } }]
])
const ticks = advanceSyntheticTitleSpinnerEntries({
entries,
frameCount: 3,
getPtyIdForPaneKey: (paneKey) => `pty-${paneKey}`
})
expect(ticks).toEqual([
{ paneKey: 'pane-a', ptyId: 'pty-pane-a', frame: 2, profile: { label: 'A' } },
{ paneKey: 'pane-b', ptyId: 'pty-pane-b', frame: 0, profile: { label: 'B' } }
])
expect(entries.get('pane-a')?.frame).toBe(2)
expect(entries.get('pane-b')?.frame).toBe(0)
})
it('drops panes whose pty mapping disappeared', () => {
const entries = new Map<string, SyntheticTitleSpinnerEntry<{ label: string }>>([
['live-pane', { frame: 0, profile: { label: 'live' } }],
['stale-pane', { frame: 0, profile: { label: 'stale' } }]
])
const ticks = advanceSyntheticTitleSpinnerEntries({
entries,
frameCount: 4,
getPtyIdForPaneKey: (paneKey) => (paneKey === 'stale-pane' ? null : `pty-${paneKey}`)
})
expect(ticks).toEqual([
{ paneKey: 'live-pane', ptyId: 'pty-live-pane', frame: 1, profile: { label: 'live' } }
])
expect(entries.has('live-pane')).toBe(true)
expect(entries.has('stale-pane')).toBe(false)
})
})

View File

@ -0,0 +1,33 @@
export type SyntheticTitleSpinnerEntry<TProfile> = {
frame: number
profile: TProfile
}
export type SyntheticTitleSpinnerTick<TProfile> = {
paneKey: string
ptyId: string
frame: number
profile: TProfile
}
export function advanceSyntheticTitleSpinnerEntries<TProfile>(args: {
entries: Map<string, SyntheticTitleSpinnerEntry<TProfile>>
frameCount: number
getPtyIdForPaneKey: (paneKey: string) => string | null | undefined
}): SyntheticTitleSpinnerTick<TProfile>[] {
if (args.frameCount <= 0) {
return []
}
const ticks: SyntheticTitleSpinnerTick<TProfile>[] = []
for (const [paneKey, entry] of args.entries) {
const ptyId = args.getPtyIdForPaneKey(paneKey)
if (!ptyId) {
args.entries.delete(paneKey)
continue
}
entry.frame = (entry.frame + 1) % args.frameCount
ticks.push({ paneKey, ptyId, frame: entry.frame, profile: entry.profile })
}
return ticks
}

View File

@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest'
import { shouldSendSyntheticTitleFrame } from './synthetic-title-visibility'
describe('shouldSendSyntheticTitleFrame', () => {
it('skips decorative spinner frames only while the window is hidden', () => {
expect(shouldSendSyntheticTitleFrame({ force: false, windowVisible: false })).toBe(false)
expect(shouldSendSyntheticTitleFrame({ force: false, windowVisible: true })).toBe(true)
})
it('always sends forced terminal-state frames', () => {
expect(shouldSendSyntheticTitleFrame({ force: true, windowVisible: false })).toBe(true)
})
})

View File

@ -0,0 +1,6 @@
export function shouldSendSyntheticTitleFrame(args: {
force: boolean
windowVisible: boolean
}): boolean {
return args.force || args.windowVisible
}