From 0856df806fdec0fa3090f14baa13df479685d845 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 31 May 2026 10:11:51 -0700 Subject: [PATCH] perf: cap usage worktree canonicalization (#4263) --- src/main/codex-usage/scanner.ts | 10 +---- src/main/opencode-usage/scanner.ts | 9 +---- src/main/usage-worktree-canonicalizer.test.ts | 37 +++++++++++++++++++ src/main/usage-worktree-canonicalizer.ts | 36 ++++++++++++++++++ 4 files changed, 77 insertions(+), 15 deletions(-) create mode 100644 src/main/usage-worktree-canonicalizer.test.ts create mode 100644 src/main/usage-worktree-canonicalizer.ts diff --git a/src/main/codex-usage/scanner.ts b/src/main/codex-usage/scanner.ts index 7fa6a56ef..bd77a020a 100644 --- a/src/main/codex-usage/scanner.ts +++ b/src/main/codex-usage/scanner.ts @@ -7,6 +7,7 @@ import type { Repo } from '../../shared/types' import { areWorktreePathsEqual } from '../ipc/worktree-logic' import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from '../codex/codex-home-paths' import { getLegacyCopiedCodexSessionBridgeScanPreference } from '../codex/codex-session-bridge' +import { canonicalizeUsageWorktreePaths } from '../usage-worktree-canonicalizer' import type { CodexUsageAttributedEvent, CodexUsageDailyAggregate, @@ -429,14 +430,7 @@ function localDayFromTimestamp(timestamp: string): string | null { async function buildWorktreesWithCanonicalPaths( worktrees: CodexUsageWorktreeRef[] ): Promise<(CodexUsageWorktreeRef & { canonicalPath: string })[]> { - const canonicalized = await Promise.all( - worktrees.map(async (worktree) => ({ - ...worktree, - canonicalPath: await canonicalizePath(worktree.path) - })) - ) - - return canonicalized.sort((left, right) => right.canonicalPath.length - left.canonicalPath.length) + return canonicalizeUsageWorktreePaths(worktrees, canonicalizePath) } function isContainingPath(candidatePath: string, targetPath: string): boolean { diff --git a/src/main/opencode-usage/scanner.ts b/src/main/opencode-usage/scanner.ts index d6167aa6e..83cc7f742 100644 --- a/src/main/opencode-usage/scanner.ts +++ b/src/main/opencode-usage/scanner.ts @@ -6,6 +6,7 @@ import { isAbsolute, join, posix, win32 } from 'path' import type { Repo } from '../../shared/types' import { areWorktreePathsEqual } from '../ipc/worktree-logic' import Database from '../sqlite/sync-database' +import { canonicalizeUsageWorktreePaths } from '../usage-worktree-canonicalizer' import type { OpenCodeUsageAttributedEvent, OpenCodeUsageDailyAggregate, @@ -441,13 +442,7 @@ function isContainingPath(candidatePath: string, targetPath: string): boolean { async function buildWorktreesWithCanonicalPaths( worktrees: OpenCodeUsageWorktreeRef[] ): Promise<(OpenCodeUsageWorktreeRef & { canonicalPath: string })[]> { - const canonicalized = await Promise.all( - worktrees.map(async (worktree) => ({ - ...worktree, - canonicalPath: await canonicalizePath(worktree.path) - })) - ) - return canonicalized.sort((left, right) => right.canonicalPath.length - left.canonicalPath.length) + return canonicalizeUsageWorktreePaths(worktrees, canonicalizePath) } async function canonicalizePath(pathValue: string): Promise { diff --git a/src/main/usage-worktree-canonicalizer.test.ts b/src/main/usage-worktree-canonicalizer.test.ts new file mode 100644 index 000000000..8ab8de9b5 --- /dev/null +++ b/src/main/usage-worktree-canonicalizer.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import { + USAGE_WORKTREE_CANONICALIZATION_CONCURRENCY, + canonicalizeUsageWorktreePaths +} from './usage-worktree-canonicalizer' + +describe('canonicalizeUsageWorktreePaths', () => { + it('caps concurrent canonicalization work and preserves longest-path-first ordering', async () => { + let active = 0 + let maxActive = 0 + const seenPaths: string[] = [] + + const worktrees = Array.from( + { length: USAGE_WORKTREE_CANONICALIZATION_CONCURRENCY + 3 }, + (_value, index) => ({ + path: `/repo/${index}`, + worktreeId: `worktree-${index}` + }) + ) + + const result = await canonicalizeUsageWorktreePaths(worktrees, async (path) => { + active++ + maxActive = Math.max(maxActive, active) + seenPaths.push(path) + await new Promise((resolve) => setTimeout(resolve, 0)) + active-- + return path.endsWith('/10') ? `${path}/nested/longer` : path + }) + + expect(maxActive).toBeLessThanOrEqual(USAGE_WORKTREE_CANONICALIZATION_CONCURRENCY) + expect(seenPaths).toEqual(worktrees.map((worktree) => worktree.path)) + expect(result[0]).toMatchObject({ + path: '/repo/10', + canonicalPath: '/repo/10/nested/longer' + }) + }) +}) diff --git a/src/main/usage-worktree-canonicalizer.ts b/src/main/usage-worktree-canonicalizer.ts new file mode 100644 index 000000000..b10e9491b --- /dev/null +++ b/src/main/usage-worktree-canonicalizer.ts @@ -0,0 +1,36 @@ +export const USAGE_WORKTREE_CANONICALIZATION_CONCURRENCY = 8 + +export type CanonicalizedUsageWorktree = T & { + canonicalPath: string +} + +export async function canonicalizeUsageWorktreePaths( + worktrees: readonly T[], + canonicalizePath: (path: string) => Promise, + concurrency = USAGE_WORKTREE_CANONICALIZATION_CONCURRENCY +): Promise[]> { + if (worktrees.length === 0) { + return [] + } + + // Why: usage scans can see many stale remembered worktrees. Bound realpath + // fanout so opening the usage pane does not stampede the filesystem. + const workerCount = Math.min(worktrees.length, Math.max(1, Math.floor(concurrency))) + const canonicalized = Array.from>({ length: worktrees.length }) + let nextIndex = 0 + + async function worker(): Promise { + while (nextIndex < worktrees.length) { + const index = nextIndex + nextIndex++ + const worktree = worktrees[index] + canonicalized[index] = { + ...worktree, + canonicalPath: await canonicalizePath(worktree.path) + } + } + } + + await Promise.all(Array.from({ length: workerCount }, worker)) + return canonicalized.sort((left, right) => right.canonicalPath.length - left.canonicalPath.length) +}