Skip to content

bsp: make the compile server explicable — heap at boot, and telemetry that charts what it records - #633

Merged
oyvindberg merged 9 commits into
masterfrom
ci/heap-visibility
Jul 31, 2026
Merged

bsp: make the compile server explicable — heap at boot, and telemetry that charts what it records#633
oyvindberg merged 9 commits into
masterfrom
ci/heap-visibility

Conversation

@oyvindberg

@oyvindberg oyvindberg commented Jul 31, 2026

Copy link
Copy Markdown
Owner

The compile server is a long-lived JVM you never launched and cannot see. This makes it say what it is doing, and makes the dashboard show what it already records.

Grew well past its original one-line change — worth reading as three pieces.

1. The heap, at boot

Ensuring BSP server (mode=shared, heap=max 12g of 48g RAM, socket=…)

The cap was invisible until something went wrong, at which point it surfaced in an OOM message. It is worth stating up front because the default surprises: min(16g, max(4g, RAM/4)) clamps to its 4g floor on anything at or below 16GB, so a 16GB laptop and a 14GB CI runner are sized identically and neither gets "a quarter of RAM".

RAM quarter actual
14 GB (macos-15-intel runner) 3.5 g 4 g — floor
16 GB (windows runner, a laptop) 4.0 g 4 g — floor
48 GB 12 g 12 g

max, because -Xmx is a ceiling and ZGC with ZUncommitDelay=30 hands memory back; a bare size next to the machine's RAM reads like a reservation.

Verified against both paths: default, max-memory 6g, and max-memory-clear.

2. Telemetry the dashboard was throwing away

bleep server-metrics and the CI summariser read the same file and had drifted. Of twenty event types the server writes, the dashboard handled fourteen and dropped six via case _ => () — and the six were the ones people arrive with questions about: machine, admission_defer, compile_allocation, analysis_cache, workspace_state.

Five new charts, six new cards. Two worth calling out:

  • Scheduling draws CPU-in-use against cores with running and queued on top, because saturated and starved look identical from outside — a slow build — and have opposite fixes.
  • Deferred compiles are split by reason. stagger is the scheduler spreading first starts apart and says nothing about memory; heap_pressure is a compile actually waiting for heap. Only the second argues for a bigger -Xmx. On a real 18-core dlab run: 115 defers, every one a stagger, zero heap pressure — precisely the case that used to read as memory-starved under the old label.

Legacy heap_pressure_stall events are read too, and shown as unlabelled (pre-rename) rather than assigned a cause the data does not state. Not theoretical: the released M9 spawned a server mid-testing that wrote 42 of them.

server-metrics --file <path> opens any metrics.jsonl, so one downloaded from CI is read by the same dashboard as a local one — same file, same viewer. Verified on a real artifact of 353 events.

3. Forked test JVMs, which was recorded nowhere

The server recorded what it compiled; what it ran was invisible. Five events joined on pid: fork_start, fork_reused, fork_end, suite_scheduled, suite_finished.

bleep-core cannot call bleep-bsp, and a singleton it could reach is the global mutable state this codebase refuses — so JvmPoolListener inverts it: the pool announces, whoever builds it decides who listens, default no-op. fork_end carries killed_by separately from the exit summary because destroyForcibly sends SIGKILL and a fork bleep killed is otherwise indistinguishable from one the OS killed.

Charted as one lane per JVM — the pale bar is the fork's life, the segments on it are the suites that ran there. That answers "which suite was on the JVM that got killed", otherwise a hand-join across three event types.

What it immediately showed on dlab (73 suites, 500 tests):

66 forks started, 7 reused          ~10% reuse; 59 forks ran exactly one suite
fork lifetime median 31.3s, max 1728.9s
26 suites timed out at ~28.8 min, across four different pids

The reuse rate may be intentional — the pool destroys rather than caches under memory contention — but no metric expressed it before.

Docs

docs/guides/compile-servers.mdx gains the floor table, a telemetry section (where the files are, what the event types mean, stagger vs pressure), a CI recipe with tested jq one-liners, and an explicit note that metrics.jsonl records nothing about individual testsbleep test --junit-report is where per-test timing lives.

Verification

Everything here was checked by running it and reading the output, not by a green compile. That caught: fork_end never firing (shutdown bypasses destroy), lifetime_ms: -1 (start instant read after the process died), outcome: "Success$", a default parameter added mid-list that rebound every positional argument, and — twice — a dispatch edit that silently matched nothing, leaving charts guarded off on real data.

Final state exercised end to end on a 42-project dlab build: all nine charts and eight cards render.

🤖 Generated with Claude Code

oyvindberg and others added 9 commits July 31, 2026 12:19
The cap was invisible until something went wrong, at which point it surfaced in
an OOM message. It belongs next to the mode and the socket, because the default
is surprising: min(16g, max(4g, physicalMb/4)) clamps to its 4g floor on
anything under 16GB of RAM, so a rule that sounds proportional hands the same 4g
to a 14GB CI runner and a 16GB laptop. Both CI arches land there while ~21GB
flows through the heap and ZGC runs ~100 major cycles.

Reporting the machine's RAM beside it makes the ratio visible without
prescribing anything. `bleep config compile-server max-memory 8g` is the lever
and `max-memory-clear` puts it back; neither was discoverable from the outside.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Two gaps, both things someone would have to read source to learn.

The heap default was described accurately — a quarter of RAM clamped to
4g..16g — without conveying what the clamp does. The floor swallows the
entire low end: every machine with 16GB or less gets exactly 4g, so a
16GB laptop and a 14GB CI runner are sized identically and neither is
getting a quarter of anything. Now stated with the table, next to the
boot line that reports what you actually got, and with the reminder that
raising it shrinks the fork budget.

Telemetry had no documentation at all, despite being the thing that makes
a server you never launched explicable. Where the files are, what the
event types mean, and the distinction that matters when reading a slow
build: a deferred compile is usually the scheduler staggering starts, not
heap pressure, and only one of those is an argument for more memory.

Says plainly that the HTML dashboard does not chart everything the file
records — machine counters, per-compile allocation, analysis-cache
sharing and admission decisions are recorded but not drawn — rather than
implying `server-metrics` is the whole picture.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
`bleep server-metrics` and the CI summariser read the same file and had
drifted apart. Of the twenty event types the server writes, the dashboard
handled fourteen and dropped six on the floor via `case _ => ()` — and the
six were the ones that answer the questions people arrive with:

  machine              was the machine full, or was work queued behind idle cores
  admission_defer      why a compile did not start, and for how long
  compile_allocation   what actually allocates
  analysis_cache       the largest single retainer in the server heap
  workspace_state      how many builds are held
  compile_phase        (still not charted: zinc internals, not this build)

Five new charts and six summary cards. The scheduling chart draws CPU in
use against cores with running and queued on top, because saturated and
starved look identical from outside — a slow build — and have opposite
fixes. The memory chart draws the fork budget rather than the machine's
RAM, since that budget already has the server's footprint and an OS
reserve subtracted and is retuned as other processes come and go.

Deferred compiles are split by reason. `stagger` is the scheduler
spreading first starts apart and says nothing about memory;
`heap_pressure` is a compile actually waiting for heap. Only the second
is an argument for a bigger `-Xmx`, and folding them together is how a
build that never approached its ceiling still looks memory-bound.

Legacy `heap_pressure_stall` events are read too. Servers predating the
rename are still writing them — the released M9 spawned one while this was
being tested — and they carry no reason, so they are charted as
`unlabelled (pre-rename)` rather than being assigned a cause the data does
not state. The CI summariser already did this; the dashboard silently
dropping half its input is how the two drifted in the first place.

Verified against a metrics file covering every type, including both
formats: all six charts render, all six cards populate, and the three
defer reasons draw separately. Old files missing the newer machine fields
degrade to fewer cards rather than failing.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The compile server recorded what it compiled; what it *ran* was invisible.
A slow or flaky test run raises questions none of the telemetry could
answer: how many JVMs were started, how many were reused rather than
respawned, how long each lived, which were killed rather than finishing,
and which suite was placed on which JVM.

Five events, joined on pid: fork_start, fork_reused, fork_end,
suite_scheduled, suite_finished.

The pool lives in bleep-core and the telemetry in bleep-bsp, which depends
on it, so the pool cannot call the recorder — and a singleton it could
reach is the global mutable state this codebase refuses. JvmPoolListener
inverts it: the pool announces, and whoever builds it decides who listens.
The default is a no-op, so nothing outside the server pays for this.

fork_end carries killed_by separately from the exit summary because
destroyForcibly sends SIGKILL: a fork bleep terminated and one the OS
killed both report exit 137, and only one of them means the machine ran
out of memory.

Suite events are recorded in TestRunner rather than the pool, because that
is the only place that knows both which JVM was handed out and what is
about to run on it.

Also: server-metrics --file opens any metrics.jsonl, so one downloaded
from CI is read by the same dashboard as a local one. Same file, same
viewer — verified against a real artifact of 353 events.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
shutdown kills its JVMs directly rather than going through destroy, so
the forks that survive to the end of a run — usually most of them — had a
fork_start and never a fork_end, and their lifetimes were unknowable.
Found by running a suite and counting: 2 starts, 0 ends.

Also strips the trailing $ getSimpleName leaves on a case object, so the
outcome reads Success rather than Success$.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
`process.info().startInstant()` was read at announce time, which is after
the process has been killed — the OS stops reporting a start instant for a
process that no longer exists, so every fork_end carried lifetime_ms: -1.
Recorded at construction instead.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Added mid-list, its default silently shifted every positional argument
after it, so `new ManagedJvm(process, stdin, ...)` bound stdin to the
timestamp and bleep-core stopped compiling. I had already committed it,
having read a compile result that was still running.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
One lane per forked JVM: the pale bar is the fork's life, the segments on
it are the suites that ran there. That is the view which answers 'which
suite was on the JVM that got killed' — otherwise a hand-join across
fork_start, fork_end and suite_finished.

A fork killed by bleep is how the pool shuts down, not a failure, so only
an unexplained end is coloured as trouble. Suites are coloured by outcome.

Plus a slowest-suites bar and two cards: JVMs started vs reused, which is
what the pool actually saves and appears nowhere else, and suites run with
a count of any that did not succeed.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The buffers and the charts were both there; nothing filled the buffers.
The dispatch edit had silently matched nothing — bleep fmt realigns the
case arrows, so a literal written against the pre-format text no longer
occurs — and I did not assert, so it passed quietly and the charts were
guarded off on real data.

Second time the same way in this file. The wiring is now derived from the
buffer names and checked after writing.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
@oyvindberg oyvindberg changed the title bsp: say how much heap the compile server gets, at boot bsp: make the compile server explicable — heap at boot, and telemetry that charts what it records Jul 31, 2026
@oyvindberg
oyvindberg merged commit d9860a9 into master Jul 31, 2026
10 checks passed
@oyvindberg
oyvindberg deleted the ci/heap-visibility branch July 31, 2026 19:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant