Skip to main content

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

Reference →

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

Reference →

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
kstop the selected server
rrestart it
qquit

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.

Reference →

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.)

Reference →

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.

Reference →

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 RAMquarterwhat you get
8 GB2 g4 g — floor
14 GB3.5 g4 g — floor
16 GB4 g4 g — floor, only just
32 GB8 g8 g
48 GB12 g12 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.

Reference →

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.

Reference →

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).

Reference →

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>/:

filewhat it is
metrics.jsonlappend-only event log, one JSON object per line
outputthe server's own stdout/stderr, rotated
lockhow 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:

typesays
compile_start / compile_enda project compiled, and for how long
compile_allocationhow much that compile allocated — the biggest lever on heap
machineevery 15s: cores in use, queue depth, fork budget, RAM, heap cap
jvmevery 15s: heap used, GC counts and cycle times, threads
analysis_cachehow much Zinc analysis is held, and how well it's shared
admission_defera compile the scheduler declined to start, and why
oom_crash / oom_pressurethe 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 reason before reaching for max-memory. A build can be entirely stagger and 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 its timeout-minutes is torn down with its remaining steps, always() included — so if you are chasing a hang, put a timeout-minutes on 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.