commit 1a57f3c51f08ef206a897b21526563bccad2be2f
parent 30cd9597d5b8eaa8ac369a276f6c58d390e470d5
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Tue, 4 Aug 2026 21:49:39 -0400
Keep the channel page intact when there is no report yet
The previous commit replaced the whole channel page with a "no report yet"
placeholder when snapshot.json was missing. That removed the page's controls —
including "Download videos" — from exactly the channels most likely to need
them, and broke ~20 e2e specs that (reasonably) expect a channel page to be a
channel page.
Renders against an empty snapshot instead, with a banner explaining that the
buckets and counts read zero until a report is generated. The video list is
read from the data directory rather than the report, so the page is fully
usable; only the derived numbers are empty. The landmine this all started
with — generateChannelSnapshot running inside a GET — stays gone.
Also adds scripts/measure-nav.mjs (read-only navigation measurement against a
running instance, documenting the router-state-tree encoding traps that make
this easy to measure wrongly) and e2e/perf-budget.spec.ts as a tripwire for a
render path re-acquiring a corpus walk.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Diffstat:
5 files changed, 275 insertions(+), 36 deletions(-)
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -2,7 +2,7 @@
## [Unreleased]
- **The editor is fast now.** Every page in the editor had a floor of about 4.4 seconds on it, and the reason was one line in the sidebar. The reclaimable-disk badge — the little "12.4 GB" pill next to Cleanup — asked for the channel list, and the function it asked was the one that counts the corpus from scratch: a `readdir` for each of the **78,350** video directories plus a digest sidecar read for each of the ~70,000 transcribed ones, **~474,559 files touched, measured at 3,985 ms**, to describe **98 videos**. Every count that walk produced was then thrown away. It sat in the root layout, so *every* document load paid it; the 5-second auto-refresh re-ran it on a timer, on every route, forever; and three widget endpoints called it on each poll. It now reads the 65 per-channel snapshots it could always have read — the same numbers, **68 ms**, a 59× improvement — and the sidebar badge itself is down to ~40 ms. Loading `/channels` went from 4.5 s to roughly a tenth of a second; the dashboard from ~10 s. The corpus-walking function still exists under a name that says what it costs (`listChannelStatsFromDisk`) for the batch jobs that genuinely need ground truth, and a test now fails the build if it ever reappears anywhere the editor renders. **The honest trade:** the video, transcript and download counts on `/channels` and the dashboard now come from each channel's last generated report rather than from disk directly, so a job that just finished can take a moment — the snapshot scheduler's ~1 second debounce — to show up. Verified against the live corpus: those three counts match a full walk **exactly** on all 65 channels. The one field that doesn't is digest coverage, which reads 0 for the 11 channels whose reports predate per-engine digest counts until their next report refresh. `/channels` now prints how old the oldest report on the page is, rather than leaving you to assume the numbers are live.
-- **Auto-refresh no longer refreshes when nothing has changed.** The passive refresher called `router.refresh()` on a timer — a full server-side re-render of the entire page tree, every 5 seconds, on every route, whether or not anything had actually happened. Against the real corpus that was ~4.9 seconds of work per tick, and it held the editor's server process at roughly **22% of a CPU core, permanently, with a single idle tab open**. It now asks a new `/api/pulse` endpoint whether anything moved — a change token built from in-memory job, queue and worker state plus two file timestamps, no corpus reads at all — and re-renders only when the answer is yes. An idle page now performs **zero** re-renders where it used to perform one every five seconds; there's a test that fails if that ever regresses. Same setting, same 5-second default, same "0 disables" behaviour, and editing settings still repaints the sidebar immediately, because the settings file's timestamp is part of the token. The sidebar's job and reclaimable-disk pills now update on their own rather than requiring the whole page to re-render — which in turn let fifteen server actions stop invalidating the client's entire navigation cache to announce that a job count had changed. Also fixed while in here: opening a channel with no report used to **generate one inside the page load**, a full analysis of every video directory in that channel — minutes, on the big ones, with no progress and no way to stop it. It now offers a button and runs it as a normal background job.
+- **Auto-refresh no longer refreshes when nothing has changed.** The passive refresher called `router.refresh()` on a timer — a full server-side re-render of the entire page tree, every 5 seconds, on every route, whether or not anything had actually happened. Against the real corpus that was ~4.9 seconds of work per tick, and it held the editor's server process at roughly **22% of a CPU core, permanently, with a single idle tab open**. It now asks a new `/api/pulse` endpoint whether anything moved — a change token built from in-memory job, queue and worker state plus two file timestamps, no corpus reads at all — and re-renders only when the answer is yes. An idle page now performs **zero** re-renders where it used to perform one every five seconds; there's a test that fails if that ever regresses. Same setting, same 5-second default, same "0 disables" behaviour, and editing settings still repaints the sidebar immediately, because the settings file's timestamp is part of the token. The sidebar's job and reclaimable-disk pills now update on their own rather than requiring the whole page to re-render — which in turn let fifteen server actions stop invalidating the client's entire navigation cache to announce that a job count had changed. Also fixed while in here: opening a channel with no report used to **generate one inside the page load**, a full analysis of every video directory in that channel — minutes, on the big ones, with no progress and no way to stop it. It now shows a banner offering to run it as a normal background job, and renders the rest of the page as usual — the video list comes from disk, not the report, and the pipeline controls are exactly what you want on a channel you haven't analysed yet.
- **A page that throws no longer takes the whole editor with it, and moving between pages is instant.** There were **zero** error boundaries in the editor: anything that threw while rendering — a malformed config, a half-written snapshot — blanked the entire document, sidebar and all, with nothing to click and nothing to read. There is now a route error boundary that keeps the chrome alive, shows the error's digest so you can find it in the server log, and offers *Try again* (Next 16.2's `unstable_retry`, which actually re-fetches, rather than the older `reset`, which only clears the error state). Alongside it: `loading.tsx` skeletons for the routes with the most to render, and a 15-second client router cache (`staleTimes`), which is what makes bouncing between two sidebar links immediate instead of a fresh server round trip each way. One honest note, since it's easy to assume otherwise: in Next 16 `loading.tsx` does **not** guarantee a fallback appears during a client-side navigation — the framework's own reference says so, and testing confirmed it. Fast sidebar navigation here comes from prefetching plus that cache, not from the skeletons; the skeletons cover document loads.
- **Cleaning audio now checks the video still exists upstream, and keeps it forever if it doesn't.** The transcribed-audio sweep hard-deletes a video's `audio.*` files once whisper has produced a transcript — `remove()`, no trash, no undo — and nothing had ever asked whether the video was still *there*. So a video YouTube had since removed, privated, or put behind a membership, sitting outside the keep-latest window, got its source audio deleted precisely when that local copy had become the only copy. Before deleting anything, the sweep now resolves each candidate's availability and writes a `do-not-clean.json` marker on any video found permanently gone (`deleted` / `private` / `members_only` — the same rule the keep-latest deletion pass uses, now shared as `isPermanentlyGone`), protecting it from this and every future sweep. The check is **cheap-first, not one probe per video**: a cached availability verdict costs nothing and is the only tier that catches `members_only` (a members-only video stays listed in its channel's playlist, so a listing diff can never flag it); then **one** flat-playlist call per channel narrows the field to candidates that have dropped out of the listing; only those few get a per-video probe, which is also what distinguishes a deleted video from an *unlisted* one that legitimately left the listing and is still fetchable by URL. Anything the check cannot resolve — a probe error, an age-gate, a video with no URL to probe — is **left alone with no marker written** and retried next run: the sweep never deletes on incomplete information, and a rate-limited or offline source therefore cleans nothing rather than cleaning wrongly. The summary line breaks the total down (`Skipped 4 (0 protected, 3 gone-from-source pinned, 1 unverified)`) whenever the check acted. On by default; **Check availability before cleaning audio** in Settings turns it off for an offline setup or channels with no URL, where the check can never resolve and cleanup would otherwise stop deleting anything. Two related fixes ride along: the sweep now shares `isRealAudioFile` with the rest of the app instead of its own hand-rolled filter, so it no longer deletes the `audio.live_chat.json` sidecar or the `.part.good`/`.part.testing` audio-check snapshots (which the reclaim estimate never counted, so the two had quietly drifted); and `runAvailabilityCheck` gains `ignoreShard`, because a saved shard slice on disk would otherwise replace an explicit `onlyIds` list wholesale. Scoped to the primary sweep only — the wrong-format, extra-format and auto-sub purges are unchanged, as are the explicit per-video deletes, which still ignore markers deliberately. See `common/controller/verifyBeforeClean.ts`, `common/controller/cleanAudioFromTranscribed.ts`, `common/lib/availability.ts`, and `editor/e2e/pre-clean-availability.spec.ts`.
- **The monitor widget can now reclaim disk, not just report it.** The widget's cleanable-data strip showed a single global number ("4.2 GB reclaimable") with nothing to act on — reclaiming it meant leaving the widget for `/cleanup` or `/actionable`. A new opt-in **"Needs cleaning"** section (URL flag `cleanlist=1`, plus a **Channels needing cleanup** checkbox in the builder and the in-widget gear) lists the channels actually holding that audio, each with its reclaim estimate (`⌫ 2.5 MB`, the video count in the tooltip), capped at 6 channels with a `+N more` line like the needs-work list. With `controls=1` each row gains the same per-channel **Clean audio** button as the `/actionable` page — the existing `window.confirm` still guards the delete — so a pinned interactive widget clears disk pressure the way it already clears a download backlog. This is also the first surface on which a channel that is *fully downloaded and transcribed* but still holding reclaimable audio is actionable: the needs-work list is fed by a backlog route with a download/transcribe precondition, so such a channel never appeared there. It costs no extra polling — the per-channel rows come from the same `/api/widget/cleanable` snapshot read that already backed the total, and the total is now a sum over those rows so the section and the strip above it can't disagree. The dashboard's needs-work panel and its shared route are untouched. See `editor/app/cleanup/lib/loadCleanup.ts` (`cleanableChannels`), `editor/app/api/widget/cleanable/route.ts`, `editor/app/widget/{lib/config.ts,components/{MonitorWidget,WidgetConfigForm}.tsx}`, and `editor/e2e/widget.spec.ts`.
diff --git a/editor/app/channels/[slug]/components/NoReportYet.tsx b/editor/app/channels/[slug]/components/NoReportYet.tsx
@@ -1,46 +1,41 @@
import Link from "next/link";
import { InlineActionButton } from "../../../actionable/components/InlineActionButton";
-// Shown when a channel has no generated report yet.
+// Banner shown at the top of a channel page that has no generated report.
//
// This page used to GENERATE the snapshot inline when one was missing — a full
// channel analysis, walking every video directory, inside a GET. On the largest
// channel here that is a multi-minute page load triggered by nothing more than
-// clicking a link, with no progress and no way to cancel it. Worse, it made the
+// clicking a link, with no progress and no way to cancel. Worse, it made the
// expensive path the invisible one: the page looked slow rather than looking
// like it was doing a job.
//
-// So the page now asks. "Refresh report" queues the same refresh-report job
-// every other surface uses, it shows up in /jobs like any other work, and the
-// page fills in once it lands.
-export function NoReportYet({ slug, name }: { slug: string; name: string }) {
+// So the page now asks. Deliberately a BANNER rather than a replacement for the
+// page: the video list is read from the data directory, not the report, and the
+// pipeline controls are exactly what you want on a channel you haven't analysed
+// yet. Only the derived buckets and counts are empty, which is what this says.
+export function NoReportYet({ slug }: { slug: string }) {
return (
- <div className="flex flex-col gap-4">
- <div className="flex items-center gap-2 text-sm text-muted-foreground">
- <Link href="/channels" className="underline hover:text-foreground">
- Channels
- </Link>
- <span>/</span>
- <span>{slug}</span>
- </div>
- <h1 className="text-2xl font-semibold">{name}</h1>
- <div className="rounded-lg border border-dashed border-border p-6 flex flex-col gap-3 items-start">
- <h2 className="text-lg font-medium">No report yet</h2>
- <p className="text-sm text-muted-foreground max-w-prose">
- This channel hasn’t been analysed yet, so there are no buckets,
- counts or cleanup estimates to show. Generating one reads every video
- directory in the channel — on a large channel that takes a while, so
- it runs as a background job rather than holding this page open.
- </p>
+ <div
+ data-testid="no-report-banner"
+ className="rounded-lg border border-dashed border-warning/40 bg-warning-soft/40 p-4 flex flex-col gap-2 items-start"
+ >
+ <h2 className="text-sm font-medium">No report yet</h2>
+ <p className="text-sm text-muted-foreground max-w-prose">
+ This channel hasn’t been analysed, so every bucket and count below
+ reads zero. Generating a report reads every video directory in the
+ channel — on a large channel that takes a while, so it runs as a
+ background job rather than holding this page open.
+ </p>
+ <div className="flex items-center gap-3">
<InlineActionButton variant={{ kind: "refreshReport", slug }} />
- <p className="text-xs text-muted-foreground">
+ <span className="text-xs text-muted-foreground">
Follow it on the{" "}
<Link href="/jobs" className="underline">
Jobs
</Link>{" "}
- page — it’s a <code>refresh-report</code> job. Reload here when
- it finishes.
- </p>
+ page, then reload.
+ </span>
</div>
</div>
);
diff --git a/editor/app/channels/[slug]/page.tsx b/editor/app/channels/[slug]/page.tsx
@@ -17,6 +17,7 @@ import "yt-dlp-transcript-common/social/xGalleryDlFetcher";
import "yt-dlp-transcript-common/social/xPlaywrightFetcher";
import { SocialChannelPanel } from "./components/SocialChannelPanel";
import { NoReportYet } from "./components/NoReportYet";
+import type { ChannelSnapshot } from "yt-dlp-transcript-common/controller/channelSnapshot";
import {
excludedDownloadIdSet,
normalizeAvailability,
@@ -226,14 +227,22 @@ export default async function ChannelDetailPage({
// ⚠️ This used to be `existing ?? await generateChannelSnapshot(paths, slug)`
// — a full channel analysis run INSIDE A GET. On the 11,224-video channel
// that is a multi-minute request that walks every video directory, and the
- // only thing that triggers it is opening a page. A render must never
- // generate; it reads what's there and offers to generate what isn't.
- const snapshot = await readChannelSnapshot(paths, slug);
- if (!snapshot) {
- return (
- <NoReportYet slug={slug} name={config.name ?? slug} />
- );
- }
+ // only thing that triggers it is opening a link. A render must never
+ // generate; it reads what's there and OFFERS to generate what isn't.
+ //
+ // With no report, the page renders normally against an empty one rather than
+ // replacing itself with a placeholder: the video list comes from the data
+ // directory, not the snapshot, and the pipeline controls (Download videos,
+ // Transcribe, Sync) are exactly what you want available on a channel that has
+ // never been analysed. Only the derived buckets and counts are empty, and
+ // NoReportYet says so at the top.
+ const existing = await readChannelSnapshot(paths, slug);
+ const snapshot: ChannelSnapshot = existing ?? {
+ generatedAt: "",
+ totals: { videos: 0, transcribed: 0, downloaded: 0 },
+ buckets: normalizeBuckets(undefined),
+ undownloadedIds: [],
+ };
// Saved-video store summary for this channel + whether backups are configured,
// for the Cleanup stage's Retention & persistence section (Phase 5).
const savedTotals = await savedVideoTotals({ paths, channelSlug: slug });
@@ -547,6 +556,7 @@ export default async function ChannelDetailPage({
return (
<div className="flex flex-col gap-6">
+ {!existing && <NoReportYet slug={slug} />}
<StatusHeader
slug={slug}
channelName={config.name ?? slug}
diff --git a/editor/e2e/perf-budget.spec.ts b/editor/e2e/perf-budget.spec.ts
@@ -0,0 +1,58 @@
+import { test, expect } from "@playwright/test";
+import { resetData, generateReport } from "./helpers";
+
+// A TRIPWIRE, not a benchmark.
+//
+// These budgets are enormous relative to what the pages cost against the test
+// corpus (tens of milliseconds) and they run on a shared machine alongside
+// other work, so they will not catch a 2× slowdown. What they catch is the
+// specific failure this whole change set was about: a render path quietly
+// acquiring a full corpus walk again. That turns tens of milliseconds into
+// seconds, and it is the kind of regression that is invisible in review and
+// obvious here.
+//
+// If one of these fails, the question to ask is "what did this page start
+// reading?" — not "is the machine busy?".
+const BUDGET_MS = 3_000;
+
+test.describe("perf budget", () => {
+ test.beforeEach(async () => {
+ await resetData("channel-with-counts");
+ });
+
+ test("the dashboard renders within budget", async ({ page }) => {
+ const started = Date.now();
+ await page.goto("/");
+ await expect(
+ page.getByRole("heading", { name: "Dashboard", level: 1 }),
+ ).toBeVisible();
+ const elapsed = Date.now() - started;
+ expect(elapsed, `dashboard took ${elapsed}ms`).toBeLessThan(BUDGET_MS);
+ });
+
+ test("the channels page renders within budget", async ({ page }) => {
+ const started = Date.now();
+ await page.goto("/channels");
+ await expect(
+ page.getByRole("heading", { name: "Channels", level: 1 }),
+ ).toBeVisible();
+ const elapsed = Date.now() - started;
+ expect(elapsed, `channels took ${elapsed}ms`).toBeLessThan(BUDGET_MS);
+ });
+
+ test("the sidebar's reclaimable-disk badge is not a corpus walk", async ({
+ page,
+ request,
+ }) => {
+ // This endpoint is the one that used to cost 4.4 s to describe ~98 videos,
+ // on every widget poll. It reads the per-channel snapshots now.
+ await generateReport(page, "counted");
+ const started = Date.now();
+ const res = await request.get("/api/widget/cleanable");
+ const elapsed = Date.now() - started;
+ expect(res.ok()).toBe(true);
+ expect(elapsed, `/api/widget/cleanable took ${elapsed}ms`).toBeLessThan(
+ BUDGET_MS,
+ );
+ });
+});
diff --git a/editor/scripts/measure-nav.mjs b/editor/scripts/measure-nav.mjs
@@ -0,0 +1,176 @@
+#!/usr/bin/env node
+// Measure what the editor actually costs to navigate, against a RUNNING
+// instance. Read-only: it issues GETs and times them, nothing else. That is
+// deliberate — it can be pointed at the live :3001 instance without booting a
+// second editor against the real corpus (which would arm the job runners and
+// resume the multi-week digest sweep via instrumentation.ts).
+//
+// node scripts/measure-nav.mjs [--base http://localhost:3001] [--runs 3]
+//
+// It reports three different costs per route, because they are three different
+// things and conflating them is what misdirected this whole investigation once
+// already:
+//
+// document a full page load (what you get from the URL bar / a reload)
+// nav a SIBLING CLIENT NAVIGATION (what a sidebar click actually costs)
+// prefetch the shell Next fetches ahead of time, with its byte count
+//
+// ⚠️ STATE-TREE ENCODING TRAPS. Both of these were hit while writing this:
+//
+// * The router state tree is a 4-element form:
+// [segment, parallelRoutes, url, refresh]
+// Sending a 5th element returns a bare HTTP 500 that reads like a real
+// server error rather than a malformed request.
+// * "refetch" is the FOURTH element, not the fifth.
+// * OMITTING the Next-Router-State-Tree header entirely makes the server
+// render from the ROOT — so you measure a whole document and think you have
+// measured a sidebar click. That mistake produced a "4.5s sidebar click"
+// figure that sent the first pass of this work after the wrong cause.
+
+const args = process.argv.slice(2);
+const argOf = (name, fallback) => {
+ const i = args.indexOf(`--${name}`);
+ return i >= 0 && args[i + 1] ? args[i + 1] : fallback;
+};
+const BASE = argOf("base", "http://localhost:3001").replace(/\/$/, "");
+const RUNS = Number(argOf("runs", "3"));
+
+// Routes worth timing, with the segment name used to build a state tree.
+const ROUTES = [
+ ["/", "__PAGE__"],
+ ["/channels", "channels"],
+ ["/actionable", "actionable"],
+ ["/saved-videos", "saved-videos"],
+ ["/auto-queue", "auto-queue"],
+ ["/cleanup", "cleanup"],
+ ["/jobs", "jobs"],
+ ["/build", "build"],
+ ["/charts", "charts"],
+ ["/aliases", "aliases"],
+];
+
+const APIS = [
+ "/api/pulse",
+ "/api/widget/cleanable",
+ "/api/widget/actionable",
+ "/api/widget/sync",
+ "/api/jobs/active",
+ "/api/workers",
+];
+
+// The 4-element form. A 5th element is a 500; `true` in slot 4 means "refetch",
+// which is what router.refresh() sends.
+function stateTree(segment, refetch) {
+ const page = segment === "__PAGE__"
+ ? ["", { children: ["__PAGE__", {}] }, null, refetch ? "refetch" : null]
+ : ["", { children: [segment, { children: ["__PAGE__", {}] }] }, null, refetch ? "refetch" : null];
+ return JSON.stringify(page);
+}
+
+async function timeGet(url, headers = {}) {
+ const t0 = performance.now();
+ let bytes = 0;
+ let status = 0;
+ try {
+ const res = await fetch(url, { headers, redirect: "manual" });
+ status = res.status;
+ const buf = await res.arrayBuffer();
+ bytes = buf.byteLength;
+ } catch (e) {
+ return { ms: performance.now() - t0, bytes: 0, status: 0, error: String(e) };
+ }
+ return { ms: performance.now() - t0, bytes, status };
+}
+
+// Best-of-N. The median would hide a first-hit compile in dev; the minimum is
+// the cleanest signal of what the work actually costs once warm — and warming
+// is exactly what a real user's second visit does.
+async function best(fn) {
+ let out = null;
+ for (let i = 0; i < RUNS; i++) {
+ const r = await fn();
+ if (!out || r.ms < out.ms) out = r;
+ }
+ return out;
+}
+
+const fmt = (n) => `${n.toFixed(0)}`.padStart(7);
+const kb = (n) => `${(n / 1024).toFixed(1)}kb`.padStart(9);
+
+console.log(`base=${BASE} runs=${RUNS}\n`);
+console.log(
+ "route".padEnd(16) +
+ "document".padStart(9) +
+ "nav".padStart(9) +
+ "refresh".padStart(9) +
+ "prefetch".padStart(10) +
+ " prefetch size",
+);
+console.log("-".repeat(72));
+
+for (const [route, segment] of ROUTES) {
+ const doc = await best(() => timeGet(`${BASE}${route}`));
+ const nav = await best(() =>
+ timeGet(`${BASE}${route}?_rsc=measure`, {
+ RSC: "1",
+ "Next-Router-State-Tree": stateTree(segment, false),
+ }),
+ );
+ const refresh = await best(() =>
+ timeGet(`${BASE}${route}?_rsc=measure`, {
+ RSC: "1",
+ "Next-Router-State-Tree": stateTree(segment, true),
+ }),
+ );
+ const pre = await best(() =>
+ timeGet(`${BASE}${route}?_rsc=measure`, {
+ RSC: "1",
+ "Next-Router-Prefetch": "1",
+ "Next-Router-State-Tree": stateTree(segment, false),
+ }),
+ );
+ const bad = [doc, nav, refresh, pre].find((r) => r.status >= 400 || r.status === 0);
+ console.log(
+ route.padEnd(16) +
+ fmt(doc.ms) +
+ " " +
+ fmt(nav.ms) +
+ " " +
+ fmt(refresh.ms) +
+ " " +
+ fmt(pre.ms) +
+ " " +
+ kb(pre.bytes) +
+ (bad ? ` ⚠️ status ${bad.status}${bad.error ? ` ${bad.error}` : ""}` : ""),
+ );
+}
+
+console.log("\n" + "endpoint".padEnd(28) + "ms".padStart(8) + " size");
+console.log("-".repeat(56));
+for (const api of APIS) {
+ const r = await best(() => timeGet(`${BASE}${api}`));
+ console.log(
+ api.padEnd(28) +
+ fmt(r.ms) +
+ " " +
+ kb(r.bytes) +
+ (r.status >= 400 || r.status === 0 ? ` ⚠️ status ${r.status}` : ""),
+ );
+}
+
+// The idle pulse is the one that has to stay cheap forever — measure it the way
+// the client actually calls it, with a rev in hand.
+const first = await timeGet(`${BASE}/api/pulse`);
+if (first.status === 200) {
+ const res = await fetch(`${BASE}/api/pulse`);
+ const body = await res.json();
+ const idle = await best(() =>
+ timeGet(`${BASE}/api/pulse?rev=${encodeURIComponent(body.rev)}`),
+ );
+ console.log(
+ "\n" +
+ "/api/pulse (idle, rev matched)".padEnd(34) +
+ fmt(idle.ms) +
+ "ms ← this one is polled forever; keep it tiny",
+ );
+}