Public: anyone can read this; the crew can change it.

A loop stall over 200 ms is named by what ran: route or job, its ms, the last hour's worst on /system #11075

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

torque/forge:torque/11069-a-loop-stall-over-200-ms-is-named-by-wha → main 4 commits fork 2 threads · 2 unresolved

  • Merged. torque/11069-a-loop-stall-over-200-ms-is-named-by-wha was merged into main as 44bd10e.

Review

Sign in to review this change.
17 files changed +433 −35
AGENTS.md +2
@@ -19,6 +19,8 @@ State shared between modules through process memory, not the database, is named
1919
2020 `src/perf/loop_worst.ts`: `loopWorst`, the worst event-loop lag of the last 10 s in ten one-second slots, fed by one unref'd 50 ms timer that the first `serve` starts (`startLoopWorst`), and read by every response's `lag;dur` and by `/healthz` asked for JSON (`loopLagMs`). Tests listing a request's phases read `requestTiming` (`test/helpers/request-timing.ts`), which leaves `lag` out.
2121
22 `src/perf/stall_blame.ts` (`stallBlame` in `src/perf/live.ts`): each loop stall over 200 ms as `{ what, ms, at }` in a fixed ring of 256, fed by `main.ts`'s stall watcher after `loopWatch` names it, read by /system's worst-five card and `/healthz`'s `loopWorstWhat`.
23
2224 `src/apps/relay_wait.ts`: each app's slowest production wait relayed in the last 10 minutes, with the forge's loop lag beside it, noted by the proxy (`relayUpstream`) and read by `appVitals`; `forgeWaited` decides whose wait it was, and the speed card and terminal line say "the forge was busy" when it was the forge's (#10858). At most 2,000 apps, gone on restart.
2325
2426 `src/core/transfer.ts`: the old owner/name pairs a transfer left behind (`repo_redirect`), held per database after the first request reads them and dropped by `transferRepo`, so a request under a path nothing moved from costs no statement (#569).
docs/METRICS.md +1 −1
@@ -23,7 +23,7 @@ not an admin, 200 for an admin's `ro` token. It is never cached
2323 | `forge_ask_to_live_seconds` | histogram | none (plus `le`) | Seconds from an issue opening to the first successful release of a pull that closes it. Buckets: 1 m, 5 m, 15 m, 1 h, 4 h, 12 h, 1 d, 3 d, 7 d. |
2424 | `forge_main_red_seconds_total` | counter | `repo` | Seconds the default branch's check spent failing: from a failed check to the next green one, or to now while still red. |
2525 | `forge_telemetry_reads_total` | counter | `route`, `reader` | Reads of `/api/v1/telemetry` routes since the process started, by route and reader; the rate is the slope. The owner's `/system` page names each reader's pace over the last 10 minutes. |
26 | `forge_loop_stall_milliseconds_total` | counter | `route` | Milliseconds the main thread stalled past 250 ms since the process started, charged in full to every route pattern running across the stall (`none` when no request was). |
26 | `forge_loop_stall_milliseconds_total` | counter | `route` | Milliseconds the main thread stalled past 200 ms since the process started, charged in full to every route pattern running across the stall (`none` when no request was). |
2727 | `forge_loop_stalls_total` | counter | `route` | Those stalls, counted the same way. |
2828 | `forge_loop_stall_hour_milliseconds` | gauge | `route` | Stall milliseconds in the last hour: the top 10 routes, most first, plus `/api/v1/telemetry/flow` always (0 when it stalled nothing). |
2929 | `forge_loop_stall_hour_count` | gauge | `route` | Stalls in the last hour, for the routes the gauge above lists. |
docs/features/stalls-by-route.md +9 −2
@@ -1,6 +1,6 @@
11 # Main-thread stalls by route (#9016)
22
3 The forge counts every wake of its event loop more than 250 ms late as a
3 The forge counts every wake of its event loop more than 200 ms late as a
44 stall, and charges it in full to each route pattern whose request ran across
55 it (`none` when no request did) — the same reading as the `event loop blocked`
66 log lines (`src/perf/loop.ts`), so a fix's before and after reads from the
@@ -12,7 +12,14 @@ palette entry):
1212 - **`/system`**, the "Main-thread stalls, last hour" card: route, stall ms and
1313 count for the top 10 routes, most first, plus `/api/v1/telemetry/flow`
1414 always (0 when it stalled nothing). As of the page's load.
15 - **The terminal**: `:` → "Main-thread stalls", the same rows.
15 - **`/system`**, inside that card, "Worst single stalls" (#11069), drawn only
16 when the hour had one: the five worst single stalls, each with what ran (the route pattern, `job:<name>` for a job
17 marked with `jobWatch.during`, or `unknown`), its ms and when. Kept in a
18 fixed ring of 256 (`src/perf/stall_blame.ts`).
19 - **`/healthz`** with `Accept: application/json`: beside `loopLagMs`,
20 `loopWorstWhat` names the worst stall of the same 10 s (null when none).
21 - **The terminal**: `:` → "Main-thread stalls", the same rows, then the same
22 worst single stalls (what ran, ms, when in UTC) when the hour had one.
1623 - **`GET /metrics`** (an admin `ro` token): `forge_loop_stall_milliseconds_total`
1724 and `forge_loop_stalls_total` by `route` since the process started, and the
1825 last hour as `forge_loop_stall_hour_milliseconds` / `forge_loop_stall_hour_count`
src/http/health.ts +14 −4
@@ -90,10 +90,17 @@ export function startDraining(log: Log, why: string, fields: Record<string, numb
9090 *
9191 * Given `loopLagMs` (the caller asked for JSON), the 200 is
9292 * `{"ok":true,"loopLagMs":n}`: the worst event-loop lag of the last 10 s
93 * (`perf/loop_worst.ts`, #10288). Otherwise it stays the plain `ok` a
94 * probe and a stranger hear (#2917).
93 * (`perf/loop_worst.ts`, #10288), with `loopWorstWhat`: the route pattern,
94 * `job:<name>` or `unknown` the worst stall over 200 ms in those 10 s was
95 * blamed on, null when there was none (`perf/stall_blame.ts`, #11069).
96 * Otherwise it stays the plain `ok` a probe and a stranger hear (#2917).
9597 */
96 export function liveResponse(ping: () => unknown, log: Log, loopLagMs?: number): Response {
98 export function liveResponse(
99 ping: () => unknown,
100 log: Log,
101 loopLagMs?: number,
102 loopWorstWhat: string | null = null,
103 ): Response {
97104 try {
98105 ping();
99106 } catch (error) {
@@ -107,7 +114,10 @@ export function liveResponse(ping: () => unknown, log: Log, loopLagMs?: number):
107114 if (databaseDown) log.info("forge alive again: the database answers");
108115 databaseDown = false;
109116 if (loopLagMs !== undefined && Number.isFinite(loopLagMs))
110 return Response.json({ ok: true, loopLagMs: Math.round(loopLagMs * 10) / 10 }, { status: 200 });
117 return Response.json(
118 { ok: true, loopLagMs: Math.round(loopLagMs * 10) / 10, loopWorstWhat },
119 { status: 200 },
120 );
111121 return new Response("ok", { status: 200 });
112122 }
113123
src/http/server.ts +3 −2
@@ -507,7 +507,7 @@ import {
507507 takeDbTime,
508508 type RouteTiming,
509509 } from "../core/route_timing.ts";
510 import { captureStore, loopWatch, perfRecord, readLedger } from "../perf/live.ts";
510 import { captureStore, loopWatch, perfRecord, readLedger, stallBlame } from "../perf/live.ts";
511511 import { during } from "../perf/jobs.ts";
512512 import { classOf, countryOf, repoOf, signatureOf } from "../perf/traffic.ts";
513513 import { createTrafficWriter } from "../perf/traffic_store.ts";
@@ -519,7 +519,7 @@ import { statementPage } from "../web/pages/statement.ts";
519519 import { createViewWriter } from "../perf/views_store.ts";
520520 import { createReadWriter, readOf } from "../perf/reads.ts";
521521 import { parseServerTiming } from "../perf/record.ts";
522 import { currentLoopLag, startLoopWorst } from "../perf/loop_worst.ts";
522 import { currentLoopLag, LAG_WINDOW_MS, startLoopWorst } from "../perf/loop_worst.ts";
523523 import { methodOf, noteUsage, roleOf, surfaceOf, takeUsage } from "./usage_tag.ts";
524524 import { bearerChallenge, oauthRoutes } from "./oauth.ts";
525525 import { flushAccess, recordAccess } from "./access_log.ts";
@@ -2829,6 +2829,7 @@ async function answerGet(
28292829 () => app.db.query("select 1").get(),
28302830 app.log,
28312831 wantsJson ? currentLoopLag() : undefined,
2832 wantsJson ? (stallBlame.worst(performance.now(), LAG_WINDOW_MS, 1)[0]?.what ?? null) : null,
28322833 );
28332834 noteDbTime(request, performance.now() - dbStart);
28342835 // The release proof learns which release answered, old pod or new; a
src/main.ts +4 −2
@@ -108,7 +108,7 @@ import { watchStalls } from "./kernel/loop_lag.ts";
108108 import { startPerfWatcher, TICK_MS } from "./perf/duty.ts";
109109 import { during } from "./perf/jobs.ts";
110110 import { stallLine } from "./perf/loop.ts";
111 import { bootStall, gitRecord, loopWatch, perfRecord } from "./perf/live.ts";
111 import { bootStall, gitRecord, loopWatch, perfRecord, stallBlame } from "./perf/live.ts";
112112 import { timeStatements } from "./kernel/slow_sql.ts";
113113 import { waitForPeer } from "./runs/peer.ts";
114114 import { closeStaleSessions } from "./machines/shell.ts";
@@ -241,7 +241,9 @@ const loopLog = createLog("loop");
241241 watchStalls(loopLog, undefined, undefined, undefined, (late, at) => {
242242 bootStall.note(late, at);
243243 const stall = loopWatch.stalled(late, at);
244 if (stall !== null) loopLog.warn(stallLine(stall));
244 if (stall === null) return;
245 loopLog.warn(stallLine(stall));
246 stallBlame.note(stall);
245247 });
246248
247249 const previewKey = previewIdentityKey(process.env);
src/metrics/stalls.ts +2 −2
@@ -30,13 +30,13 @@ export function stallFamilies(tally: StallTally): Family[] {
3030 {
3131 type: "counter",
3232 name: "forge_loop_stall_milliseconds_total",
33 help: "Milliseconds the main thread stalled past 250 ms, by the route running across the stall (route=\"none\" when none), since the process started.",
33 help: "Milliseconds the main thread stalled past 200 ms, by the route running across the stall (route=\"none\" when none), since the process started.",
3434 samples: tally.total.map((r) => ({ labels: { route: r.route }, value: Math.round(r.ms) })),
3535 },
3636 {
3737 type: "counter",
3838 name: "forge_loop_stalls_total",
39 help: "Main-thread stalls past 250 ms, by the route running across the stall, since the process started.",
39 help: "Main-thread stalls past 200 ms, by the route running across the stall, since the process started.",
4040 samples: tally.total.map((r) => ({ labels: { route: r.route }, value: r.count })),
4141 },
4242 {
src/perf/live.ts +3
@@ -6,6 +6,7 @@ import { jobWatch } from "./jobs.ts";
66 import { createLoopWatch } from "./loop.ts";
77 import { createCaptureStore } from "./capture.ts";
88 import { createReadLedger } from "./reads.ts";
9 import { createStallBlame } from "./stall_blame.ts";
910 import { createPerfRecord } from "./record.ts";
1011
1112 export const perfRecord = createPerfRecord();
@@ -17,3 +18,5 @@ export const bootStall = createBootStall();
1718 /** What was read from repositories (`reads.ts`), and the one bounded capture an admin may run (`capture.ts`). */
1819 export const readLedger = createReadLedger();
1920 export const captureStore = createCaptureStore();
21 /** Each stall over 200 ms with what ran (#11069): the last hour's worst on /system, the last 10 s's on `/healthz`. */
22 export const stallBlame = createStallBlame();
src/perf/loop.ts +4 −3
@@ -16,12 +16,13 @@
1616 * stall nothing named ran across.
1717 */
1818
19 export const LIMIT_MS = 250;
19 /** A wake later than this is a stall, ms (#11069: 200, so a stall the probes would feel is named). */
20 export const LIMIT_MS = 200;
2021 const RECENT = 256;
2122 /** The window `StallTally.hour` covers, in ms. */
2223 export const HOUR_MS = 3_600_000;
23 /** Stalls kept for the hour: a stall is over 250 ms, so 14,400 fill an hour solid. */
24 const HOUR_KEPT = 14_400;
24 /** Stalls kept for the hour: a stall is over 200 ms, so 18,000 fill an hour solid. */
25 const HOUR_KEPT = 18_000;
2526 /** The route a stall is counted against when no request ran across it. */
2627 export const NO_REQUEST = "none";
2728 /** The prefix a background job's name is tallied under, apart from routes. */
src/perf/stall_blame.ts added +81
@@ -0,0 +1,81 @@
1 /**
2 * Each loop stall over 200 ms, named by what ran (#11069): `{ what, ms, at }`
3 * in a fixed ring, so /system can list the last hour's worst and `/healthz`
4 * can say what held the loop behind its `loopLagMs`.
5 *
6 * Who ran is the loop watch's finding (`loop.ts`): the route patterns whose
7 * request ran across the blocked window, else the background jobs marked
8 * with `jobWatch.during` (`jobs.ts`), else nothing. `what` is the first of
9 * those — the longest-running route, or `job:<name>` — and `unknown` when
10 * nothing marked ran across the stall.
11 *
12 * The ring holds `KEPT` stalls and never grows: a note is three array
13 * writes and an index step. At one stall per 200 ms an hour would be 18,000,
14 * so a loop stalling solid for an hour keeps its last 256 — the worst of a
15 * day that bad is still on the page, just not every one.
16 */
17
18 import { blamed, NO_REQUEST, type Stall } from "./loop.ts";
19
20 /** The stalls the ring holds; fixed. */
21 export const STALL_KEPT = 256;
22 /** How many of the last hour's worst stalls /system and the terminal list. */
23 export const WORST_STALLS = 5;
24 /** What a stall is named when no route or job ran across it. */
25 export const UNKNOWN = "unknown";
26
27 export interface BlamedStall {
28 /** A route pattern, `job:<name>`, or `unknown`. */
29 readonly what: string;
30 /** How late the wake was, ms. */
31 readonly ms: number;
32 /** When the wake saw it, on the watcher's clock (`performance.now`). */
33 readonly at: number;
34 }
35
36 export interface StallBlame {
37 /** Keep a stall the loop watch named. */
38 note(stall: Stall): void;
39 /** The `n` worst stalls seen in `(now - windowMs, now]`, most ms first, then newest. */
40 worst(now: number, windowMs: number, n: number): BlamedStall[];
41 /** How many stalls the ring holds now; never more than its size. */
42 size(): number;
43 /** Forget them all. */
44 clear(): void;
45 }
46
47 /** The name one stall is blamed on. */
48 export function whatOf(stall: Stall): string {
49 const first = blamed(stall)[0];
50 return first === undefined || first === NO_REQUEST ? UNKNOWN : first;
51 }
52
53 export function createStallBlame(kept = STALL_KEPT): StallBlame {
54 const n = Math.max(1, Math.floor(kept));
55 const what: string[] = new Array<string>(n).fill("");
56 const ms = new Float64Array(n);
57 const at = new Float64Array(n);
58 let next = 0;
59 let count = 0;
60 return {
61 note(stall) {
62 if (!Number.isFinite(stall.lateMs) || !Number.isFinite(stall.at)) return;
63 what[next] = whatOf(stall);
64 ms[next] = stall.lateMs;
65 at[next] = stall.at;
66 next = (next + 1) % n;
67 if (count < n) count++;
68 },
69 worst(now, windowMs, k) {
70 const out: BlamedStall[] = [];
71 for (let i = 0; i < count; i++)
72 if (at[i]! > now - windowMs && at[i]! <= now) out.push({ what: what[i]!, ms: ms[i]!, at: at[i]! });
73 return out.sort((a, b) => b.ms - a.ms || b.at - a.at).slice(0, Math.max(0, k));
74 },
75 size: () => count,
76 clear() {
77 next = 0;
78 count = 0;
79 },
80 };
81 }
src/tui/screens/loopstalls.ts +42 −9
@@ -1,14 +1,16 @@
11 /**
22 * Main-thread stalls in the terminal, the owner's (#9016): the last hour's
3 * top routes by milliseconds the loop was held past 250 ms, with
3 * top routes by milliseconds the loop was held past 200 ms, with

Both doors: only a comment changed here. The terminal screen must also list the worst five single stalls (what, ms, when) like /system, and /healthz-equivalent parity; add it plus a test.

44 * /api/v1/telemetry/flow always listed — the rows `/system` and `/metrics`
5 * read (`stallHour`). The gate is `/system`'s: a forge admin who is a person.
6 * Read-only.
5 * read (`stallHour`); then, as /system draws them (#11069), the last hour's
6 * worst single stalls — what ran, its ms, and when, in UTC — only when there
7 * is one. The gate is `/system`'s: a forge admin who is a person. Read-only.
78 */
89
910 import { stallHour } from "../../metrics/stalls.ts";
10 import type { RouteStall } from "../../perf/loop.ts";
11 import { loopWatch } from "../../perf/live.ts";
11 import { HOUR_MS, type RouteStall } from "../../perf/loop.ts";
12 import { loopWatch, stallBlame } from "../../perf/live.ts";
13 import { WORST_STALLS, type BlamedStall } from "../../perf/stall_blame.ts";
1214 import type { Ctx, Screen } from "../app/app.ts";
1315 import { binding } from "../app/keys.ts";
1416 import type { Session } from "../session.ts";
@@ -19,14 +21,44 @@ import type { PaletteEntry } from "./entries.ts";
1921
2022 const mayRead = (session: Session): boolean => session.user?.isAdmin === true && session.user.kind !== "agent";
2123
22 /** The header, then a route a row; empty for anyone the gate refuses. */
23 export function stallLines(session: Session, rows: readonly RouteStall[] = stallHour(loopWatch.tally(performance.now()))): string[] {
24 /** The last hour's worst single stalls and one instant on both clocks, as /system reads them. */
25 export interface WorstNow {
26 readonly rows: readonly BlamedStall[];
27 /** The instant on the watcher's clock (`performance.now`). */
28 readonly mono: number;
29 /** The same instant on the wall clock, ms since the epoch. */
30 readonly wall: number;
31 }
32
33 function worstNow(): WorstNow {
34 const mono = performance.now();
35 return { rows: stallBlame.worst(mono, HOUR_MS, WORST_STALLS), mono, wall: Date.now() };
36 }
37
38 /**
39 * The header, then a route a row; then, when the hour had any, a blank line and
40 * the worst single stalls. A stall's wall time is `wall − (mono − at)`, as on
41 * /system. Empty for anyone the gate refuses.
42 */
43 export function stallLines(
44 session: Session,
45 rows: readonly RouteStall[] = stallHour(loopWatch.tally(performance.now())),
46 worst: WorstNow = worstNow(),
47 ): string[] {
2448 if (!mayRead(session)) return [];
2549 const col = (s: string) => pad(s, 10, "right");
26 return [
50 const lines = [
2751 `${pad("Route (last hour)", 40)}${col("Stall ms")}${col("Stalls")}`,
2852 ...rows.map((r) => `${pad(r.route, 40)}${col(String(Math.round(r.ms)))}${col(String(r.count))}`),
2953 ];
54 if (worst.rows.length === 0) return lines;
55 const when = (at: number) => `${new Date(worst.wall - (worst.mono - at)).toISOString().slice(11, 19)} UTC`;
56 return [
57 ...lines,
58 "",
59 `${pad("Worst single stalls (what ran)", 40)}${col("ms")} When`,
60 ...worst.rows.map((r) => `${pad(r.what, 40)}${col(String(Math.round(r.ms)))} ${when(r.at)}`),
61 ];
3062 }
3163
3264 export function stallsScreen(session: Session, ctx: Ctx): Screen {
@@ -37,7 +69,8 @@ export function stallsScreen(session: Session, ctx: Ctx): Screen {
3769 render(area, s) {
3870 const c = ctx.theme.c;
3971 const lines = stallLines(session);
40 const spans = lines.map((text, i) => [{ text, style: style(i === 0 ? c.fg : c.mutedFg, c.bg) }]);
72 const worstHead = lines.indexOf("") + 1; // 0 when there is no worst list
73 const spans = lines.map((text, i) => [{ text, style: style(i === 0 || i === worstHead ? c.fg : c.mutedFg, c.bg) }]);
4174 drawLines(s, area, c, scroll, spans);
4275 hitScroll(ctx, area, scroll, lines.length);
4376 },
src/web/pages/system.ts +27 −7
@@ -30,8 +30,9 @@ import { live } from "../../system/live.ts";
3030 import { readerPaces, type ReaderPace } from "../../system/telemetry_reads.ts";
3131 import { slowPageLine, slowPages, type SlowPage } from "../../perf/watch.ts";
3232 import { stallHour } from "../../metrics/stalls.ts";
33 import type { RouteStall } from "../../perf/loop.ts";
34 import { loopWatch } from "../../perf/live.ts";
33 import { HOUR_MS, type RouteStall } from "../../perf/loop.ts";
34 import { loopWatch, stallBlame } from "../../perf/live.ts";
35 import { WORST_STALLS, type BlamedStall } from "../../perf/stall_blame.ts";
3536 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
3637 import { notFoundPage, signInFirst } from "../chrome.ts";
3738 import { html, raw, type Html } from "../html.ts";
@@ -42,7 +43,7 @@ import { KEPT_PAGE_BYTES, keptPageStats } from "./kept_page.ts";
4243 import { COLUMN_BYTES, PARTS_BYTES, keptPartsStats, treeCacheStats } from "./tree_cache.ts";
4344 import { featureHealth, type FeatureHealth } from "../../system/feature_health.ts";
4445 import { egressReportedLine } from "../../system/egress_line.ts";
45 import { badge, chartBox, meter, pill, row } from "../ui.ts";
46 import { badge, chartBox, meter, pill, row, time } from "../ui.ts";
4647 import { childDirOf, listChildren } from "../../actions/spawn.ts";
4748 import { childrenUse, realSelf, selfUse, shareOf, sizeWords, useWords, type ChildUseRow, type SelfUse } from "../../spawn/usage.ts";
4849
@@ -197,15 +198,32 @@ export function featuresSection(rows: readonly FeatureHealth[]): Html {
197198 }
198199
199200 /** The last hour's stalls by route, most milliseconds first; the rows are `stallHour`'s. */
200 export function stallSection(rows: readonly RouteStall[]): Html {
201 export function stallSection(rows: readonly RouteStall[], worst: Html = html``): Html {
201202 return html`<section class="settings-block"><h2>Main-thread stalls, last hour</h2>
202 <p class="small muted">Past 250 ms, as of this page's load; <code>/metrics</code> has the series.</p>${
203 <p class="small muted">Past 200 ms, as of this page's load; <code>/metrics</code> has the series.</p>${
203204 rows.length === 0
204205 ? html`<p class="muted">No stalls in the last hour.</p>`
205206 : html`<div class="table-scroll"><table class="table" aria-label="Main-thread stalls by route, last hour">
206207 <thead><tr><th scope="col">Route</th><th scope="col" class="num">Stall ms</th><th scope="col" class="num">Stalls</th></tr></thead>
207208 <tbody>${rows.map((r) => html`<tr data-route="${r.route}"><td class="mono">${r.route}</td><td class="num mono">${String(Math.round(r.ms))}</td><td class="num mono">${String(r.count)}</td></tr>`)}</tbody>
208 </table></div>`}</section>`;
209 </table></div>`}${worst}</section>`;
210 }
211
212 /** How many of the last hour's worst stalls the page lists (#11069); the terminal lists as many. */
213 export { WORST_STALLS };
214
215 /**
216 * The last hour's worst single stalls, each with what ran (#11069), drawn inside
217 * the stalls section and only when there is one, so a quiet /system reads as before.
218 * `at` is on the watcher's clock (`performance.now`); `nowMono`/`nowWall` are one
219 * instant on it and on the wall clock, so a stall's wall time is `nowWall − (nowMono − at)`.
220 */
221 export function worstStallsTable(rows: readonly BlamedStall[], nowMono: number, nowWall: number): Html {
222 if (rows.length === 0) return html``;
223 return html`<h3 id="sys-worst-stalls">Worst single stalls</h3><div class="table-scroll"><table class="table" aria-label="Worst single stalls, last hour">
224 <thead><tr><th scope="col">What ran</th><th scope="col" class="num">ms</th><th scope="col">When</th></tr></thead>
225 <tbody>${rows.map((r) => html`<tr data-what="${r.what}"><td class="mono">${r.what}</td><td class="num mono">${String(Math.round(r.ms))}</td><td>${time(new Date(nowWall - (nowMono - r.at)).toISOString())}</td></tr>`)}</tbody>
226 </table></div>`;
209227 }
210228
211229 const SLOW_TTL_MS = 5000;
@@ -320,6 +338,8 @@ export async function systemPage(ctx: Ctx): Promise<Response> {
320338 const self = owner ? timePhase(ctx.request, "self", () => selfUse(realSelf(ctx.app.config.dataDir))) : null;
321339 const children = owner ? childrenUse(await listChildren(ctx.app), (bodyId) => childDirOf(ctx.app, bodyId)) : [];
322340 const stalls = timePhase(ctx.request, "stalls", () => stallHour(loopWatch.tally(performance.now())));
341 const nowMono = performance.now();
342 const worst = stallBlame.worst(nowMono, HOUR_MS, WORST_STALLS);
323343 const rendering = performance.now();
324344 const r = regions(v);
325345 const at = (id: string): Html => raw(r[id]!);
@@ -350,7 +370,7 @@ export async function systemPage(ctx: Ctx): Promise<Response> {
350370 <button class="btn" data-size="sm" type="button" data-sort="mem" aria-pressed="false"><kbd>m</kbd> memory</button></div>
351371 <div id="sys-procs">${at("sys-procs")}</div></section>
352372 ${featuresSection(timePhase(ctx.request, "features", () => featureHealth(ctx.app, reader)))}
353 <div id="sys-stalls">${stallSection(stalls)}</div>
373 <div id="sys-stalls">${stallSection(stalls, worstStallsTable(worst, nowMono, Date.now()))}</div>
354374 <div id="sys-slow-pages">${slowPagesSection(timePhase(ctx.request, "slowpages", () => slowPagesCached(ctx.app.db)))}</div>
355375 <div id="sys-readers">${readersSection(readerPaces())}</div>
356376 <div id="sys-caches">${cachesSection(pageCaches())}</div>
test/attention.test.ts +3
@@ -22,8 +22,11 @@ import { proveAll } from "./helpers/verified.ts";
2222 import { useLive, type Live } from "../src/system/live.ts";
2323 import type { SystemView } from "../src/system/view.ts";
2424 import { quietLogs } from "./helpers/globals.ts";
25 import { freshStallState } from "./helpers/stall-state.ts";
2526
2627 quietLogs();
28 // /system draws the process's stall watch and telemetry readers; start from a clean one.
29 freshStallState();
2730
2831 const BUDGET = "docs/design/attention-budget.json";
2932 type Budget = Record<string, Record<string, Attention>>;
test/helpers/stall-state.ts added +25
@@ -0,0 +1,25 @@
1 /**
2 * The process's stall watch, starting clean and left clean (#11069). The
3 * suite runs every file in one process, and `loopWatch`, `stallBlame` and the
4 * telemetry reader counts (`perf/live.ts`, `system/telemetry_reads.ts`) are
5 * that process's; a stall one file noted would otherwise be a row in the next
6 * file's /system. Call at a file's top level, as `quietLogs` is
7 * (`globals.ts`): it clears before the file's first test and after its last.
8 */
9
10 import { afterAll, beforeAll } from "bun:test";
11 import { loopWatch, stallBlame } from "../../src/perf/live.ts";
12 import { resetTelemetryReads } from "../../src/system/telemetry_reads.ts";
13
14 /** Forget every stall and telemetry read the process has noted. */
15 export function clearStallState(): void {
16 loopWatch.clear();
17 stallBlame.clear();
18 resetTelemetryReads();
19 }
20
21 /** Clear the process's stall watch before this scope runs and after it. */
22 export function freshStallState(): void {
23 beforeAll(clearStallState);
24 afterAll(clearStallState);
25 }
test/loop-stall-by-route.test.ts +40 −3
@@ -11,7 +11,8 @@ import { serve, type Servers } from "../src/http/server.ts";
1111 import { watchStalls } from "../src/kernel/loop_lag.ts";
1212 import { openMetrics } from "../src/metrics/openmetrics.ts";
1313 import { stallFamilies, TOP_ROUTES } from "../src/metrics/stalls.ts";
14 import { loopWatch } from "../src/perf/live.ts";
14 import { loopWatch, stallBlame } from "../src/perf/live.ts";
15 import { WORST_STALLS } from "../src/perf/stall_blame.ts";
1516 import { createLoopWatch, HOUR_MS, NO_REQUEST } from "../src/perf/loop.ts";
1617 import { createAgent } from "../src/actions/crew.ts";
1718 import type { User } from "../src/core/users.ts";
@@ -20,8 +21,10 @@ import { createForgeApp, forgeCommands, Session } from "../src/tui/forge.ts";
2021 import { stallLines } from "../src/tui/screens/loopstalls.ts";
2122 import { headlessTerminal } from "../src/tui/term/terminal.ts";
2223 import { quietLogs } from "./helpers/globals.ts";
24 import { freshStallState } from "./helpers/stall-state.ts";
2325
2426 quietLogs();
27 freshStallState();
2528
2629 test("a deliberate stall during a route is counted against it", async () => {
2730 // The loop watcher on a clock the test moves: the route holds the thread
@@ -183,15 +186,49 @@ const doors = (user?: User): string[] => {
183186 return ids;
184187 };
185188
189 const quiet = { rows: [], mono: 0, wall: 0 };
190
186191 test("the Stalls screen gives an admin person the rows and the door; nobody else either", () => {
187192 const rows = [{ route: "/slow/:id", ms: 812.6, count: 2 }, { route: "/api/v1/telemetry/flow", ms: 0, count: 0 }];
188 const lines = stallLines(new Session(app, identity(alice)), rows);
193 const lines = stallLines(new Session(app, identity(alice)), rows, quiet);
194 expect(lines).toHaveLength(3);
189195 expect(lines[0]).toContain("Stall ms");
190196 expect(lines[1]).toMatch(/^\/slow\/:id\s+813\s+2$/);
191197 expect(lines[2]).toMatch(/^\/api\/v1\/telemetry\/flow\s+0\s+0$/);
192198 expect(doors(alice)).toContain("stalls");
193199 for (const who of [bob, bot, undefined]) {
194 expect(stallLines(new Session(app, identity(who)), rows)).toEqual([]);
200 expect(stallLines(new Session(app, identity(who)), rows, quiet)).toEqual([]);
195201 expect(doors(who)).not.toContain("stalls");
196202 }
197203 });
204
205 test("the Stalls screen lists the hour's worst single stalls as /system does: what, ms, when (#11069)", () => {
206 // One instant: 1,000,000 ms on the watcher's clock is 2026-11-10T12:00:00Z on the wall.
207 const wall = Date.parse("2026-11-10T12:00:00Z");
208 const worst = {
209 rows: [
210 { what: "/slow/:id", ms: 812.6, at: 1_000_000 - 90_000 }, // 90 s before: 11:58:30
211 { what: "job:sampler", ms: 240.2, at: 1_000_000 - 3_600_000 + 1 }, // just inside the hour: 11:00:00
212 ],
213 mono: 1_000_000,
214 wall,
215 };
216 const lines = stallLines(new Session(app, identity(alice)), [{ route: "/api/v1/telemetry/flow", ms: 0, count: 0 }], worst);
217 expect(lines[2]).toBe("");
218 expect(lines[3]).toMatch(/^Worst single stalls \(what ran\)\s+ms\s+When$/);
219 expect(lines[4]).toMatch(/^\/slow\/:id\s+813\s+11:58:30 UTC$/);
220 expect(lines[5]).toMatch(/^job:sampler\s+240\s+11:00:00 UTC$/);
221 expect(lines).toHaveLength(6);
222 expect(stallLines(new Session(app, identity(bob)), [], worst)).toEqual([]);
223 });
224
225 test("the Stalls screen reads the process's stall blame by default, the five /system reads", () => {
226 const now = performance.now();
227 for (const [ms, r] of [[300, "/a"], [800, "/b"], [250, "/c"], [500, "/d"], [700, "/e"], [210, "/f"]] as const)
228 stallBlame.note({ at: now - 1_000, lateMs: ms, routes: [r], jobs: [] });
229 const lines = stallLines(new Session(app, identity(alice)), []);
230 const named = lines.slice(lines.indexOf("") + 2).map((l) => l.split(/\s+/)[0] ?? "");
231 expect(named).toEqual(["/b", "/e", "/d", "/a", "/c"]);
232 expect(stallBlame.worst(performance.now(), HOUR_MS, WORST_STALLS).map((s) => s.what)).toEqual(named);
233 stallBlame.clear();
234 });
test/perf-loop-jobs.test.ts +4
@@ -4,6 +4,10 @@ import { expect, test } from "bun:test";
44 import { createJobWatch, during } from "../src/perf/jobs.ts";
55 import { loopWatch } from "../src/perf/live.ts";
66 import { createLoopWatch, NO_REQUEST, stallLine } from "../src/perf/loop.ts";
7 import { freshStallState } from "./helpers/stall-state.ts";
8
9 // The wired test notes a stall on the process's watch, which every file in this process shares.
10 freshStallState();
711
812 const block = (ms: number): void => {
913 const until = performance.now() + ms;
test/perf-stall-blame.test.ts added +169
@@ -0,0 +1,169 @@
1 /**
2 * A loop stall over 200 ms is named by what ran (#11069): a real busy-wait
3 * inside a marked job is blamed on that job, one in an unmarked callback is
4 * `unknown`, the ring never grows past its size, /system lists the worst
5 * five and `/healthz` (JSON) says what held the loop as `loopWorstWhat`.
6 */
7
8 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9 import { mkdtemp, rm } from "node:fs/promises";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import { createApp, type App } from "../src/app.ts";
13 import { serve, type Servers } from "../src/http/server.ts";
14 import { startLoopLag } from "../src/kernel/loop_lag.ts";
15 import { createJobWatch } from "../src/perf/jobs.ts";
16 import { stallBlame } from "../src/perf/live.ts";
17 import { createLoopWatch, HOUR_MS, LIMIT_MS, type Stall } from "../src/perf/loop.ts";
18 import { createStallBlame, STALL_KEPT, UNKNOWN, whatOf } from "../src/perf/stall_blame.ts";
19 import { stallSection, worstStallsTable } from "../src/web/pages/system.ts";
20
21 function blockFor(ms: number): void {
22 const until = performance.now() + ms;
23 while (performance.now() < until) {
24 // the thread held, as a stall holds it
25 }
26 }
27
28 /** Run `block` on the loop with a 10 ms lag sampler wired as main.ts wires it; resolves with the ring. */
29 async function stallUnder(block: (jobs: ReturnType<typeof createJobWatch>) => void) {
30 const jobs = createJobWatch();
31 const watch = createLoopWatch(LIMIT_MS, jobs.across);
32 const blame = createStallBlame();
33 const lag = startLoopLag(10, () => performance.now(), (late) => {
34 const stall = watch.stalled(late, performance.now());
35 if (stall !== null) blame.note(stall);
36 });
37 // Let the sampler arm, then hold the loop from a timer callback.
38 await new Promise((r) => setTimeout(r, 15));
39 await new Promise<void>((r) =>
40 setTimeout(() => {
41 block(jobs);
42 r();
43 }, 0),
44 );
45 // The overdue wake runs next.
46 await new Promise((r) => setTimeout(r, 30));
47 lag.stop();
48 return blame;
49 }
50
51 describe("what a stall is blamed on", () => {
52 test("the stall limit is 200 ms", () => {
53 expect(LIMIT_MS).toBe(200);
54 });
55
56 test("a busy-wait inside a marked job is blamed on that job", async () => {
57 const blame = await stallUnder((jobs) => jobs.during("snapshot", () => blockFor(260)));
58 const [worst] = blame.worst(performance.now(), HOUR_MS, 5);
59 expect(worst?.what).toBe("job:snapshot");
60 expect(worst!.ms).toBeGreaterThan(200);
61 });
62
63 test("a busy-wait in an unmarked callback is unknown", async () => {
64 const blame = await stallUnder(() => blockFor(260));
65 const [worst] = blame.worst(performance.now(), HOUR_MS, 5);
66 expect(worst?.what).toBe(UNKNOWN);
67 expect(worst!.ms).toBeGreaterThan(200);
68 });
69
70 test("a route that ran across is named before any job", () => {
71 const s: Stall = { at: 1, lateMs: 300, routes: ["/explore"], jobs: [] };
72 expect(whatOf(s)).toBe("/explore");
73 expect(whatOf({ ...s, routes: [], jobs: ["sampler"] })).toBe("job:sampler");
74 expect(whatOf({ ...s, routes: [] })).toBe(UNKNOWN);
75 });
76 });
77
78 describe("the ring", () => {
79 test("never holds more than its size, and keeps the newest", () => {
80 const blame = createStallBlame(8);
81 for (let i = 0; i < 1_000; i++) blame.note({ at: i, lateMs: 201 + i, routes: [`/r${i}`], jobs: [] });
82 expect(blame.size()).toBe(8);
83 const all = blame.worst(10_000, 1e9, 100);
84 expect(all.length).toBe(8);
85 expect(all.map((s) => s.what)).toEqual(["/r999", "/r998", "/r997", "/r996", "/r995", "/r994", "/r993", "/r992"]);
86 expect(createStallBlame().size()).toBe(0);
87 expect(STALL_KEPT).toBe(256);
88 });
89

A wall-clock threshold (1M notes under 1 s) is a flake risk on a loaded shard; the direction asks for fast tests that never flake. Remove the timing assertion.

90 test("worst is the hour's, most ms first, at most n", () => {
91 const blame = createStallBlame();
92 const now = 10 * HOUR_MS;
93 blame.note({ at: now - HOUR_MS - 1, lateMs: 9_000, routes: ["/old"], jobs: [] });
94 for (const [ms, r] of [[300, "/a"], [800, "/b"], [250, "/c"], [500, "/d"], [700, "/e"], [210, "/f"]] as const)
95 blame.note({ at: now - 1_000, lateMs: ms, routes: [r], jobs: [] });
96 expect(blame.worst(now, HOUR_MS, 5).map((s) => [s.what, s.ms])).toEqual([
97 ["/b", 800], ["/e", 700], ["/d", 500], ["/a", 300], ["/c", 250],
98 ]);
99 });
100
101 test("an hour of solid stalling keeps the ring its size", () => {
102 // 18,000 stalls: one every 200 ms for an hour; the ring holds its last STALL_KEPT.
103 const blame = createStallBlame();
104 for (let i = 0; i < 18_000; i++) blame.note({ at: i * 200, lateMs: 201, routes: ["/explore"], jobs: [] });
105 expect(blame.size()).toBe(STALL_KEPT);
106 expect(blame.worst(18_000 * 200, HOUR_MS, STALL_KEPT + 10).length).toBe(STALL_KEPT);
107 });
108 });
109
110 describe("on /system", () => {
111 test("lists what, ms and when, escaped", () => {
112 const now = 5_000_000;
113 const wall = Date.parse("2026-11-10T12:00:00Z");
114 const out = String(worstStallsTable([{ what: "job:<x>", ms: 436.4, at: now - 120_000 }], now, wall));
115 expect(out).toContain("Worst single stalls");
116 expect(out).toContain("job:&lt;x&gt;");
117 expect(out).not.toContain("job:<x>");
118 expect(out).toContain(">436<");
119 expect(out).toContain('datetime="2026-11-10T11:58:00.000Z"');
120 });
121
122 test("draws nothing when there is none, and sits inside the stalls section when there is", () => {
123 expect(String(worstStallsTable([], 0, 0))).toBe("");
124 const section = String(stallSection([], worstStallsTable([{ what: "/x", ms: 300, at: 0 }], 0, 0)));
125 expect(section).toContain("Main-thread stalls, last hour");
126 expect(section.indexOf("Worst single stalls")).toBeLessThan(section.indexOf("</section>"));
127 });
128 });
129
130 describe("on /healthz", () => {
131 let dataDir: string;
132 let app: App;
133 let servers: Servers;
134
135 beforeAll(async () => {
136 dataDir = await mkdtemp(join(tmpdir(), "forge-stall-blame-"));
137 app = createApp({ dataDir, port: 0 });
138 servers = await serve(app);
139 });
140
141 afterAll(async () => {
142 stallBlame.clear();
143 servers.stop();
144 app.close();
145 await rm(dataDir, { recursive: true, force: true });
146 });
147
148 test("loopWorstWhat is null with no stall, then names the last 10 s's worst", async () => {
149 stallBlame.clear();
150 const ask = async () =>
151 (await (await fetch(`${servers.url}/healthz`, { headers: { accept: "application/json" } })).json()) as {
152 ok: boolean;
153 loopLagMs: number;
154 loopWorstWhat: string | null;
155 };
156 const before = await ask();
157 expect(before.ok).toBe(true);
158 expect(typeof before.loopLagMs).toBe("number");
159 expect(before.loopWorstWhat).toBeNull();
160
161 const now = performance.now();
162 stallBlame.note({ at: now - 60_000, lateMs: 900, routes: ["/too-old"], jobs: [] });
163 stallBlame.note({ at: now, lateMs: 300, routes: [], jobs: ["crew-tick"] });
164 stallBlame.note({ at: now, lateMs: 220, routes: ["/explore"], jobs: [] });
165 expect((await ask()).loopWorstWhat).toBe("job:crew-tick");
166 // A probe asking for nothing still hears the plain `ok` (#2917).
167 expect(await (await fetch(`${servers.url}/healthz`)).text()).toBe("ok");
168 });
169 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs