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

Each event-loop stall over 200 ms is now recorded as `{ what, ms, at }`, where `what` is what was running. /system lists the last hour's five worst stalls, and `/healthz` asked for JSON reports `loopWorstWhat` next to `loopLagMs`.

shipped production is serving it deploy a985b29

opened this

Each event-loop stall over 200 ms is now recorded as { what, ms, at }, where what is what was running. /system lists the last hour's five worst stalls, and /healthz asked for JSON reports loopWorstWhat next to loopLagMs.

What changed

  • src/perf/stall_blame.ts (new) keeps the stalls in a fixed ring of 256. what comes from the existing loop watch (perf/loop.ts, #7559/#10834):

- the route pattern whose request overlapped the stall; - otherwise job:<name> for a job marked with jobWatch.during; - otherwise unknown.

  • Feeding it: the instance lives in perf/live.ts as stallBlame, and main.ts adds each stall right after loopWatch.stalled names it. Adding one is a few writes into fixed arrays.
  • Threshold: the stall limit (LIMIT_MS) drops from 250 to 200 ms. That changes the existing stall log lines and the /metrics stall counters too. Their help text, docs/METRICS.md and docs/features/stalls-by-route.md are updated to match.
  • /system has a new "Worst stalls, last hour" card showing what ran, ms, and a <time> for when.
  • /healthz with Accept: application/json adds loopWorstWhat: the cause of the worst stall in the last 10 s, the same window as loopLagMs, or null when there was none. Without that header the plain ok is unchanged.
  • AGENTS.md gets the in-memory-state note for the ring.

What I chose not to do

  • I didn't add the separate "what is running now" marker the issue sketched. Requests are already timed in server.ts (loopWatch.finished) and jobs already marked through jobWatch.during. A single "current" variable would usually hold the wrong name by the time the late timer fires, so matching the stalled time span against those records gives a better answer.
  • The request path is unchanged; nothing new runs per request. So /explore's app;dur can't move from this change. I didn't measure it on a preview.
  • One existing test was out of date, not wrong code: test/system-parts.test.ts lists /system's section headings, so I added the new card to it.

Tests test/perf-stall-blame.test.ts:

  • A real 260 ms busy-wait inside jobWatch.during("snapshot") is blamed on job:snapshot.
  • The same busy-wait in an unmarked timer callback is unknown.
  • 1,000 notes into a ring of 8 leave exactly 8, the newest ones.
  • The worst-five list is ordered and limited to the hour.
  • The card escapes its text, and /healthz's loopWorstWhat is null and then names the worst stall.

Checks run locally: bun scripts/gate.ts, bun x tsc --noEmit and bun scripts/design-lint.ts all pass. scripts/fast.ts named 2,017 test files, so instead I ran the 21 stall, loop, health, metrics and system test files; they pass. I couldn't call the keep_plan tool from this session, so the issue has no plan posted.

How to try it

  • Open /system as the forge's owner and read "Worst stalls, last hour".
  • Run curl -H 'accept: application/json' <preview>/healthz and read loopWorstWhat.

🤖 Generated with Claude Code

Closes #11069

commented

Each event-loop stall over 200 ms is now recorded as { what, ms, at }, where what is what was running. /system lists the last hour's five worst stalls, and /healthz asked for JSON reports loopWorstWhat next to loopLagMs.

What changed

  • src/perf/stall_blame.ts (new) keeps the stalls in a fixed ring of 256. what comes from the existing loop watch (perf/loop.ts, #7559/#10834):

- the route pattern whose request overlapped the stall; - otherwise job:<name> for a job marked with jobWatch.during; - otherwise unknown.

  • Feeding it: the instance lives in perf/live.ts as stallBlame, and main.ts adds each stall right after loopWatch.stalled names it. Adding one is a few writes into fixed arrays.
  • Threshold: the stall limit (LIMIT_MS) drops from 250 to 200 ms. That changes the existing stall log lines and the /metrics stall counters too. Their help text, docs/METRICS.md and docs/features/stalls-by-route.md are updated to match.
  • /system has a new "Worst stalls, last hour" card showing what ran, ms, and a <time> for when.
  • /healthz with Accept: application/json adds loopWorstWhat: the cause of the worst stall in the last 10 s, the same window as loopLagMs, or null when there was none. Without that header the plain ok is unchanged.
  • AGENTS.md gets the in-memory-state note for the ring.

What I chose not to do

  • I didn't add the separate "what is running now" marker the issue sketched. Requests are already timed in server.ts (loopWatch.finished) and jobs already marked through jobWatch.during. A single "current" variable would usually hold the wrong name by the time the late timer fires, so matching the stalled time span against those records gives a better answer.
  • The request path is unchanged; nothing new runs per request. So /explore's app;dur can't move from this change. I didn't measure it on a preview.
  • One existing test was out of date, not wrong code: test/system-parts.test.ts lists /system's section headings, so I added the new card to it.

Tests test/perf-stall-blame.test.ts:

  • A real 260 ms busy-wait inside jobWatch.during("snapshot") is blamed on job:snapshot.
  • The same busy-wait in an unmarked timer callback is unknown.
  • 1,000 notes into a ring of 8 leave exactly 8, the newest ones.
  • The worst-five list is ordered and limited to the hour.
  • The card escapes its text, and /healthz's loopWorstWhat is null and then names the worst stall.

Checks run locally: bun scripts/gate.ts, bun x tsc --noEmit and bun scripts/design-lint.ts all pass. scripts/fast.ts named 2,017 test files, so instead I ran the 21 stall, loop, health, metrics and system test files; they pass. I couldn't call the keep_plan tool from this session, so the issue has no plan posted.

How to try it

  • Open /system as the forge's owner and read "Worst stalls, last hour".
  • Run curl -H 'accept: application/json' <preview>/healthz and read loopWorstWhat.

🤖 Generated with Claude Code

commented

A loop stall over 200 ms is now named by what ran across it: a route pattern, job:<name>, or unknown. src/perf/stall_blame.ts keeps the stalls in a fixed ring of 256, and main.ts feeds it after loopWatch.stalled. The stall limit drops from 250 to 200 ms.

  • /system: the five worst single stalls of the last hour are a "Worst single stalls" table inside the existing "Main-thread stalls, last hour" section. It is drawn only when the hour had a stall, so a quiet /system looks as it did before, apart from the limit now reading 200 ms.
  • /healthz (JSON): loopWorstWhat names the worst stall of the last 10 s, or null when there was none.

What the failing check found:

  • First-view budget. The first version gave the worst stalls their own section. That put /system over its budget even with no stalls (249 words against 212, 68 blocks against 65). Moving the table inside the stalls section and hiding it when empty fixes this; /system measures 212/212 and 65/65 again.
  • Test order. /system's budget count also depended on which test files ran before it in the same process. The process-wide loop watch, the stall-blame ring and the telemetry-reader counts carried over between files, adding rows to the page. Running test/loop-stall-by-route.test.ts before the attention test fails on main too (215 > 212 words). test/attention.test.ts now clears all three before it renders. loop-stall-by-route, perf-loop-jobs and relay-stall-name clear the loop watch after their tests.
  • The /map and / timing test ("budgets > /map and / answer under their p95…", in test/snappy.test.ts) passes here. This pull adds no queries to those pages, so I read that failure as load on the shard. I changed nothing for it.

Tests changed, and why:

  • test/system-parts.test.ts drops the extra section heading it expected.
  • test/perf-stall-blame.test.ts now checks that the table draws nothing when there are no stalls and sits inside the stalls section when there are.

The gate, bun x tsc --noEmit and the design lint pass. I ran the stall, system and attention test files, 124 tests, all passing, and the attention set in both file orders. I didn't run the whole suite: scripts/fast.ts named 2017 of 2672 files, far too broad to run here.

To try it: open /system as the forge's owner after a stall and read "Worst single stalls"; curl -H 'accept: application/json' /healthz shows loopWorstWhat.

🤖 Generated with Claude Code

Changes requested — before the latest push

Mechanism is sound: it reuses loopWatch's route/job matching, the ring is fixed, no security finding (what is escaped, route patterns and job names are not secret). One change needed: the terminal does not show the new worst-single-stalls list (both doors rule).

To fix:

  1. Add the five worst single stalls (what, ms, when) to the terminal's Main-thread stalls screen (src/tui/screens/loopstalls.ts), same gate as /system, with a test.
  2. Drop the wall-clock assertion 1M notes < 1000 ms from test/perf-stall-blame.test.ts; timing assertions flake under shard load. Assert the ring size instead.
  3. The test-isolation edits (clearing loopWatch/stallBlame/telemetry reads in attention and three other files) paper over shared process state. Prefer a small helper in test/helpers/ that resets it, rather than ad-hoc afterAll in each file.
the crew's run was interrupted: restart, resumes on the next pass — cancelled
commented

Each loop stall over 200 ms is now recorded with what was running when it happened: the route pattern, job:<name> for a background job, or unknown. Each record also keeps the stall's length in ms and when it happened. src/perf/stall_blame.ts holds them in a fixed ring of 256 entries, filled from main.ts, and the stall threshold drops from 250 to 200 ms. Three places show them:

  • /system: a "Worst single stalls" table with the last hour's five worst, inside the stalls section and only when the hour had a stall.
  • /healthz: a new loopWorstWhat field names the worst stall of the same 10 s as loopLagMs, and is null when there was none.
  • Terminal: : → "Main-thread stalls" now shows the same worst five as /system, after the route rows, with what ran, ms, and the time in UTC. They are read from the same ring and use the same WORST_STALLS constant, which now lives in stall_blame.ts, so the two lists can't drift.

Review changes in this push:

  • Terminal list: the review asked for the worst-five list on the terminal screen. test/loop-stall-by-route.test.ts checks the rows and their UTC times against a fixed instant. It also checks that the screen's default list has the same five stalls in the same order as /system, and that anyone who can't open /system gets an empty screen.
  • Timing test removed: the review asked to drop the "1M notes in under a second" test. Wall-clock limits flake on a loaded machine. In its place, test/perf-stall-blame.test.ts feeds the ring an hour of solid stalling (18,000 stalls) and checks it still holds exactly 256.
  • Clean stall state in tests: test files share one process, so a stall noted by one file would show up as a row in the next file's /system. A shared helper, test/helpers/stall-state.ts (freshStallState()), clears the stall state before and after each file that uses it, and the four stall-related test files now call it instead of each clearing its own way.

Checks: bun scripts/gate.ts and bun x tsc --noEmit are clean. scripts/fast.ts wanted 2017 test files for this diff, which is over its budget, so I ran the 14 files covering /system, the stalls screen, the stall ring, stall naming and the palette instead: 103 pass, 0 fail.

To try it: as a forge admin, open : → "Main-thread stalls" in the terminal, or "Worst single stalls" on /system, after a slow request. curl -H 'accept: application/json' /healthz shows loopWorstWhat.

🤖 Generated with Claude Code

commented

Each loop stall over 200 ms is now recorded with what was running when it happened: the route pattern, job:<name> for a background job, or unknown. Each record also keeps the stall's length in ms and when it happened. src/perf/stall_blame.ts holds them in a fixed ring of 256 entries, filled from main.ts, and the stall threshold drops from 250 to 200 ms. Three places show them:

  • /system: a "Worst single stalls" table with the last hour's five worst, inside the stalls section and only when the hour had a stall.
  • /healthz: a new loopWorstWhat field names the worst stall of the same 10 s as loopLagMs, and is null when there was none.
  • Terminal: : → "Main-thread stalls" shows the same worst five as /system, after the route rows, with what ran, ms, and the time in UTC. Both read the same ring and the same WORST_STALLS constant, so the two lists can't drift.

Review changes:

  • Terminal list: the review asked for the worst-five list on the terminal screen. test/loop-stall-by-route.test.ts checks its rows and UTC times against a fixed instant, checks that its default list matches /system's five in the same order, and checks that anyone who can't open /system gets an empty screen.
  • Timing test removed: the review asked to drop the "1M notes in under a second" test, since wall-clock limits flake on a loaded machine. In its place, test/perf-stall-blame.test.ts feeds the ring an hour of solid stalling (18,000 stalls) and checks it still holds exactly 256.
  • Clean stall state in tests: test files share one process, so a stall noted by one file would show up as a row in the next file's /system. A shared helper, test/helpers/stall-state.ts (freshStallState()), clears the stall state before and after each file that uses it, and the four stall-related test files now call it.

Failed check: "a site-admin person is offered the whole roster" (test/palette-roster.test.ts) failed because I had changed the hint on the "Main-thread stalls" palette entry, and that test pins every hint as main has them. The new wording wasn't needed, so the hint is back to main's text and the test is unchanged.

Checks: bun scripts/gate.ts and bun x tsc --noEmit are clean. scripts/fast.ts wanted 2017 test files for this diff, which is over its budget, so I ran the files covering /system, the stalls screen, the stall ring and the palette instead. The roster test and the stalls-screen tests pass after the hint fix. The full suite runs in the forge's check.

To try it: as a forge admin, open : → "Main-thread stalls" in the terminal, or "Worst single stalls" on /system, after a slow request. curl -H 'accept: application/json' /healthz shows loopWorstWhat.

🤖 Generated with Claude Code

Approved

Both earlier asks are met: the terminal Stalls screen now lists the worst five (what, ms, UTC when) from the same ring and WORST_STALLS as /system, with tests for rows, parity and the gate; the wall-clock timing test is gone, replaced by an 18,000-stall ring-size test. The how-to is in docs/features/stalls-by-route.md, not README. Nothing new on the request path. Non-blocking: the 250 to 200 ms limit change also moves the existing log lines and counters; it is documented.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs