fix: harden perf cleanup edge cases (#2712)

This commit is contained in:
Neil 2026-05-23 13:38:05 -07:00 committed by GitHub
parent f286702c62
commit 1ea9aeb362
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 272 additions and 48 deletions

View File

@ -3530,5 +3530,45 @@ describe('registerPtyHandlers', () => {
vi.useRealTimers()
}
})
it('serializes overlapping headless snapshots for the same PTY', async () => {
let resolveFirst!: (snapshot: { data: string; cols: number; rows: number }) => void
let resolveSecond!: (snapshot: { data: string; cols: number; rows: number }) => void
const runtime = {
setPtyController: vi.fn(),
onPtySpawned: vi.fn(),
onPtyData: vi.fn(),
onPtyExit: vi.fn(),
preAllocateHandleForPty: vi.fn(),
serializeHeadlessTerminalBufferForRenderer: vi
.fn()
.mockImplementationOnce(
() =>
new Promise<{ data: string; cols: number; rows: number }>((resolve) => {
resolveFirst = resolve
})
)
.mockImplementationOnce(
() =>
new Promise<{ data: string; cols: number; rows: number }>((resolve) => {
resolveSecond = resolve
})
)
}
handlers.clear()
registerPtyHandlers(mainWindow as never, runtime as never)
const first = handlers.get('pty:serializeHeadlessBuffer')!(null, { id: 'pty-1' })
const second = handlers.get('pty:serializeHeadlessBuffer')!(null, { id: 'pty-1' })
expect(runtime.serializeHeadlessTerminalBufferForRenderer).toHaveBeenCalledTimes(1)
resolveFirst({ data: 'first', cols: 80, rows: 24 })
await expect(first).resolves.toEqual({ data: 'first', cols: 80, rows: 24 })
await Promise.resolve()
expect(runtime.serializeHeadlessTerminalBufferForRenderer).toHaveBeenCalledTimes(2)
resolveSecond({ data: 'second', cols: 80, rows: 24 })
await expect(second).resolves.toEqual({ data: 'second', cols: 80, rows: 24 })
})
})
})

View File

@ -658,8 +658,11 @@ export function registerPtyHandlers(
// reduces IPC round-trips from hundreds/sec to ~120/sec under high
// throughput. Keystroke echo/redraws bypass this below because agent TUIs
// already spend tens of ms producing their redraw.
type HeadlessRendererSnapshot = { data: string; cols: number; rows: number } | null
const pendingData = new Map<string, string>()
const headlessSnapshotHeldPtyIds = new Set<string>()
const headlessSnapshotHoldCounts = new Map<string, number>()
const headlessSnapshotQueues = new Map<string, Promise<HeadlessRendererSnapshot>>()
const trustedTerminalHandleEnv = new Set<string>()
let flushTimer: ReturnType<typeof setTimeout> | null = null
const PTY_BATCH_INTERVAL_MS = 8
@ -670,7 +673,7 @@ export function registerPtyHandlers(
const hasFlushablePendingData = (): boolean => {
for (const id of pendingData.keys()) {
if (!headlessSnapshotHeldPtyIds.has(id)) {
if (!headlessSnapshotHoldCounts.has(id)) {
return true
}
}
@ -684,7 +687,7 @@ export function registerPtyHandlers(
return
}
for (const [id, data] of pendingData) {
if (headlessSnapshotHeldPtyIds.has(id)) {
if (headlessSnapshotHoldCounts.has(id)) {
continue
}
mainWindow.webContents.send('pty:data', { id, data })
@ -720,6 +723,62 @@ export function registerPtyHandlers(
return data
}
const captureHeadlessSnapshotForRenderer = async (
id: string,
opts: { scrollbackRows?: number }
): Promise<HeadlessRendererSnapshot> => {
if (!runtime) {
return null
}
// Why: hidden-pane reveal uses the headless snapshot as the authoritative
// paint. Hold any ≤8ms main-process PTY batches while serializing: a
// successful headless snapshot already contains them, while a null
// snapshot must release them so the renderer fallback can replay them.
const pendingBeforeSnapshot = takePendingDataForPty(id)
const holdCount = headlessSnapshotHoldCounts.get(id) ?? 0
headlessSnapshotHoldCounts.set(id, holdCount + 1)
const releaseSnapshotHold = (): void => {
const current = headlessSnapshotHoldCounts.get(id) ?? 0
if (current <= 1) {
headlessSnapshotHoldCounts.delete(id)
return
}
headlessSnapshotHoldCounts.set(id, current - 1)
}
let snapshot: HeadlessRendererSnapshot
try {
snapshot = await runtime.serializeHeadlessTerminalBufferForRenderer(id, opts)
} catch (err) {
const pendingDuringSnapshot = takePendingDataForPty(id)
releaseSnapshotHold()
sendPtyDataToRenderer(id, pendingBeforeSnapshot + pendingDuringSnapshot)
throw err
}
const pendingDuringSnapshot = takePendingDataForPty(id)
releaseSnapshotHold()
if (!snapshot) {
sendPtyDataToRenderer(id, pendingBeforeSnapshot + pendingDuringSnapshot)
}
return snapshot
}
const queueHeadlessSnapshotForRenderer = (
id: string,
opts: { scrollbackRows?: number }
): Promise<HeadlessRendererSnapshot> => {
const previous = headlessSnapshotQueues.get(id)
const next = previous
? previous.catch(() => null).then(() => captureHeadlessSnapshotForRenderer(id, opts))
: captureHeadlessSnapshotForRenderer(id, opts)
const tracked = next.finally(() => {
if (headlessSnapshotQueues.get(id) === tracked) {
headlessSnapshotQueues.delete(id)
}
})
headlessSnapshotQueues.set(id, tracked)
return tracked
}
// Why: extracted so the "Restart daemon" flow can rebind against the fresh
// adapter after replaceDaemonProvider runs. Both the startup registration
// and the post-restart rebind go through the same code path — no risk of
@ -758,7 +817,7 @@ export function registerPtyHandlers(
nextData.length <= INTERACTIVE_OUTPUT_MAX_CHARS &&
lastInputAt !== undefined &&
performance.now() - lastInputAt <= INTERACTIVE_OUTPUT_WINDOW_MS
if (isInteractiveOutput && !headlessSnapshotHeldPtyIds.has(payload.id)) {
if (isInteractiveOutput && !headlessSnapshotHoldCounts.has(payload.id)) {
pendingData.delete(payload.id)
clearFlushTimerIfIdle()
// Why: agent TUIs redraw small prompt regions after every keystroke.
@ -1868,27 +1927,7 @@ export function registerPtyHandlers(
) {
opts.scrollbackRows = Math.floor(args.scrollbackRows)
}
// Why: hidden-pane reveal uses the headless snapshot as the authoritative
// paint. Hold any ≤8ms main-process PTY batches while serializing: a
// successful headless snapshot already contains them, while a null
// snapshot must release them so the renderer fallback can replay them.
const pendingBeforeSnapshot = takePendingDataForPty(args.id)
headlessSnapshotHeldPtyIds.add(args.id)
let snapshot: { data: string; cols: number; rows: number } | null
try {
snapshot = await runtime.serializeHeadlessTerminalBufferForRenderer(args.id, opts)
} catch (err) {
const pendingDuringSnapshot = takePendingDataForPty(args.id)
headlessSnapshotHeldPtyIds.delete(args.id)
sendPtyDataToRenderer(args.id, pendingBeforeSnapshot + pendingDuringSnapshot)
throw err
}
const pendingDuringSnapshot = takePendingDataForPty(args.id)
headlessSnapshotHeldPtyIds.delete(args.id)
if (!snapshot) {
sendPtyDataToRenderer(args.id, pendingBeforeSnapshot + pendingDuringSnapshot)
}
return snapshot
return queueHeadlessSnapshotForRenderer(args.id, opts)
}
)

View File

@ -11,8 +11,7 @@ import { getSpawnArgsForWindows } from '../win32-utils'
export const EXTERNAL_EDITOR_CLI_COMMAND = 'code'
const REPO_ICON_IMAGE_MIME_TYPES: Record<string, string> = {
'.png': 'image/png',
'.svg': 'image/svg+xml'
'.png': 'image/png'
}
async function pathExists(pathValue: string): Promise<boolean> {
@ -248,7 +247,7 @@ export function registerShellHandlers(): void {
async (): Promise<{ dataUrl: string; fileName: string } | null> => {
const result = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [{ name: 'Repo icon images', extensions: ['png', 'svg'] }]
filters: [{ name: 'Repo icon images', extensions: ['png'] }]
})
if (result.canceled || result.filePaths.length === 0) {
return null
@ -258,7 +257,7 @@ export function registerShellHandlers(): void {
const extension = extname(filePath).toLowerCase()
const mimeType = REPO_ICON_IMAGE_MIME_TYPES[extension]
if (!mimeType) {
throw new Error('Repo icons must be PNG or SVG files.')
throw new Error('Repo icons must be PNG files.')
}
const stats = await stat(filePath)

View File

@ -1376,6 +1376,39 @@ describe('Store', () => {
expect(store.getRepo('r1')!.displayName).toBe('renamed')
})
it('updateRepo drops repo icons that fail shared sanitization', async () => {
const store = await createStore()
store.addRepo(makeRepo())
const updated = store.updateRepo('r1', {
repoIcon: {
type: 'image',
source: 'upload',
src: 'data:image/svg+xml;base64,PHN2Zz48L3N2Zz4='
} as never
})
expect(updated).not.toBeNull()
expect(updated!.repoIcon).toBeUndefined()
expect(store.getRepo('r1')!.repoIcon).toBeUndefined()
})
it('getRepo does not expose invalid persisted repo icons', async () => {
const store = await createStore()
store.addRepo(
makeRepo({
repoIcon: {
type: 'image',
source: 'upload',
src: 'data:image/svg+xml;base64,PHN2Zz48L3N2Zz4='
} as never
})
)
expect(store.getRepo('r1')!.repoIcon).toBeUndefined()
expect(store.getRepos()[0]!.repoIcon).toBeUndefined()
})
it('updateRepo returns null for nonexistent id', async () => {
const store = await createStore()
expect(store.updateRepo('nope', { displayName: 'x' })).toBeNull()

View File

@ -93,6 +93,7 @@ import {
normalizeWorkspaceStatuses
} from '../shared/workspace-statuses'
import { isLegacyRepoForExternalWorktreeVisibility } from '../shared/worktree-ownership'
import { sanitizeRepoIcon } from '../shared/repo-icon'
function encrypt(plaintext: string): string {
if (!plaintext || !safeStorage.isEncryptionAvailable()) {
@ -415,6 +416,21 @@ function readLegacySidekickFlag(parsed: PersistedState | undefined): boolean | u
return (parsed?.settings as { experimentalSidekick?: boolean } | undefined)?.experimentalSidekick
}
function sanitizeRepoUpdatesForPersistence<T extends Partial<Pick<Repo, 'repoIcon'>>>(
updates: T
): T {
const sanitized = { ...updates }
if ('repoIcon' in sanitized) {
const repoIcon = sanitizeRepoIcon(sanitized.repoIcon)
if (repoIcon === undefined) {
delete sanitized.repoIcon
} else {
sanitized.repoIcon = repoIcon
}
}
return sanitized
}
function expandFloatingWorkspaceHomePath(input: string, home: string): string {
if (input === '~') {
return home
@ -2057,8 +2073,10 @@ export class Store {
if (!repo) {
return null
}
const sanitizedUpdates = sanitizeRepoUpdatesForPersistence(updates)
const externalWorktreeVisibilityLegacy =
'externalWorktreeVisibility' in updates && repo.externalWorktreeVisibilityLegacy === undefined
'externalWorktreeVisibility' in sanitizedUpdates &&
repo.externalWorktreeVisibilityLegacy === undefined
? isLegacyRepoForExternalWorktreeVisibility(repo)
: undefined
// Why: `issueSourcePreference === undefined` in the patch means "reset to
@ -2066,15 +2084,18 @@ export class Store {
// stale explicit value via Object.assign's skip-on-undefined behavior).
// Without this delete branch, toggling explicit → auto would silently
// leave the old preference in place on disk.
if ('issueSourcePreference' in updates && updates.issueSourcePreference === undefined) {
if (
'issueSourcePreference' in sanitizedUpdates &&
sanitizedUpdates.issueSourcePreference === undefined
) {
delete repo.issueSourcePreference
const { issueSourcePreference: _drop, ...rest } = updates
const { issueSourcePreference: _drop, ...rest } = sanitizedUpdates
Object.assign(repo, rest)
} else {
Object.assign(repo, updates)
Object.assign(repo, sanitizedUpdates)
}
if (
'externalWorktreeVisibility' in updates &&
'externalWorktreeVisibility' in sanitizedUpdates &&
repo.externalWorktreeVisibilityLegacy === undefined
) {
// Why: old persisted repos have no explicit marker. Stamp it the first
@ -2086,6 +2107,8 @@ export class Store {
}
private hydrateRepo(repo: Repo): Repo {
const { repoIcon: rawRepoIcon, ...repoWithoutIcon } = repo
const repoIcon = sanitizeRepoIcon(rawRepoIcon)
const gitUsername = isFolderRepo(repo)
? ''
: (this.gitUsernameCache.get(repo.path) ??
@ -2096,7 +2119,8 @@ export class Store {
})())
return {
...repo,
...repoWithoutIcon,
...(repoIcon !== undefined ? { repoIcon } : {}),
kind: isFolderRepo(repo) ? 'folder' : 'git',
gitUsername,
hookSettings: {

View File

@ -230,7 +230,7 @@ export function RepositoryIconPicker({
onClick={handleUploadImage}
>
<Image className="size-3.5" />
Upload PNG/SVG
Upload PNG
</Button>
<Button
type="button"
@ -262,7 +262,7 @@ export function RepositoryIconPicker({
Favicon
</Button>
</div>
<p className="text-xs text-muted-foreground">PNG/SVG uploads must be 256KB or smaller.</p>
<p className="text-xs text-muted-foreground">PNG uploads must be 256KB or smaller.</p>
</TabsContent>
</Tabs>
</div>

View File

@ -127,4 +127,21 @@ describe('repo update serialization', () => {
errorSpy.mockRestore()
}
})
it('does not apply repo icons that fail shared sanitization', async () => {
reposUpdate.mockResolvedValueOnce(undefined)
const store = createTestStore()
store.setState({ repos: [localRepo] })
await store.getState().updateRepo(localRepo.id, {
repoIcon: {
type: 'image',
source: 'upload',
src: 'data:image/svg+xml;base64,PHN2Zz48L3N2Zz4='
} as never
})
expect(reposUpdate).toHaveBeenCalledWith({ repoId: localRepo.id, updates: {} })
expect(store.getState().repos[0]?.repoIcon).toBeUndefined()
})
})

View File

@ -7,6 +7,7 @@ import { toast } from 'sonner'
import type { AppState } from '../types'
import type { Repo } from '../../../../shared/types'
import { isGitRepoKind } from '../../../../shared/repo-kind'
import { sanitizeRepoIcon } from '../../../../shared/repo-icon'
import { getRepoIdFromWorktreeId } from './worktree-helpers'
import { callRuntimeRpc, getActiveRuntimeTarget } from '../../runtime/runtime-rpc-client'
import { buildDismissedOnboardingFolderAgentStartup } from '@/lib/onboarding-folder-agent-startup'
@ -29,6 +30,19 @@ type RepoUpdate = Partial<
>
>
function sanitizeRepoUpdate(updates: RepoUpdate): RepoUpdate {
const sanitized = { ...updates }
if ('repoIcon' in sanitized) {
const repoIcon = sanitizeRepoIcon(sanitized.repoIcon)
if (repoIcon === undefined) {
delete sanitized.repoIcon
} else {
sanitized.repoIcon = repoIcon
}
}
return sanitized
}
const updateRepoChainsByStore = new WeakMap<() => AppState, Map<string, Promise<boolean>>>()
function getRepoUpdateChains(get: () => AppState): Map<string, Promise<boolean>> {
@ -345,12 +359,18 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
const updateRepoChains = getRepoUpdateChains(get)
const applyRepoUpdate = async () => {
try {
const sanitizedUpdates = sanitizeRepoUpdate(updates)
const target = getActiveRuntimeTarget(get().settings)
await (target.kind === 'local'
? window.api.repos.update({ repoId, updates })
: callRuntimeRpc(target, 'repo.update', { repo: repoId, updates }, { timeoutMs: 15_000 }))
? window.api.repos.update({ repoId, updates: sanitizedUpdates })
: callRuntimeRpc(
target,
'repo.update',
{ repo: repoId, updates: sanitizedUpdates },
{ timeoutMs: 15_000 }
))
set((s) => ({
repos: s.repos.map((r) => (r.id === repoId ? { ...r, ...updates } : r))
repos: s.repos.map((r) => (r.id === repoId ? { ...r, ...sanitizedUpdates } : r))
}))
return true
} catch (err) {

View File

@ -24,6 +24,28 @@ describe('sanitizeRepoIcon', () => {
source: 'github',
label: 'stablyai/orca'
})
expect(
sanitizeRepoIcon({
type: 'image',
src: 'https://www.google.com/s2/favicons?domain=example.com&sz=64',
source: 'favicon'
})
).toEqual({
type: 'image',
src: 'https://www.google.com/s2/favicons?domain=example.com&sz=64',
source: 'favicon'
})
expect(
sanitizeRepoIcon({
type: 'image',
src: 'data:image/png;base64,aGVsbG8=',
source: 'upload'
})
).toEqual({
type: 'image',
src: 'data:image/png;base64,aGVsbG8=',
source: 'upload'
})
})
it('keeps null as an explicit reset', () => {
@ -45,5 +67,19 @@ describe('sanitizeRepoIcon', () => {
source: 'upload'
})
).toBeUndefined()
expect(
sanitizeRepoIcon({
type: 'image',
src: 'data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=',
source: 'upload'
})
).toBeUndefined()
expect(
sanitizeRepoIcon({
type: 'image',
src: 'https://example.com/icon.png',
source: 'github'
})
).toBeUndefined()
})
})

View File

@ -9,13 +9,29 @@ export const MAX_REPO_ICON_UPLOAD_BYTES = 256 * 1024
export const MAX_REPO_ICON_DATA_URL_LENGTH = 400 * 1024
const LUCIDE_ICON_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9]*$/
const IMAGE_SOURCE_IDS = new Set(['upload', 'favicon', 'github'])
const isRepoIconImageSource = (value: string): value is RepoIconImageSource =>
value === 'upload' || value === 'favicon' || value === 'github'
function isSupportedImageSrc(src: string): boolean {
return (
/^https:\/\/[^\s]+$/i.test(src) ||
/^data:image\/(?:png|svg\+xml);base64,[A-Za-z0-9+/=\s]+$/i.test(src)
)
function isSupportedImageSrc(src: string, source: RepoIconImageSource): boolean {
if (source === 'upload') {
return /^data:image\/png;base64,[A-Za-z0-9+/=\s]+$/i.test(src)
}
let url: URL
try {
url = new URL(src)
} catch {
return false
}
if (url.protocol !== 'https:') {
return false
}
if (source === 'github') {
return url.hostname === 'github.com' && /^\/[^/?#]+\.png$/i.test(url.pathname)
}
return url.hostname === 'www.google.com' && url.pathname === '/s2/favicons'
}
export function sanitizeRepoIcon(value: unknown): RepoIcon | null | undefined {
@ -49,10 +65,10 @@ export function sanitizeRepoIcon(value: unknown): RepoIcon | null | undefined {
if (candidate.type === 'image') {
const src = typeof candidate.src === 'string' ? candidate.src.trim() : ''
const source = typeof candidate.source === 'string' ? candidate.source : ''
if (!IMAGE_SOURCE_IDS.has(source) || src.length > MAX_REPO_ICON_DATA_URL_LENGTH) {
if (!isRepoIconImageSource(source) || src.length > MAX_REPO_ICON_DATA_URL_LENGTH) {
return undefined
}
if (!isSupportedImageSrc(src)) {
if (!isSupportedImageSrc(src, source)) {
return undefined
}
const label = typeof candidate.label === 'string' ? candidate.label.trim().slice(0, 80) : ''