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 config compile-server auto-shutdown-disable

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 config compile-server auto-shutdown-enable

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.

Stopping running servers

bleep config compile-server stop-all

Stops every shared compile server bleep has started for the current user. Run this when you're done for the day, or when you need to reclaim memory before doing something else heavy.

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 config compile-server idle-timeout 120 # minutes
bleep config compile-server idle-timeout 0 # never self-shut-down
bleep config compile-server 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 config compile-server max-memory 4g
bleep config compile-server max-memory 2048m
bleep config compile-server 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 config compile-server 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 config compile-server read-timeout 30
bleep config compile-server read-timeout 0 # wait forever
bleep config compile-server 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 build-wide default underneath that. See Memory & Parallelism.

bleep config test-runner max-memory 2g
bleep config test-runner max-memory-clear

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 and can't see, which is a bad combination when a build is slower than you expect. So it keeps a running account of itself, always on — the overhead is one appended line per event — and there are three places to look.

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
testRunnerMaxMemory: 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.