* perf: overlap sidebar-scope loads with worktree scan at startup (#7225) Continues the renderer-chain parallelization proposed in #7225 (proposal 2), on top of the merged #7266: - Run project-groups → folder-workspaces concurrently with the per-repo `git worktree list` fan-out. Neither reads the repos store nor worktrees, so a slow remote host's 15s scope RPCs no longer queue ahead of the scan. - Raise worktree refresh concurrency 5 → 8 so multi-core machines run fewer sequential scan batches, still bounded so one moment can't launch every git probe at once. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Test overlapping sidebar scope loads and worktree hydration at startup Verify that sidebar scope loads and worktree hydration operations run concurrently before session hydration. This protects against regressions in startup performance under the perf/startup-lag optimization. Additionally, document the rationale for the worktree refresh concurrency limit in the worktrees slice. * Clarify worktree refresh concurrency comment --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
This commit is contained in:
parent
0d8205ae73
commit
9964d1ccbf
|
|
@ -878,19 +878,28 @@ function App(): React.JSX.Element {
|
|||
// active one) so a cold start that restored a remote workspace doesn't
|
||||
// hide local repos. The sidebar "All hosts" scope then shows them all.
|
||||
await timeRendererStartupStep('fetch-repos', () => actions.fetchReposForAllHosts())
|
||||
await timeRendererStartupStep('fetch-project-groups', () =>
|
||||
actions.fetchProjectGroupsForAllHosts()
|
||||
)
|
||||
await timeRendererStartupStep('fetch-folder-workspaces', () =>
|
||||
actions.fetchFolderWorkspacesForAllHosts()
|
||||
)
|
||||
// Why: both of these fan out `git worktree list` per repo on the main
|
||||
// process. Running them concurrently lets the main process share one
|
||||
// Why: project-groups/folder-workspaces read neither the repos store nor
|
||||
// worktrees, so once repos land they can overlap the per-repo
|
||||
// `git worktree list` fan-out (#7225) instead of queuing ahead of it — a
|
||||
// slow remote host's 15s scope RPCs no longer block the scan. folders
|
||||
// still follow project-groups (they read projectGroups); all settle
|
||||
// before the hydrate steps below.
|
||||
const projectScopeChain = (async () => {
|
||||
await timeRendererStartupStep('fetch-project-groups', () =>
|
||||
actions.fetchProjectGroupsForAllHosts()
|
||||
)
|
||||
await timeRendererStartupStep('fetch-folder-workspaces', () =>
|
||||
actions.fetchFolderWorkspacesForAllHosts()
|
||||
)
|
||||
})()
|
||||
// Why: worktrees + lineage both fan out `git worktree list` per repo on
|
||||
// the main process. Running them concurrently lets it share one
|
||||
// in-flight scan per repo instead of paying the process-spawn fan-out
|
||||
// twice back-to-back — the dominant renderer-chain cost on Windows
|
||||
// (issue #7225). Lineage only reads settings + its own slice, so it
|
||||
// does not depend on the worktrees fetch having landed.
|
||||
await Promise.all([
|
||||
projectScopeChain,
|
||||
timeRendererStartupStep('fetch-worktrees', () => actions.fetchAllWorktrees()),
|
||||
timeRendererStartupStep('fetch-worktree-lineage', () => actions.fetchWorktreeLineage())
|
||||
])
|
||||
|
|
|
|||
|
|
@ -6,9 +6,7 @@ describe('renderer startup runtime routing', () => {
|
|||
it('loads settings before repo and worktree hydration', () => {
|
||||
const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8')
|
||||
const startupBlockStart = source.indexOf('void (async () => {')
|
||||
const startupBlockEnd = source.indexOf(
|
||||
"const persistedUI = await timeRendererStartupStep('ui-get'"
|
||||
)
|
||||
const startupBlockEnd = source.indexOf('const persistedUI = await uiGetPromise')
|
||||
const startupBlock = source.slice(startupBlockStart, startupBlockEnd)
|
||||
|
||||
const settingsIndex = startupBlock.indexOf('actions.fetchSettings()')
|
||||
|
|
@ -17,6 +15,31 @@ describe('renderer startup runtime routing', () => {
|
|||
expect(settingsIndex).toBeLessThan(startupBlock.indexOf('actions.fetchAllWorktrees()'))
|
||||
})
|
||||
|
||||
it('overlaps sidebar scope loads with worktree hydration before session hydration', () => {
|
||||
const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8')
|
||||
const startupBlockStart = source.indexOf('void (async () => {')
|
||||
const startupBlockEnd = source.indexOf('const persistedUI = await uiGetPromise')
|
||||
const startupBlock = source.slice(startupBlockStart, startupBlockEnd)
|
||||
|
||||
const reposIndex = startupBlock.indexOf('actions.fetchReposForAllHosts()')
|
||||
const scopeChainIndex = startupBlock.indexOf('const projectScopeChain = (async () => {')
|
||||
const projectGroupsIndex = startupBlock.indexOf('actions.fetchProjectGroupsForAllHosts()')
|
||||
const folderWorkspacesIndex = startupBlock.indexOf('actions.fetchFolderWorkspacesForAllHosts()')
|
||||
const promiseAllIndex = startupBlock.indexOf('await Promise.all([')
|
||||
const awaitedScopeChainIndex = startupBlock.indexOf('projectScopeChain', promiseAllIndex)
|
||||
const worktreesIndex = startupBlock.indexOf('actions.fetchAllWorktrees()')
|
||||
const lineageIndex = startupBlock.indexOf('actions.fetchWorktreeLineage()')
|
||||
|
||||
expect(reposIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(scopeChainIndex).toBeGreaterThan(reposIndex)
|
||||
expect(projectGroupsIndex).toBeGreaterThan(scopeChainIndex)
|
||||
expect(folderWorkspacesIndex).toBeGreaterThan(projectGroupsIndex)
|
||||
expect(promiseAllIndex).toBeGreaterThan(scopeChainIndex)
|
||||
expect(awaitedScopeChainIndex).toBeGreaterThan(promiseAllIndex)
|
||||
expect(worktreesIndex).toBeGreaterThan(promiseAllIndex)
|
||||
expect(lineageIndex).toBeGreaterThan(promiseAllIndex)
|
||||
})
|
||||
|
||||
it('waits for first-window startup services before terminal reconnect', () => {
|
||||
const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8')
|
||||
const reconnectIndex = source.indexOf('await actions.reconnectPersistedTerminals')
|
||||
|
|
|
|||
|
|
@ -91,6 +91,7 @@ const mockApi = {
|
|||
globalThis.window = { api: mockApi }
|
||||
|
||||
import {
|
||||
WORKTREE_REFRESH_CONCURRENCY,
|
||||
createWorktreeSlice,
|
||||
getHostedReviewLinkMutationGenerationForTests,
|
||||
getHostedReviewLinkWorktreeAliasCountForTests,
|
||||
|
|
@ -5648,7 +5649,7 @@ describe('fetchAllWorktrees hydration-time purge (design §4.4)', () => {
|
|||
|
||||
it('bounds concurrent repo scans during hydration-time refresh', async () => {
|
||||
const store = createTestStore()
|
||||
const repos = Array.from({ length: 7 }, (_, index) => ({
|
||||
const repos = Array.from({ length: WORKTREE_REFRESH_CONCURRENCY + 2 }, (_, index) => ({
|
||||
id: `repo-${index}`,
|
||||
path: `/repos/${index}`,
|
||||
displayName: `repo-${index}`,
|
||||
|
|
@ -5670,14 +5671,14 @@ describe('fetchAllWorktrees hydration-time purge (design §4.4)', () => {
|
|||
|
||||
await store.getState().fetchAllWorktrees()
|
||||
|
||||
expect(maxActiveScans).toBeLessThanOrEqual(5)
|
||||
expect(maxActiveScans).toBeLessThanOrEqual(WORKTREE_REFRESH_CONCURRENCY)
|
||||
expect(mockApi.worktrees.list).toHaveBeenCalledTimes(repos.length)
|
||||
expect(store.getState().hasHydratedWorktreePurge).toBe(true)
|
||||
})
|
||||
|
||||
it('bounds concurrent repo scans after the hydration purge has run', async () => {
|
||||
const store = createTestStore()
|
||||
const repos = Array.from({ length: 7 }, (_, index) => ({
|
||||
const repos = Array.from({ length: WORKTREE_REFRESH_CONCURRENCY + 2 }, (_, index) => ({
|
||||
id: `repo-${index}`,
|
||||
path: `/repos/${index}`,
|
||||
displayName: `repo-${index}`,
|
||||
|
|
@ -5702,7 +5703,7 @@ describe('fetchAllWorktrees hydration-time purge (design §4.4)', () => {
|
|||
|
||||
await store.getState().fetchAllWorktrees()
|
||||
|
||||
expect(maxActiveScans).toBeLessThanOrEqual(5)
|
||||
expect(maxActiveScans).toBeLessThanOrEqual(WORKTREE_REFRESH_CONCURRENCY)
|
||||
expect(mockApi.worktrees.list).toHaveBeenCalledTimes(repos.length)
|
||||
expect(store.getState().hasHydratedWorktreePurge).toBe(true)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -72,7 +72,10 @@ const REMOTE_WORKTREE_LIST_PARITY_LIMIT = 10_000
|
|||
const ACTIVE_WORKTREE_TERMINAL_PREP_DELAY_MS = 300
|
||||
const ACTIVE_WORKTREE_TERMINAL_PREP_INPUT_QUIET_MS = 450
|
||||
const ACTIVE_WORKTREE_TERMINAL_PREP_IDLE_TIMEOUT_MS = 180
|
||||
const WORKTREE_REFRESH_CONCURRENCY = 5
|
||||
// Why: each repo's `git worktree list` is an independent main-process child, so
|
||||
// a higher ceiling cuts startup scan batches (#7225) while staying bounded so
|
||||
// one UI moment can't launch every git probe at once.
|
||||
export const WORKTREE_REFRESH_CONCURRENCY = 8
|
||||
const pendingActivationTerminalPrepCancels = new Map<string, () => void>()
|
||||
const detachedHeadAutoDerivedDisplayNames = new Map<string, string>()
|
||||
const folderWorkspaceWorktreeCache = new WeakMap<FolderWorkspace, Worktree>()
|
||||
|
|
|
|||
Loading…
Reference in New Issue