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) => {
@@ -160,6 +160,17 @@ const getRepoDefaultBranch = async (octokit, repo) => {
return cached.defaultBranch;
}
// Reuse the full repo metadata if it was already fetched (expandRepoNetwork
// calls getRepoMetadata for every candidate before the default-branch loop).
// This avoids a redundant repos.get per repo — fewer serial GitHub calls means
// less exposure to secondary-rate-limiting that makes PR status slow.
const metaCached = repoMetadataCache.get(repoKey);
if (metaCached && Date.now() - metaCached.fetchedAt < REPO_DEFAULT_BRANCH_TTL_MS) {
const defaultBranch = normalizeText(metaCached.data?.default_branch) || null;
defaultBranchCache.set(repoKey, { defaultBranch, fetchedAt: Date.now() });
return defaultBranch;
}
try {
const response = await octokit.rest.repos.get({
owner: repo.owner,
+29 -6
View File
@@ -1,7 +1,26 @@
const PR_STATUS_CACHE_TTL_MS = 90_000;
const PR_STATUS_CACHE_MAX_ENTRIES = 200;
// Upper bound for resolving a single PR status. resolveGitHubPrStatus makes many
// serial GitHub API calls; under GitHub secondary-rate-limiting a single request
// can otherwise hang 20s+. We bound it so the route fails fast instead of holding
// the response (and a client socket) open — the client keeps its last-known
// status on error, and a later poll fills it in.
const PR_STATUS_RESOLVE_TIMEOUT_MS = 12_000;
const prStatusCache = new Map();
function withTimeout(promise, timeoutMs, label) {
let timer;
const timeout = new Promise((_resolve, reject) => {
timer = setTimeout(() => {
const error = new Error(`${label} timed out after ${timeoutMs}ms`);
error.code = 'ETIMEDOUT';
reject(error);
}, timeoutMs);
if (typeof timer.unref === 'function') timer.unref();
});
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}
function getRequestedRepo(req) {
const owner = typeof req.query?.owner === 'string' ? req.query.owner.trim() : '';
const repo = typeof req.query?.repo === 'string' ? req.query.repo.trim() : '';
@@ -417,12 +436,16 @@ export function registerGitHubRoutes(app) {
}
const { resolveGitHubPrStatus } = await import('./pr-status.js');
const resolvedStatus = await resolveGitHubPrStatus({
octokit,
directory,
branch,
remoteName: remote,
});
const resolvedStatus = await withTimeout(
resolveGitHubPrStatus({
octokit,
directory,
branch,
remoteName: remote,
}),
PR_STATUS_RESOLVE_TIMEOUT_MS,
'resolveGitHubPrStatus',
);
const searchRepo = resolvedStatus.repo;
const first = resolvedStatus.pr;
if (!searchRepo) {