From 9409c43bec1c0925269132bb028e1bfba79d228c Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 16 May 2026 10:55:40 -0700 Subject: [PATCH] Coalesce renderer perf polling (#2079) --- .../sync-runtime-graph-scheduling.test.ts | 106 ++++++++++++++++++ .../src/runtime/sync-runtime-graph.test.ts | 2 + .../src/runtime/sync-runtime-graph.ts | 28 ++++- src/renderer/src/store/slices/memory.test.ts | 69 ++++++++++++ src/renderer/src/store/slices/memory.ts | 46 +++++--- 5 files changed, 235 insertions(+), 16 deletions(-) create mode 100644 src/renderer/src/runtime/sync-runtime-graph-scheduling.test.ts create mode 100644 src/renderer/src/store/slices/memory.test.ts diff --git a/src/renderer/src/runtime/sync-runtime-graph-scheduling.test.ts b/src/renderer/src/runtime/sync-runtime-graph-scheduling.test.ts new file mode 100644 index 000000000..7c9fb69ff --- /dev/null +++ b/src/renderer/src/runtime/sync-runtime-graph-scheduling.test.ts @@ -0,0 +1,106 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + registerRuntimeTerminalTab, + scheduleRuntimeGraphSync, + setRuntimeGraphStoreStateGetter, + setRuntimeGraphSyncEnabled +} from './sync-runtime-graph' +import type { AppState } from '../store/types' +import type { TerminalTab } from '../../../shared/types' + +function makeState(overrides: Partial = {}): AppState { + return { + tabsByWorktree: {}, + terminalLayoutsByTabId: {} as AppState['terminalLayoutsByTabId'], + runtimePaneTitlesByTabId: {} as AppState['runtimePaneTitlesByTabId'], + groupsByWorktree: {}, + activeGroupIdByWorktree: {}, + unifiedTabsByWorktree: {}, + tabBarOrderByWorktree: {}, + activeFileId: null, + activeFileIdByWorktree: {}, + openFiles: [], + editorDrafts: {}, + activeTabId: null, + ...overrides + } as AppState +} + +function makeTerminalTab(): TerminalTab { + return { + id: 'term-1', + ptyId: null, + worktreeId: 'wt-1', + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } +} + +function deferred(): { + promise: Promise + resolve: (value: T | PromiseLike) => void +} { + let resolve: (value: T | PromiseLike) => void = () => {} + const promise = new Promise((r) => { + resolve = r + }) + return { promise, resolve } +} + +async function flushMicrotasks(): Promise { + await Promise.resolve() + await Promise.resolve() +} + +afterEach(() => { + setRuntimeGraphSyncEnabled(false) + setRuntimeGraphStoreStateGetter(null) + vi.unstubAllGlobals() +}) + +describe('scheduleRuntimeGraphSync', () => { + it('coalesces updates that arrive while the runtime graph IPC is in flight', async () => { + const syncCalls: { + promise: Promise + resolve: (value: void | PromiseLike) => void + }[] = [] + const syncWindowGraph = vi.fn(() => { + const call = deferred() + syncCalls.push(call) + return call.promise + }) + vi.stubGlobal('window', { api: { runtime: { syncWindowGraph } } }) + vi.stubGlobal('HTMLElement', class HTMLElement {}) + const unregister = registerRuntimeTerminalTab({ + tabId: 'term-1', + worktreeId: 'wt-1', + getManager: () => null, + getContainer: () => null, + getPtyIdForPane: () => null + }) + setRuntimeGraphStoreStateGetter(() => + makeState({ + tabsByWorktree: { 'wt-1': [makeTerminalTab()] } as AppState['tabsByWorktree'] + }) + ) + + setRuntimeGraphSyncEnabled(true) + await flushMicrotasks() + + expect(syncWindowGraph).toHaveBeenCalledTimes(1) + scheduleRuntimeGraphSync() + scheduleRuntimeGraphSync() + await flushMicrotasks() + + expect(syncWindowGraph).toHaveBeenCalledTimes(1) + syncCalls[0]?.resolve() + await flushMicrotasks() + + expect(syncWindowGraph).toHaveBeenCalledTimes(2) + syncCalls[1]?.resolve() + unregister() + }) +}) diff --git a/src/renderer/src/runtime/sync-runtime-graph.test.ts b/src/renderer/src/runtime/sync-runtime-graph.test.ts index f7ad3303d..d28ea2f97 100644 --- a/src/renderer/src/runtime/sync-runtime-graph.test.ts +++ b/src/renderer/src/runtime/sync-runtime-graph.test.ts @@ -9,6 +9,8 @@ import type { AppState } from '../store/types' function makeState(overrides: Partial = {}): AppState { return { tabsByWorktree: {}, + terminalLayoutsByTabId: {} as AppState['terminalLayoutsByTabId'], + runtimePaneTitlesByTabId: {} as AppState['runtimePaneTitlesByTabId'], groupsByWorktree: {}, activeGroupIdByWorktree: {}, unifiedTabsByWorktree: {}, diff --git a/src/renderer/src/runtime/sync-runtime-graph.ts b/src/renderer/src/runtime/sync-runtime-graph.ts index 83cb03e2b..864d25682 100644 --- a/src/renderer/src/runtime/sync-runtime-graph.ts +++ b/src/renderer/src/runtime/sync-runtime-graph.ts @@ -51,6 +51,8 @@ const registeredTabs = new Map() const tabRegisteredAt = new Map() const NO_TRANSPORT_GRACE_MS = 10_000 let syncScheduled = false +let syncInFlight = false +let syncPendingAfterFlight = false let syncEnabled = false let getStoreState: (() => AppState) | null = null let mobileSessionSnapshotVersion = 0 @@ -106,13 +108,37 @@ export function scheduleRuntimeGraphSync(): void { if (!syncEnabled || syncScheduled) { return } + if (syncInFlight) { + syncPendingAfterFlight = true + return + } syncScheduled = true queueMicrotask(() => { syncScheduled = false - void syncRuntimeGraph() + void runRuntimeGraphSync() }) } +async function runRuntimeGraphSync(): Promise { + if (syncInFlight) { + syncPendingAfterFlight = true + return + } + syncInFlight = true + try { + await syncRuntimeGraph() + } finally { + syncInFlight = false + if (syncPendingAfterFlight) { + syncPendingAfterFlight = false + // Why: syncWindowGraph crosses IPC and can be slower than title/layout + // churn. Collapse all updates that arrived during one in-flight sync + // into a single trailing graph instead of stacking concurrent IPC calls. + scheduleRuntimeGraphSync() + } + } +} + export type RuntimeMobileSessionSyncKey = { // Why: large maps the renderer never reshapes are compared by reference. // Reallocating `terminalLayoutsByTabId` / `runtimePaneTitlesByTabId` is the diff --git a/src/renderer/src/store/slices/memory.test.ts b/src/renderer/src/store/slices/memory.test.ts new file mode 100644 index 000000000..750ef373d --- /dev/null +++ b/src/renderer/src/store/slices/memory.test.ts @@ -0,0 +1,69 @@ +import { create } from 'zustand' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createMemorySlice } from './memory' +import type { AppState } from '../types' +import type { MemorySnapshot } from '../../../../shared/types' + +function makeMemorySnapshot(overrides: Partial = {}): MemorySnapshot { + return { + app: { + cpu: 1, + memory: 1024, + main: { cpu: 1, memory: 512 }, + renderer: { cpu: 0, memory: 256 }, + other: { cpu: 0, memory: 256 }, + history: [1024] + }, + worktrees: [], + host: { + totalMemory: 8192, + freeMemory: 4096, + usedMemory: 4096, + memoryUsagePercent: 50, + cpuCoreCount: 8, + loadAverage1m: 1 + }, + totalCpu: 1, + totalMemory: 1024, + collectedAt: 1, + ...overrides + } +} + +function makeStore() { + return create>()( + (...args) => createMemorySlice(...(args as Parameters)) + ) +} + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('createMemorySlice', () => { + it('dedupes concurrent memory snapshot IPC calls', async () => { + let resolveSnapshot: (snapshot: MemorySnapshot) => void = () => {} + const getSnapshot = vi.fn( + () => + new Promise((resolve) => { + resolveSnapshot = resolve + }) + ) + vi.stubGlobal('window', { api: { memory: { getSnapshot } } }) + + const store = makeStore() + const first = store.getState().fetchMemorySnapshot() + const second = store.getState().fetchMemorySnapshot() + + expect(getSnapshot).toHaveBeenCalledTimes(1) + resolveSnapshot(makeMemorySnapshot({ collectedAt: 10 })) + await Promise.all([first, second]) + + expect(store.getState().memorySnapshot?.collectedAt).toBe(10) + getSnapshot.mockResolvedValueOnce(makeMemorySnapshot({ collectedAt: 11 })) + await store.getState().fetchMemorySnapshot() + + expect(getSnapshot).toHaveBeenCalledTimes(2) + expect(store.getState().memorySnapshot?.collectedAt).toBe(11) + }) +}) diff --git a/src/renderer/src/store/slices/memory.ts b/src/renderer/src/store/slices/memory.ts index a011adaf8..bc6b26f50 100644 --- a/src/renderer/src/store/slices/memory.ts +++ b/src/renderer/src/store/slices/memory.ts @@ -8,22 +8,38 @@ export type MemorySlice = { fetchMemorySnapshot: () => Promise } -export const createMemorySlice: StateCreator = (set) => ({ - memorySnapshot: null, - memorySnapshotError: null, +export const createMemorySlice: StateCreator = (set) => { + let inFlightSnapshot: Promise | null = null - fetchMemorySnapshot: async () => { - try { - const snapshot = await window.api.memory.getSnapshot() - set({ memorySnapshot: snapshot, memorySnapshotError: null }) - } catch (err) { - // Why: the always-on Resource Manager status-bar segment needs to know when - // the snapshot IPC is failing so it can surface a "daemon not responding" - // banner with a Restart CTA. Prior code only console.error'd. - console.error('Failed to fetch memory snapshot:', err) - set({ - memorySnapshotError: err instanceof Error ? err.message : String(err) + return { + memorySnapshot: null, + memorySnapshotError: null, + + fetchMemorySnapshot: () => { + if (inFlightSnapshot) { + return inFlightSnapshot + } + const request = (async () => { + try { + const snapshot = await window.api.memory.getSnapshot() + set({ memorySnapshot: snapshot, memorySnapshotError: null }) + } catch (err) { + // Why: the always-on Resource Manager status-bar segment needs to know when + // the snapshot IPC is failing so it can surface a "daemon not responding" + // banner with a Restart CTA. Prior code only console.error'd. + console.error('Failed to fetch memory snapshot:', err) + set({ + memorySnapshotError: err instanceof Error ? err.message : String(err) + }) + } + })() + const trackedRequest = request.finally(() => { + if (inFlightSnapshot === trackedRequest) { + inFlightSnapshot = null + } }) + inFlightSnapshot = trackedRequest + return trackedRequest } } -}) +}