fix(github): stop PR-status requests from starving startup connection pool

Watching N worktrees fired N PR-status requests at once (startWatching
called refresh() directly, bypassing the batch limiter). Each request can
take 20s+ under GitHub secondary-rate-limiting, and N of them saturate the
browser's ~6 HTTP/1.1 connections per origin, starving the critical path
(bootstrap session.status, diffs, sending messages) until they finish — the
UI appeared frozen for ~20s on startup.

- Gate all PR-status network calls through a global concurrency semaphore
  (max 2), so free sockets always remain for critical traffic.
- Bound resolveGitHubPrStatus with a 12s timeout so a slow request fails
  fast instead of holding a socket; the client keeps its last-known status.
- Reuse already-fetched repo metadata for the default branch instead of a
  redundant repos.get, reducing serial GitHub calls (less rate-limiting).
This commit is contained in:
Bohdan Triapitsyn
2026-06-28 23:41:14 +03:00
parent f040ea85e6
commit 9f720c66af
3 changed files with 84 additions and 7 deletions
@@ -93,6 +93,43 @@ const timers = new Map<string, number>();
const bootstrapTimers = new Map<string, number[]>();
const inFlightBySignature = new Set<string>();
const lastRefreshBySignature = new Map<string, number>();
// Global concurrency gate for PR-status network requests.
//
// PR status is non-critical chrome, but each request can be slow (the server
// makes many serial GitHub API calls and GitHub secondary-rate-limits bursts,
// so a single request can take 20s+). The browser allows only ~6 concurrent
// HTTP/1.1 connections per origin. Without this cap, watching N worktrees fires
// N PR-status requests at once (each startWatching() calls refresh() directly,
// bypassing refreshTargets' batch limiter), which saturates the connection pool
// and starves the critical path (bootstrap session.status, diffs, sending
// messages) for the full duration — the whole UI appears frozen on startup.
//
// Capping concurrency low guarantees free sockets remain for critical traffic.
const PR_STATUS_NETWORK_CONCURRENCY = 2;
let prStatusNetworkActive = 0;
const prStatusNetworkWaiters: Array<() => void> = [];
const acquirePrStatusNetworkSlot = (): Promise<void> => {
if (prStatusNetworkActive < PR_STATUS_NETWORK_CONCURRENCY) {
prStatusNetworkActive += 1;
return Promise.resolve();
}
return new Promise<void>((resolve) => {
prStatusNetworkWaiters.push(resolve);
});
};
const releasePrStatusNetworkSlot = (): void => {
const next = prStatusNetworkWaiters.shift();
if (next) {
// Hand the slot directly to the next waiter — keep the active count steady.
next();
return;
}
prStatusNetworkActive = Math.max(0, prStatusNetworkActive - 1);
};
const createEntry = (): PrStatusEntry => ({
status: null,
isLoading: false,
@@ -488,7 +525,13 @@ export const useGitHubPrStatusStore = create<GitHubPrStatusStore>()(
activeRequestCount: prev.activeRequestCount + 1,
totalRequestCount: prev.totalRequestCount + 1,
}));
const next = await params.github.prStatus(params.directory, params.branch, params.remoteName ?? undefined, { force: options?.force });
await acquirePrStatusNetworkSlot();
let next: GitHubPullRequestStatus;
try {
next = await params.github.prStatus(params.directory, params.branch, params.remoteName ?? undefined, { force: options?.force });
} finally {
releasePrStatusNetworkSlot();
}
set((prev) => {
const nextEntries = { ...prev.entries };
signatureKeys.forEach((signatureKey) => {