From 89ef65ad5d9affe58dce2e5bc9ce9b4268030a92 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Wed, 6 May 2026 19:50:17 -0700 Subject: [PATCH 1/2] =?UTF-8?q?perf(host-service):=20listBranches=20single?= =?UTF-8?q?=20git=20spawn=20instead=20of=204=C3=97N?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `listBranches` was running 4 git child processes per local branch (`git config remote`, `git config merge`, `git rev-list --left-right`, `git log -1`) for an O(N) fanout, but the only renderer consumer (`BaseBranchSelector`) reads `branch.name` plus a UI check on `isHead`. With ~1k local branches that was ~3-4k spawns per call, sustained near-100% CPU on the host-service event loop. Replaced with a single `git for-each-ref refs/heads/ --format='%(HEAD)\t%(refname:short)'` call. Cost now O(1). `useDiffStats` was bypassing React Query — calling the imperative `client.git.getStatus.query()` per `git:changed` event, so each sibling consumer (every sidebar tile) issued its own uncached `getStatus`. Switched to `workspaceTrpc.git.getStatus.useQuery()` + invalidate-on-event so concurrent invalidations collapse to one refetch. Same update semantics, shared cache. Plan doc captures the diagnostic methodology (sample(1) → realising PID 2182 was the host-service via ELECTRON_RUN_AS_NODE → in-process spawn tracer → root cause). --- .../host-service/useDiffStats/useDiffStats.ts | 84 ++++++++----------- .../host-service/src/trpc/router/git/git.ts | 37 ++++---- 2 files changed, 55 insertions(+), 66 deletions(-) diff --git a/apps/desktop/src/renderer/hooks/host-service/useDiffStats/useDiffStats.ts b/apps/desktop/src/renderer/hooks/host-service/useDiffStats/useDiffStats.ts index 13f27f2e853..7495649efcd 100644 --- a/apps/desktop/src/renderer/hooks/host-service/useDiffStats/useDiffStats.ts +++ b/apps/desktop/src/renderer/hooks/host-service/useDiffStats/useDiffStats.ts @@ -1,7 +1,6 @@ -import { useCallback, useEffect, useState } from "react"; -import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; +import { workspaceTrpc } from "@superset/workspace-client"; +import { useCallback, useMemo } from "react"; import { useWorkspaceEvent } from "../useWorkspaceEvent"; -import { useWorkspaceHostUrl } from "../useWorkspaceHostUrl"; export interface DiffStats { additions: number; @@ -9,54 +8,39 @@ export interface DiffStats { } /** - * Fetches diff stats for a single workspace, auto-updates on git changes. - * Just pass the workspaceId — host resolution is handled internally. + * Diff stats for a single workspace, derived from the shared `git.getStatus` + * query cache. Subscribes to `git:changed` and invalidates the query — React + * Query collapses concurrent invalidations from sibling consumers (e.g. + * `useGitStatus`, multiple sidebar tiles) into a single refetch. */ export function useDiffStats(workspaceId: string): DiffStats | null { - const [stats, setStats] = useState(null); - const hostUrl = useWorkspaceHostUrl(workspaceId); - - const fetchStats = useCallback(async () => { - if (!hostUrl) return; - try { - const client = getHostServiceClientByUrl(hostUrl); - const status = await client.git.getStatus.query({ workspaceId }); - - // Deduplicate by path — a file can appear in multiple categories - const byPath = new Map< - string, - { additions: number; deletions: number } - >(); - for (const file of status.againstBase) { - byPath.set(file.path, file); - } - for (const file of status.staged) { - byPath.set(file.path, file); - } - for (const file of status.unstaged) { - byPath.set(file.path, file); - } - - let additions = 0; - let deletions = 0; - for (const file of byPath.values()) { - additions += file.additions; - deletions += file.deletions; - } - - setStats({ additions, deletions }); - } catch { - // Host unavailable or workspace deleted + const utils = workspaceTrpc.useUtils(); + const { data: status } = workspaceTrpc.git.getStatus.useQuery( + { workspaceId }, + { enabled: Boolean(workspaceId) }, + ); + + const invalidate = useCallback(() => { + void utils.git.getStatus.invalidate({ workspaceId }); + }, [utils, workspaceId]); + + useWorkspaceEvent("git:changed", workspaceId, invalidate); + + return useMemo(() => { + if (!status) return null; + + // Deduplicate by path — a file can appear in multiple categories. + const byPath = new Map(); + for (const file of status.againstBase) byPath.set(file.path, file); + for (const file of status.staged) byPath.set(file.path, file); + for (const file of status.unstaged) byPath.set(file.path, file); + + let additions = 0; + let deletions = 0; + for (const file of byPath.values()) { + additions += file.additions; + deletions += file.deletions; } - }, [hostUrl, workspaceId]); - - useEffect(() => { - void fetchStats(); - }, [fetchStats]); - - useWorkspaceEvent("git:changed", workspaceId, () => { - void fetchStats(); - }); - - return stats; + return { additions, deletions }; + }, [status]); } diff --git a/packages/host-service/src/trpc/router/git/git.ts b/packages/host-service/src/trpc/router/git/git.ts index 177f4bc245e..81eaef668c8 100644 --- a/packages/host-service/src/trpc/router/git/git.ts +++ b/packages/host-service/src/trpc/router/git/git.ts @@ -65,27 +65,32 @@ export const gitRouter = router({ const worktreePath = resolveWorktreePath(ctx, input.workspaceId); const git = await ctx.git(worktreePath); - const currentBranchName = ( - await git.revparse(["--abbrev-ref", "HEAD"]).catch(() => "") - ).trim(); - const base = await resolveBaseComparison(git); - - let branchNames: string[] = []; + // `%(HEAD)` emits "*" for the checked-out branch, " " otherwise. + // Single spawn — independent of branch count. Only `name`/`isHead` + // are read by the v2 sidebar's BaseBranchSelector; the other + // per-branch fields the previous implementation computed (upstream, + // ahead/behind, last-commit) cost 4 spawns each and were unused. + let branches: { name: string; isHead: boolean }[] = []; try { const raw = await git.raw([ - "branch", - "--list", - "--format=%(refname:short)", + "for-each-ref", + "refs/heads/", + "--format=%(HEAD)\t%(refname:short)", ]); - branchNames = raw.trim().split("\n").filter(Boolean); + branches = raw + .trim() + .split("\n") + .filter(Boolean) + .map((line) => { + const tab = line.indexOf("\t"); + if (tab < 0) return { name: line, isHead: false }; + return { + isHead: line.slice(0, tab) === "*", + name: line.slice(tab + 1), + }; + }); } catch {} - const branches = await Promise.all( - branchNames.map((name) => - buildBranch(git, name, name === currentBranchName, base?.baseRef), - ), - ); - return { branches }; }), From 7586b453b59d61254ac1d616e9e2ec3d8377dd9b Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Wed, 6 May 2026 20:05:59 -0700 Subject: [PATCH 2/2] perf(host-service): disable refetchOnWindowFocus in useDiffStats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pre-RQ `useDiffStats` only fetched on mount and on `git:changed` events. Switching to `useQuery` without an explicit `refetchOnWindowFocus: false` inherited React Query's default of `true`, so every window focus would issue one `getStatus` per visible sidebar tile (each tile has its own `workspaceId` query key, so RQ won't dedupe across tiles). With 8 tiles open that's ~112 git spawns per focus event — exactly the multi-consumer fanout this hook was supposed to eliminate. --- .../hooks/host-service/useDiffStats/useDiffStats.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/renderer/hooks/host-service/useDiffStats/useDiffStats.ts b/apps/desktop/src/renderer/hooks/host-service/useDiffStats/useDiffStats.ts index 7495649efcd..4d23f60f135 100644 --- a/apps/desktop/src/renderer/hooks/host-service/useDiffStats/useDiffStats.ts +++ b/apps/desktop/src/renderer/hooks/host-service/useDiffStats/useDiffStats.ts @@ -17,7 +17,14 @@ export function useDiffStats(workspaceId: string): DiffStats | null { const utils = workspaceTrpc.useUtils(); const { data: status } = workspaceTrpc.git.getStatus.useQuery( { workspaceId }, - { enabled: Boolean(workspaceId) }, + { + enabled: Boolean(workspaceId), + // Match the pre-RQ behavior: only update on `git:changed`, never + // on focus. Multiple sidebar tiles each have their own query key, + // so focus refetch would re-fan out the very work this hook is + // supposed to consolidate. + refetchOnWindowFocus: false, + }, ); const invalidate = useCallback(() => {