Compile servers
Bleep ships its own BSP server (bleep-bsp) that drives the Zinc
incremental compiler directly. Most of the time you don't think
about it, it starts when you run bleep compile, stays
warm between invocations, and goes away when you log out. Two
knobs you may need to turn: mode (when does it shut down?) and
memory (how much heap?).
Modes
Shared (default)
The compile server stays running between bleep invocations. The first compile pays JVM warmup + Zinc analysis load (~seconds). Every subsequent compile reuses the same JVM, so it's near-instant. This is what you want for interactive development.
If you previously switched modes and want to go back:
bleep server config mode shared
One-shot per invocation
The compile server shuts down after each command. Slower (every compile pays JVM warmup), but releases memory between commands. Useful on memory-constrained machines, or when you only run bleep occasionally and don't want a JVM resident in the background.
bleep server config mode per-invocation
Per-JVM servers
Projects can pin the JVM they're compiled with (jvm.name: at
build root). The compile server runs the Scala compiler on the JVM
it was started with, so if your build mixes projects on different
JVMs, bleep starts one shared compile server per JVM.
The dashboard
bleep server # same thing
bleep server top
A live view of every compile server on the machine, refreshed once a second. The list is on top, yours marked; below it a detail pane for whichever server is selected.
Tabs: Overview (heap with a gauge, live set, GC, threads, CPU, file descriptors, cache sizes), Workspaces (which are loaded and what each is running), Activity (what is compiling, what is queued behind it, who is connected), Log (that server's log, tailed), and Config (what it booted with, including the java options and the full classpath it was started with).
| key | |
|---|---|
↑ ↓ | select a server — or scroll, on the Log tab |
← → ⇥ | move between tabs |
k | stop the selected server |
r | restart it |
q | quit |
Everything is clickable. Rows select, tabs open, the action
buttons along the bottom fire, and a confirmation is answered by
clicking [yes] or [no]. The mouse wheel scrolls the log. Clicks
go through exactly the same path as the keys, so the two cannot
disagree.
k and r ask for confirmation first — a compile server may be in
the middle of somebody's build — and then run exactly the same code
as bleep server kill and bleep server restart.
The Log tab follows the newest line until you scroll back, and then holds your place while new lines keep arriving; scrolling back to the bottom resumes following. The title says which it is doing.
The dashboard connects as an observer, which means watching a
server never keeps it alive and never resets its idle clock. If a
server idles out while you are looking at it, the row turns dead,
which is the truth.
Where a terminal cannot support it — a pipe, a CI log — this falls back to the listing below and says why.
Seeing what is running
Because one server exists per bleep version and JVM configuration,
a machine that has been through a few upgrades or worktrees quietly
accumulates several — each holding a multi-gigabyte heap. bleep server ls shows all of them, and marks the one serving the build
you are standing in:
bleep server ls
● fd769f08a1374d38 running 1.0.0-M11 graalvm-community:25.0.1 ← this build
pid 36310 heap 106/12288MB up 52m 1 clients 1 workspaces
◇ 4bded7741810ee92 dead 1.0.0-M10 graalvm-community:24.0.1
pid 6028 412MB on disk
Nothing is filtered out. A server whose process is alive but has
stopped listening shows as wedged, a stopped one as dead, and
leftover logs and metrics with no server at all as litter — with
their disk usage, since those are what silently eat a few hundred
megabytes each.
Compile servers are found by scanning those socket directories, so a
server whose directory has been deleted while it kept running would
otherwise be invisible — still holding its heap, and unreachable.
Those are listed too, as orphan, with the pid to stop them by.
For one server in depth — heap and live set, GC, threads, open file descriptors, what it is compiling right now, what is queued behind that, who is connected, and the config it actually booted with:
bleep server status # the one serving this build
bleep server status <id> -o json # any of them, for scripts
<id> is a pid, a socket-directory hash, or any unambiguous prefix
of one. Every subcommand below takes the same, and every one of
them defaults to the server for the build you are in.
Reading a server's log
bleep server log # this build's server
bleep server log -f # follow
bleep server log --generation 1
The log is read from disk, so this works on a server that has crashed or been killed — which is when you want it most. Logs rotate only when a server starts, so a crashed server's last words are usually in generation 1, not the current one.
Stopping running servers
bleep server stop-all # every server; `kill --all` by another name
bleep server kill # just this build's
bleep server kill <id>
Stopping is graceful: bleep asks the server to shut down over the
protocol so it can release its lock and clean up its files, then
escalates to a signal, and only then to force. Pass --force to
skip straight to the end and delete the socket directory too.
Run this when you're done for the day, or when you need to reclaim memory before doing something else heavy.
To pick up a configuration change, bleep server restart stops the
server without starting a replacement — the next build starts one
that reads the config as it is on disk now. (It deliberately does
not replay the old server's command line, which would reinstate the
very -Xmx you were trying to change.)
Idle self-shutdown
A shared server that hasn't served a client for a while shuts itself down, so servers left behind by closed worktrees, JVM bumps, or upgraded bleep binaries don't pile up and hold memory indefinitely. The default is one hour.
bleep server config idle-timeout 120 # minutes
bleep server config idle-timeout 0 # never self-shut-down
bleep server config idle-timeout-clear # back to default 60
The clock measures fully-idle time only: it resets whenever a
client connects and stays reset for as long as any client is
connected. So a long compile, or an editor left open over lunch,
keeps the server alive — the timeout only fires once nothing has
been connected for the whole window. When it does fire, the server
releases its lock and removes its socket files, so the next bleep
command in that workspace just starts a fresh one.
This is different from the idle read timeout below: that drops a single stuck connection; this retires the whole server once it has been unused for long enough.
Memory
The compile server holds Zinc analysis, compiler state, and live class metadata in memory. The defaults work for most repos; large or generated-code-heavy builds may need more.
Compile-server heap
Start by looking. Every time bleep brings a server up it says what that server's heap ceiling is, and what the machine has:
Ensuring BSP server (mode=shared, heap=max 12g of 48g RAM, socket=…)
That one line answers "how much am I actually getting?" without any digging, which matters more than it sounds — see the floor below.
max, because -Xmx is a ceiling, not a reservation. Nothing is
committed up front; ZGC grows the heap as it needs it and hands memory
back to the OS when the server goes quiet. A server showing max 12g
is usually resident in a fraction of that.
bleep server config max-memory 4g
bleep server config max-memory 2048m
bleep server config max-memory-clear # back to bleep's default
The default, and its floor
The default is a quarter of physical RAM, clamped to between 4g and 16g:
min(16g, max(4g, RAM / 4))
The clamp is the part worth internalising. A quarter of RAM sounds proportional, but the 4g floor swallows the whole low end — every machine with 16 GB or less gets exactly 4g, however much or little it has:
| machine RAM | quarter | what you get |
|---|---|---|
| 8 GB | 2 g | 4 g — floor |
| 14 GB | 3.5 g | 4 g — floor |
| 16 GB | 4 g | 4 g — floor, only just |
| 32 GB | 8 g | 8 g |
| 48 GB | 12 g | 12 g |
| 64 GB+ | 16 g+ | 16 g — ceiling |
So on a 16 GB laptop and a 14 GB CI runner the server is sized
identically, and neither is "a quarter of RAM". If that is not what you
want, say so explicitly — that is exactly what max-memory is for.
Reach for it when you're hitting OutOfMemoryError during compile, or
when you have plenty of RAM and want to give the compiler room to
breathe. Raising it is not free, though: the server's footprint is
subtracted from the budget your test forks draw on, so a bigger server
means fewer forks running at once. Memory &
Parallelism does
that arithmetic properly, with a table of what fits.
When the server does die of OutOfMemoryError, it exits
immediately — no heap dump or core file is written. The client
that was connected (or the next bleep invocation) detects the OOM
from the server log and reports it together with the configured
cap and the command to raise it. Heap dumps left behind by older
bleep versions are deleted automatically at server start.
How much runs at once
Compiles, links, tests and sourcegen all draw from one budget:
parallelism — see
Memory & Parallelism.
There is deliberately no separate cap for compiles. One existed briefly and was removed: holding capacity back for test forks that might not exist is a static partition inside an otherwise work-conserving governor, so a compile-only run left half the machine idle. Instead the scheduler does two distinct things, and it is worth knowing they are distinct because both show up as a compile that didn't start immediately:
- it staggers a project's first admission attempt whenever something else is already compiling, so a wave of projects doesn't all allocate at once;
- it defers under heap pressure, watching the live heap rather than guessing from a count.
Both are recorded, each with its reason, so a slow build can tell you which one it was — see Seeing what the server is doing.
bleep server config parallelism 4
Cached builds
The server keeps resolved builds in memory so repeated invocations don't re-resolve from scratch, and drops the least recently used idle one when it needs room. The Zinc analyses read while compiling a workspace are held with it and dropped with it.
There is no knob: analyses are structurally shared between workspaces, so holding several costs far less than it looks, and the bound is a number nobody has a basis to tune. Evictions are logged if you want to see them.
Idle read timeout
bleep server config read-timeout 30
bleep server config read-timeout 0 # wait forever
bleep server config read-timeout-clear # back to default 30
How long the server waits for a client's next message before dropping the connection, so a client that dies without saying goodbye doesn't pin a server thread for the rest of the day.
This is a per-read idle timeout, not a session limit. A long compile keeps the socket quiet, but the clock resets the moment the client speaks again — so it can never interrupt a running build.
Test-runner heap
The test runner is a separate forked JVM. It does not share heap with
the compile server, and neither does anything else your build forks — your
code is compiled inside the server, but run inside a fork. A project that
needs more memory for its tests says so in bleep.yaml with
platform.jvmOptions; this setting is the default underneath that, for
projects that state no -Xmx of their own. It is a default, not a
ceiling — a project asking for -Xmx4g gets 4 GB even when this says
1g. See
Memory & Parallelism.
bleep server config test-runner-heap 2g
bleep server config test-runner-heap-clear
The old spelling, test-runner-max-memory, still works and writes the
same setting.
Reach for this when test suites OOM (Testcontainers spinning up big stacks, generated-fixture tests with large classpaths).
Seeing what the server is doing
The compile server is a long-lived JVM you never launched, which is a bad combination when a build is slower than you expect. Two different questions get two different tools:
- What is it doing now?
bleep server status— heap, GC, threads, what is compiling, what is queued behind it, who is connected. It asks the running server directly. - What did it do, and when? The rest of this section. The server keeps a running account of itself, always on — the overhead is one appended line per event.
The one-command answer
bleep server metrics # the most recent server
bleep server metrics <pid> # a specific one
This reads that server's metrics.jsonl, renders it as an HTML page and
opens it in your browser: heap and GC over time, a timeline of compiles,
connections, sourcegen, and any OOM events. Start here.
It does not yet chart everything the server records — the machine-level
counters (machine), per-compile allocation, analysis-cache sharing and
the admission decisions below are in the file but not on the page. For
those, read metrics.jsonl directly; it is one JSON object per line and
jq handles it comfortably.
The files
Everything a server writes lives in its own socket directory, one per server — and you don't have to hunt for it, because bleep prints the path every time it brings one up:
Ensuring BSP server (mode=shared, heap=max 12g of 48g RAM, socket=…/socket/105026fd4c1726ac)
BSP server ready (server log: …/socket/105026fd4c1726ac/output)
The parent differs per OS (~/.cache/bleep on Linux,
~/Library/Caches/build.bleep on macOS, %LOCALAPPDATA%\bleep\cache on
Windows), which is another reason to read it off the line rather than
reconstruct it. Inside each socket/<id>/:
| file | what it is |
|---|---|
metrics.jsonl | append-only event log, one JSON object per line |
output | the server's own stdout/stderr, rotated |
lock | how bleep elects a single spawner |
metrics.jsonl is flushed after every write, so it survives a crash —
which is the point, since the interesting failures are the ones that take
the server with them.
What the events mean
Each line has a type. The ones worth knowing:
| type | says |
|---|---|
compile_start / compile_end | a project compiled, and for how long |
compile_allocation | how much that compile allocated — the biggest lever on heap |
machine | every 15s: cores in use, queue depth, fork budget, RAM, heap cap |
jvm | every 15s: heap used, GC counts and cycle times, threads |
analysis_cache | how much Zinc analysis is held, and how well it's shared |
admission_defer | a compile the scheduler declined to start, and why |
oom_crash / oom_pressure | the server ran out of heap, or came close |
admission_defer carries a reason, and the distinction matters when
you're reading a slow build:
stagger— the scheduler deliberately spread compiles out. Normal, and nothing to do with memory. It happens on a project's first admission attempt whenever anything else is compiling, at 5% heap as readily as at 79%.heap_pressure— the live heap was actually above the threshold and the compile waited for room. This is the one that means "consider more heap".
It also carries delay_ms, so you can add up what the deferrals actually
cost rather than counting them.
Check
reasonbefore reaching formax-memory. A build can be entirelystaggerand have never gone near its heap ceiling — in which case more heap buys nothing.
In CI — where this pays off most
A slow build on your own machine you can poke at. A slow build on a CI
runner is gone the moment the job ends, and CI is exactly where the
interesting problems live: four cores instead of sixteen, a cold cache,
and a memory ceiling you didn't choose. The same metrics.jsonl is
sitting there when the job fails — it just needs collecting before the
runner is destroyed.
- name: Collect compile-server telemetry
if: always() # the runs worth reading are the ones that failed
shell: bash
run: |
shopt -s nullglob
mkdir -p bsp-diagnostics
# The cache directory differs per OS, so cover all three rather than
# assuming the runner you happen to be on.
for d in ~/.cache/bleep/socket/*/ \
~/Library/Caches/build.bleep/socket/*/ \
"$(command -v cygpath >/dev/null && cygpath "$LOCALAPPDATA" || echo /nonexistent)"/bleep/cache/socket/*/; do
name=$(basename "$d")
mkdir -p "bsp-diagnostics/$name"
for f in "$d"metrics.jsonl "$d"output; do
[ -f "$f" ] && cp "$f" "bsp-diagnostics/$name/"
done
done
- uses: actions/upload-artifact@v7
if: always()
with:
name: bsp-diagnostics-${{ matrix.os }}
path: bsp-diagnostics
Two details that are easy to get wrong, and silent when you do:
if: always(). A job that exceeds itstimeout-minutesis torn down with its remaining steps,always()included — so if you are chasing a hang, put atimeout-minuteson the step that hangs, below the job's. Then the step fails, the job continues, and you get the telemetry that explains it.- The per-OS paths. A Linux-only glob silently collects nothing on Windows and macOS, which looks identical to "there was nothing to collect".
Download the artifact and read it with anything that speaks JSON:
# the slowest compiles
jq -r 'select(.type=="compile_end") | "\(.duration_ms) \(.project)"' metrics.jsonl | sort -rn | head
# was the machine ever full, or was work queued behind idle cores?
jq -r 'select(.type=="machine") | "\(.used_cpu)/\(.total_cpu) running=\(.running) queued=\(.waiting)"' metrics.jsonl
# what actually allocates
jq -r 'select(.type=="compile_allocation") | "\(.allocated_mb) \(.project)"' metrics.jsonl | sort -rn | head
bleep's own workflow does exactly this on every architecture and prints a
summary in the job log;
.github/scripts/collect-bsp-diagnostics.sh and summarise-bsp-metrics.py
in this repository are about forty lines between them and are not
bleep-specific.
What this does not cover: test timings
metrics.jsonl is about the compile server — compiles, sourcegen, heap,
scheduling. It records nothing about individual tests or suites. If
you are chasing a slow test run rather than a slow build, that data
comes from a different file:
bleep test --junit-report target/test-reports
Standard JUnit XML, with time= on every <testsuite> and every
<testcase> — so per-test durations, not just per-suite. Every CI system
has a viewer for it, and it uploads as an artifact the same way.
The two are complementary: the JUnit XML tells you which suite is slow,
metrics.jsonl tells you whether the machine was starved of memory or
cores while it ran.
Where this lives on disk
All of these commands write to a config.yaml whose location depends on
your OS. Ask bleep where it is rather than guessing:
bleep config file
The file is yours to read and edit by hand if you'd rather:
compileServerMode:
mode: shared # or: new-each-invocation
bspServerConfig:
compileServerMaxMemory: 4g
parallelism: 4
testRunnerHeap: 2g
bspReadTimeoutMinutes: 30
Note the key is bspServerConfig; that compileServerMode sits at the
top level rather than inside it; and that its value is an object with a
mode field, not a bare string. Unknown keys are ignored silently, so a
misspelt one looks like it worked and changes nothing — if a
hand-edit seems to have no effect, check the spelling first.
See also
bleep config: the full set of user-config subcommands (auth, remote-cache, log timing, …) auto-generated from the CLI.