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
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/11069-a-loop-stall-over-200-ms-is-named-by-wha → main 4 commits fork 2 threads · 2 unresolved
17 files
changed +433
−35
AGENTS.md +2
| @@ -19,6 +19,8 @@ State shared between modules through process memory, not the database, is named | ||
| 19 | 19 | |
| 20 | 20 | `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. |
| 21 | 21 | |
| 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 | ||
| 22 | 24 | `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. |
| 23 | 25 | |
| 24 | 26 | `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 | ||
| 23 | 23 | | `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. | |
| 24 | 24 | | `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. | |
| 25 | 25 | | `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). | | |
| 27 | 27 | | `forge_loop_stalls_total` | counter | `route` | Those stalls, counted the same way. | |
| 28 | 28 | | `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). | |
| 29 | 29 | | `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 @@ | ||
| 1 | 1 | # Main-thread stalls by route (#9016) |
| 2 | 2 | |
| 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 | |
| 4 | 4 | stall, and charges it in full to each route pattern whose request ran across |
| 5 | 5 | it (`none` when no request did) — the same reading as the `event loop blocked` |
| 6 | 6 | log lines (`src/perf/loop.ts`), so a fix's before and after reads from the |
| @@ -12,7 +12,14 @@ palette entry): | ||
| 12 | 12 | - **`/system`**, the "Main-thread stalls, last hour" card: route, stall ms and |
| 13 | 13 | count for the top 10 routes, most first, plus `/api/v1/telemetry/flow` |
| 14 | 14 | 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. | |
| 16 | 23 | - **`GET /metrics`** (an admin `ro` token): `forge_loop_stall_milliseconds_total` |
| 17 | 24 | and `forge_loop_stalls_total` by `route` since the process started, and the |
| 18 | 25 | 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 | ||
| 90 | 90 | * |
| 91 | 91 | * Given `loopLagMs` (the caller asked for JSON), the 200 is |
| 92 | 92 | * `{"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). | |
| 95 | 97 | */ |
| 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 { | |
| 97 | 104 | try { |
| 98 | 105 | ping(); |
| 99 | 106 | } catch (error) { |
| @@ -107,7 +114,10 @@ export function liveResponse(ping: () => unknown, log: Log, loopLagMs?: number): | ||
| 107 | 114 | if (databaseDown) log.info("forge alive again: the database answers"); |
| 108 | 115 | databaseDown = false; |
| 109 | 116 | 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 | ); | |
| 111 | 121 | return new Response("ok", { status: 200 }); |
| 112 | 122 | } |
| 113 | 123 |
src/http/server.ts +3 −2
| @@ -507,7 +507,7 @@ import { | ||
| 507 | 507 | takeDbTime, |
| 508 | 508 | type RouteTiming, |
| 509 | 509 | } 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"; | |
| 511 | 511 | import { during } from "../perf/jobs.ts"; |
| 512 | 512 | import { classOf, countryOf, repoOf, signatureOf } from "../perf/traffic.ts"; |
| 513 | 513 | import { createTrafficWriter } from "../perf/traffic_store.ts"; |
| @@ -519,7 +519,7 @@ import { statementPage } from "../web/pages/statement.ts"; | ||
| 519 | 519 | import { createViewWriter } from "../perf/views_store.ts"; |
| 520 | 520 | import { createReadWriter, readOf } from "../perf/reads.ts"; |
| 521 | 521 | 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"; | |
| 523 | 523 | import { methodOf, noteUsage, roleOf, surfaceOf, takeUsage } from "./usage_tag.ts"; |
| 524 | 524 | import { bearerChallenge, oauthRoutes } from "./oauth.ts"; |
| 525 | 525 | import { flushAccess, recordAccess } from "./access_log.ts"; |
| @@ -2829,6 +2829,7 @@ async function answerGet( | ||
| 2829 | 2829 | () => app.db.query("select 1").get(), |
| 2830 | 2830 | app.log, |
| 2831 | 2831 | wantsJson ? currentLoopLag() : undefined, |
| 2832 | wantsJson ? (stallBlame.worst(performance.now(), LAG_WINDOW_MS, 1)[0]?.what ?? null) : null, | |
| 2832 | 2833 | ); |
| 2833 | 2834 | noteDbTime(request, performance.now() - dbStart); |
| 2834 | 2835 | // 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"; | ||
| 108 | 108 | import { startPerfWatcher, TICK_MS } from "./perf/duty.ts"; |
| 109 | 109 | import { during } from "./perf/jobs.ts"; |
| 110 | 110 | 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"; | |
| 112 | 112 | import { timeStatements } from "./kernel/slow_sql.ts"; |
| 113 | 113 | import { waitForPeer } from "./runs/peer.ts"; |
| 114 | 114 | import { closeStaleSessions } from "./machines/shell.ts"; |
| @@ -241,7 +241,9 @@ const loopLog = createLog("loop"); | ||
| 241 | 241 | watchStalls(loopLog, undefined, undefined, undefined, (late, at) => { |
| 242 | 242 | bootStall.note(late, at); |
| 243 | 243 | 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); | |
| 245 | 247 | }); |
| 246 | 248 | |
| 247 | 249 | const previewKey = previewIdentityKey(process.env); |
src/metrics/stalls.ts +2 −2
| @@ -30,13 +30,13 @@ export function stallFamilies(tally: StallTally): Family[] { | ||
| 30 | 30 | { |
| 31 | 31 | type: "counter", |
| 32 | 32 | 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.", | |
| 34 | 34 | samples: tally.total.map((r) => ({ labels: { route: r.route }, value: Math.round(r.ms) })), |
| 35 | 35 | }, |
| 36 | 36 | { |
| 37 | 37 | type: "counter", |
| 38 | 38 | 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.", | |
| 40 | 40 | samples: tally.total.map((r) => ({ labels: { route: r.route }, value: r.count })), |
| 41 | 41 | }, |
| 42 | 42 | { |
src/perf/live.ts +3
| @@ -6,6 +6,7 @@ import { jobWatch } from "./jobs.ts"; | ||
| 6 | 6 | import { createLoopWatch } from "./loop.ts"; |
| 7 | 7 | import { createCaptureStore } from "./capture.ts"; |
| 8 | 8 | import { createReadLedger } from "./reads.ts"; |
| 9 | import { createStallBlame } from "./stall_blame.ts"; | |
| 9 | 10 | import { createPerfRecord } from "./record.ts"; |
| 10 | 11 | |
| 11 | 12 | export const perfRecord = createPerfRecord(); |
| @@ -17,3 +18,5 @@ export const bootStall = createBootStall(); | ||
| 17 | 18 | /** What was read from repositories (`reads.ts`), and the one bounded capture an admin may run (`capture.ts`). */ |
| 18 | 19 | export const readLedger = createReadLedger(); |
| 19 | 20 | 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 @@ | ||
| 16 | 16 | * stall nothing named ran across. |
| 17 | 17 | */ |
| 18 | 18 | |
| 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; | |
| 20 | 21 | const RECENT = 256; |
| 21 | 22 | /** The window `StallTally.hour` covers, in ms. */ |
| 22 | 23 | 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; | |
| 25 | 26 | /** The route a stall is counted against when no request ran across it. */ |
| 26 | 27 | export const NO_REQUEST = "none"; |
| 27 | 28 | /** 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 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * 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 | |
| 4 | 4 | * /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. | |
| 7 | 8 | */ |
| 8 | 9 | |
| 9 | 10 | 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"; | |
| 12 | 14 | import type { Ctx, Screen } from "../app/app.ts"; |
| 13 | 15 | import { binding } from "../app/keys.ts"; |
| 14 | 16 | import type { Session } from "../session.ts"; |
| @@ -19,14 +21,44 @@ import type { PaletteEntry } from "./entries.ts"; | ||
| 19 | 21 | |
| 20 | 22 | const mayRead = (session: Session): boolean => session.user?.isAdmin === true && session.user.kind !== "agent"; |
| 21 | 23 | |
| 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[] { | |
| 24 | 48 | if (!mayRead(session)) return []; |
| 25 | 49 | const col = (s: string) => pad(s, 10, "right"); |
| 26 | return [ | |
| 50 | const lines = [ | |
| 27 | 51 | `${pad("Route (last hour)", 40)}${col("Stall ms")}${col("Stalls")}`, |
| 28 | 52 | ...rows.map((r) => `${pad(r.route, 40)}${col(String(Math.round(r.ms)))}${col(String(r.count))}`), |
| 29 | 53 | ]; |
| 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 | ]; | |
| 30 | 62 | } |
| 31 | 63 | |
| 32 | 64 | export function stallsScreen(session: Session, ctx: Ctx): Screen { |
| @@ -37,7 +69,8 @@ export function stallsScreen(session: Session, ctx: Ctx): Screen { | ||
| 37 | 69 | render(area, s) { |
| 38 | 70 | const c = ctx.theme.c; |
| 39 | 71 | 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) }]); | |
| 41 | 74 | drawLines(s, area, c, scroll, spans); |
| 42 | 75 | hitScroll(ctx, area, scroll, lines.length); |
| 43 | 76 | }, |
src/web/pages/system.ts +27 −7
| @@ -30,8 +30,9 @@ import { live } from "../../system/live.ts"; | ||
| 30 | 30 | import { readerPaces, type ReaderPace } from "../../system/telemetry_reads.ts"; |
| 31 | 31 | import { slowPageLine, slowPages, type SlowPage } from "../../perf/watch.ts"; |
| 32 | 32 | 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"; | |
| 35 | 36 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; |
| 36 | 37 | import { notFoundPage, signInFirst } from "../chrome.ts"; |
| 37 | 38 | import { html, raw, type Html } from "../html.ts"; |
| @@ -42,7 +43,7 @@ import { KEPT_PAGE_BYTES, keptPageStats } from "./kept_page.ts"; | ||
| 42 | 43 | import { COLUMN_BYTES, PARTS_BYTES, keptPartsStats, treeCacheStats } from "./tree_cache.ts"; |
| 43 | 44 | import { featureHealth, type FeatureHealth } from "../../system/feature_health.ts"; |
| 44 | 45 | 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"; | |
| 46 | 47 | import { childDirOf, listChildren } from "../../actions/spawn.ts"; |
| 47 | 48 | import { childrenUse, realSelf, selfUse, shareOf, sizeWords, useWords, type ChildUseRow, type SelfUse } from "../../spawn/usage.ts"; |
| 48 | 49 | |
| @@ -197,15 +198,32 @@ export function featuresSection(rows: readonly FeatureHealth[]): Html { | ||
| 197 | 198 | } |
| 198 | 199 | |
| 199 | 200 | /** 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 { | |
| 201 | 202 | 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>${ | |
| 203 | 204 | rows.length === 0 |
| 204 | 205 | ? html`<p class="muted">No stalls in the last hour.</p>` |
| 205 | 206 | : html`<div class="table-scroll"><table class="table" aria-label="Main-thread stalls by route, last hour"> |
| 206 | 207 | <thead><tr><th scope="col">Route</th><th scope="col" class="num">Stall ms</th><th scope="col" class="num">Stalls</th></tr></thead> |
| 207 | 208 | <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>`; | |
| 209 | 227 | } |
| 210 | 228 | |
| 211 | 229 | const SLOW_TTL_MS = 5000; |
| @@ -320,6 +338,8 @@ export async function systemPage(ctx: Ctx): Promise<Response> { | ||
| 320 | 338 | const self = owner ? timePhase(ctx.request, "self", () => selfUse(realSelf(ctx.app.config.dataDir))) : null; |
| 321 | 339 | const children = owner ? childrenUse(await listChildren(ctx.app), (bodyId) => childDirOf(ctx.app, bodyId)) : []; |
| 322 | 340 | 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); | |
| 323 | 343 | const rendering = performance.now(); |
| 324 | 344 | const r = regions(v); |
| 325 | 345 | const at = (id: string): Html => raw(r[id]!); |
| @@ -350,7 +370,7 @@ export async function systemPage(ctx: Ctx): Promise<Response> { | ||
| 350 | 370 | <button class="btn" data-size="sm" type="button" data-sort="mem" aria-pressed="false"><kbd>m</kbd> memory</button></div> |
| 351 | 371 | <div id="sys-procs">${at("sys-procs")}</div></section> |
| 352 | 372 | ${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> | |
| 354 | 374 | <div id="sys-slow-pages">${slowPagesSection(timePhase(ctx.request, "slowpages", () => slowPagesCached(ctx.app.db)))}</div> |
| 355 | 375 | <div id="sys-readers">${readersSection(readerPaces())}</div> |
| 356 | 376 | <div id="sys-caches">${cachesSection(pageCaches())}</div> |
test/attention.test.ts +3
| @@ -22,8 +22,11 @@ import { proveAll } from "./helpers/verified.ts"; | ||
| 22 | 22 | import { useLive, type Live } from "../src/system/live.ts"; |
| 23 | 23 | import type { SystemView } from "../src/system/view.ts"; |
| 24 | 24 | import { quietLogs } from "./helpers/globals.ts"; |
| 25 | import { freshStallState } from "./helpers/stall-state.ts"; | |
| 25 | 26 | |
| 26 | 27 | quietLogs(); |
| 28 | // /system draws the process's stall watch and telemetry readers; start from a clean one. | |
| 29 | freshStallState(); | |
| 27 | 30 | |
| 28 | 31 | const BUDGET = "docs/design/attention-budget.json"; |
| 29 | 32 | 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"; | ||
| 11 | 11 | import { watchStalls } from "../src/kernel/loop_lag.ts"; |
| 12 | 12 | import { openMetrics } from "../src/metrics/openmetrics.ts"; |
| 13 | 13 | 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"; | |
| 15 | 16 | import { createLoopWatch, HOUR_MS, NO_REQUEST } from "../src/perf/loop.ts"; |
| 16 | 17 | import { createAgent } from "../src/actions/crew.ts"; |
| 17 | 18 | import type { User } from "../src/core/users.ts"; |
| @@ -20,8 +21,10 @@ import { createForgeApp, forgeCommands, Session } from "../src/tui/forge.ts"; | ||
| 20 | 21 | import { stallLines } from "../src/tui/screens/loopstalls.ts"; |
| 21 | 22 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; |
| 22 | 23 | import { quietLogs } from "./helpers/globals.ts"; |
| 24 | import { freshStallState } from "./helpers/stall-state.ts"; | |
| 23 | 25 | |
| 24 | 26 | quietLogs(); |
| 27 | freshStallState(); | |
| 25 | 28 | |
| 26 | 29 | test("a deliberate stall during a route is counted against it", async () => { |
| 27 | 30 | // The loop watcher on a clock the test moves: the route holds the thread |
| @@ -183,15 +186,49 @@ const doors = (user?: User): string[] => { | ||
| 183 | 186 | return ids; |
| 184 | 187 | }; |
| 185 | 188 | |
| 189 | const quiet = { rows: [], mono: 0, wall: 0 }; | |
| 190 | ||
| 186 | 191 | test("the Stalls screen gives an admin person the rows and the door; nobody else either", () => { |
| 187 | 192 | 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); | |
| 189 | 195 | expect(lines[0]).toContain("Stall ms"); |
| 190 | 196 | expect(lines[1]).toMatch(/^\/slow\/:id\s+813\s+2$/); |
| 191 | 197 | expect(lines[2]).toMatch(/^\/api\/v1\/telemetry\/flow\s+0\s+0$/); |
| 192 | 198 | expect(doors(alice)).toContain("stalls"); |
| 193 | 199 | 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([]); | |
| 195 | 201 | expect(doors(who)).not.toContain("stalls"); |
| 196 | 202 | } |
| 197 | 203 | }); |
| 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"; | ||
| 4 | 4 | import { createJobWatch, during } from "../src/perf/jobs.ts"; |
| 5 | 5 | import { loopWatch } from "../src/perf/live.ts"; |
| 6 | 6 | 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(); | |
| 7 | 11 | |
| 8 | 12 | const block = (ms: number): void => { |
| 9 | 13 | 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:<x>"); | |
| 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 | }); |
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.