Fix clogged up git operations.
This commit is contained in:
+42
-6
@@ -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
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user