Fix clogged up git operations.

This commit is contained in:
Greyson Parrelli
2026-08-27 13:32:47 -04:00
parent 2e3af19d00
commit d7452c2262
9 changed files with 315 additions and 51 deletions
+42 -6
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { parseDiff, type ViewType } from 'react-diff-view';
import { api, tabId } from './api';
import { ApiError, api, tabId } from './api';
import type { Comment, DiffContext, DiffPayload, DraftTarget, RepoState } from './types';
import { buildAnchors, isOutdated } from './lib/anchor';
import { pathOf } from './lib/filetree';
@@ -58,6 +58,23 @@ const MIN_DIFF = 420;
// entirely while the pane is hidden or already known to be stale.
const POLL_MS = 8000;
// ...but "isn't free" stops being true when a review is open in seven tabs and
// the repository is a big one being built in the terminal next door. Every pane
// polling on the same fixed interval is how the app ends up with a git process
// running most of the time, and the request an agent is waiting on ends up
// queued behind polls whose answers nobody reads.
//
// So the interval is a floor, not a period: each pane spaces its next poll by
// what the last one actually cost, which means panes on a slow repository — the
// only ones expensive enough to matter — quietly ask less often, and a pane on a
// small one carries on at POLL_MS. `POLL_COST` is how many times the last poll's
// duration to wait, and the server's own "git is busy" refusal (503) counts as
// expensive whatever it cost, because it is the app telling this pane it is one
// of too many.
const POLL_MAX_MS = 60000;
const POLL_COST = 6;
const POLL_BUSY_MS = 20000;
const CTX_KEY = 'review-ctx-by-repo';
const IGNORE_WS_KEY = 'review-ignore-whitespace';
@@ -411,17 +428,29 @@ export default function App() {
let canceled = false;
let busy = false;
let timer: number | undefined;
// Each poll arms the next one rather than a fixed interval doing it, so the
// spacing can answer to what the last one cost — see POLL_MAX_MS.
const arm = (ms: number) => {
window.clearTimeout(timer);
if (!canceled) timer = window.setTimeout(check, ms);
};
const check = async () => {
// A hidden pane is a pane nobody is reading. It gets checked the moment it
// comes back instead, which is when the answer matters.
if (canceled || busy || document.hidden) return;
if (canceled || busy) return;
if (document.hidden) return arm(POLL_MS);
busy = true;
const seq = reqRef.current;
const started = performance.now();
let next = POLL_MS;
try {
const { revision: now, branch: on } = await api.revision(ctx, {
ignoreWhitespace: ignoreWs,
});
next = Math.max(POLL_MS, Math.round((performance.now() - started) * POLL_COST));
// A load that started while this was in flight has already answered the
// question, with a revision this closure doesn't know about.
if (canceled || seq !== reqRef.current) return;
@@ -432,14 +461,21 @@ export default function App() {
// you were reading, and the banner is what offers to move that.
if (on && on !== branch) loadRepo();
if (now && now !== revision) setStale(now);
} catch {
// A failed poll says nothing about the diff — the next one will.
} catch (e) {
// A failed poll says nothing about the diff — the next one will. A 503
// says something about the app, though: too many panes are asking git
// for too much at once, and this one is part of that.
next =
e instanceof ApiError && e.status === 503
? POLL_BUSY_MS
: POLL_MS;
} finally {
busy = false;
arm(Math.min(POLL_MAX_MS, next));
}
};
const timer = window.setInterval(check, POLL_MS);
arm(POLL_MS);
const onVisible = () => {
if (!document.hidden) check();
};
@@ -447,7 +483,7 @@ export default function App() {
window.addEventListener('focus', onVisible);
return () => {
canceled = true;
window.clearInterval(timer);
window.clearTimeout(timer);
document.removeEventListener('visibilitychange', onVisible);
window.removeEventListener('focus', onVisible);
};
+16 -2
View File
@@ -32,6 +32,20 @@ export const tabId = (() => {
return match ? decodeURIComponent(match[1]) : '';
})();
// An error the server answered with, carrying the status alongside the sentence.
// The status matters in one place: a 503 from the poll is the server rationing
// git rather than anything being wrong, and the poll backs off instead of asking
// again on the same tick — see the poll effect in App.
export class ApiError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.name = 'ApiError';
this.status = status;
}
}
async function json<T>(res: Response): Promise<T> {
if (!res.ok) {
const body = await res.text();
@@ -39,11 +53,11 @@ async function json<T>(res: Response): Promise<T> {
// beats surfacing a status code, because it is written for a person.
try {
const parsed = JSON.parse(body) as { error?: string };
if (parsed?.error) throw new Error(parsed.error);
if (parsed?.error) throw new ApiError(res.status, parsed.error);
} catch (e) {
if (e instanceof Error && e.message && !e.message.startsWith('Unexpected')) throw e;
}
throw new Error(`${res.status} ${res.statusText}: ${body}`);
throw new ApiError(res.status, `${res.status} ${res.statusText}: ${body}`);
}
if (res.status === 204) return undefined as T;
return res.json() as Promise<T>;