diff --git a/src/servers/plugins/release.ts b/src/servers/plugins/release.ts index c78ad674..40a2fe9c 100644 --- a/src/servers/plugins/release.ts +++ b/src/servers/plugins/release.ts @@ -29,12 +29,18 @@ import { join } from 'node:path'; * is that tag, this checkout is at the latest release. If it is a different one, the remote has moved on. * That holds for any tag names anyone ever chooses, including ones that do not sort. * - * ── What this does NOT do ── + * ── The answer is never awaited on the network ── * - * It never fetches. Every answer comes from refs already on disk, because this is called once per plugin - * on a request that renders a list — twenty-one network round trips would make the plugins screen feel - * broken. Tags arrive at clone and at the fetch `pullPlugin` already does, so "check for updates" is a - * consequence of updating rather than a separate poll. + * Every answer comes from refs already on disk. This is called once per plugin on a request that renders + * a list, and twenty-one network round trips would make the plugins screen feel broken. + * + * But refs on disk go stale, and for a long time nothing refreshed them: tags arrived at clone and at + * the fetch `pullPlugin` does, so you only learned an update existed BY UPDATING. A machine that had not + * updated a plugin since August compared this morning's release against a snapshot from before it + * existed and said, truthfully and uselessly, that it was at the latest. + * + * So `refreshRemote` runs a fetch in the background and this request answers from what is there now. The + * NEXT read is current. Rate-limited per checkout and run one at a time — see the note on it. */ export type ReleaseState = { @@ -47,6 +53,47 @@ export type ReleaseState = { reason: 'untagged' | 'new-release' | 'at-latest' | 'not-a-checkout'; }; +/** + * A background `git fetch --tags`, at most one per checkout per REFRESH_MS. + * + * ── Not awaited, on purpose ── + * + * The caller is rendering a list. Waiting would put the network on the critical path of a page that has + * to feel instant, which is the reason this file never fetched in the first place. The request answers + * from the refs it has; the fetch it starts is for the next one. + * + * The cost of that is one stale view after a release, corrected by a refresh. The alternative — never + * fetching — is a badge that is wrong until somebody updates for an unrelated reason. + * + * ── One at a time ── + * + * Twenty-one plugins would otherwise spawn twenty-one gits at once, every ten minutes, on machines that + * are not all fast. Chained rather than pooled because there is no hurry: nothing is waiting on it. + * + * Failure is silence. A checkout with no remote, an unreachable host, a repository that has moved — none + * of those should surface as anything, because the list still has a true answer without them. + */ +const REFRESH_MS = 10 * 60 * 1000; +const fetchedAt = new Map(); +let queue: Promise = Promise.resolve(); + +export function refreshRemote(dir: string): void { + const last = fetchedAt.get(dir) ?? 0; + if (Date.now() - last < REFRESH_MS) return; + // Stamped BEFORE the fetch runs, so a slow one cannot be started again by the next twenty requests. + fetchedAt.set(dir, Date.now()); + + queue = queue.then(async () => { + const proc = Bun.spawn(['git', '-C', dir, 'fetch', '--tags', '--quiet', 'origin'], { + stdout: 'ignore', + stderr: 'ignore', + // A host that hangs must not hold the queue: every plugin behind it would stop refreshing too. + signal: AbortSignal.timeout(20_000), + }); + await proc.exited.catch(() => {}); + }); +} + const run = async (dir: string, args: string[]): Promise => { const proc = Bun.spawn(['git', '-C', dir, ...args], { stdout: 'pipe', stderr: 'ignore' }); const out = await new Response(proc.stdout).text(); @@ -61,6 +108,9 @@ export async function releaseState(dir: string): Promise { return { ...none, updateable: false, reason: 'not-a-checkout' }; } + // Started, not awaited: this answer comes from the refs on disk and the fetch is for the next one. + refreshRemote(dir); + // `--exact-match`, so this is "the tag AT this commit", not "the last tag before it". Fails, and // therefore returns null, when HEAD is between releases — which is the first half of the rule. const tagAtHead = await run(dir, ['describe', '--exact-match', '--tags', 'HEAD']);