Troubleshooting.

Node flapping, model_unavailable, cold-load timing, and the Windows tray quirks — with the exact diagnostics relay doctor already runs for you.

Relay is alpha and unaccredited
Releases are pre-release versions on the 0.1.0-alpha.N → -beta.N → -rc.N → 0.1.0 ladder. The wire protocol between hub and node, the on-disk state layout and the CLI surface can all change without a deprecation period; /v1 tracks the OpenAI shape and is the most stable thing here. Nothing is accredited or covered by a compatibility promise.

Run the doctor first

bash
relay doctor [POOL_URL]

Every check exists because its failure is otherwise silent: a missing rpc-server makes relay up exit before anything appears anywhere, an undetected GPU makes a node lend nothing, and a stale Windows icon cache hides an icon that is genuinely present in the binary. None of those announce themselves.

The checks run in this order, each printing [ ok ], [FAIL] or [ -- ] (skipped) with a one-line detail. The last three need a pool URL; omit it and all three skip.

CheckWhat it reports
iconWindows only — whether this build carries an icon resource at all, which separates "the binary lacks one" from "Windows is not drawing the one it has".
rpc-serverWhere the rpc-server binary resolved, or that it was not found. relay up cannot start without it and exits immediately.
llama-shadowA different rpc-server/llama-server is also on PATH. The bundled/beside-executable one wins by design; this makes that precedence visible.
gpuThe detected GPU and its VRAM, naming the detection source — or that no VRAM was detected, in which case this machine would lend nothing.
llama-backendWhich offload devices the resolved llama-server reports via --list-devices. CPU only means either no GPU backend is compiled into that build, or none of the ones that are found a usable device on this machine.
sandboxWhether an OS sandbox mechanism is actually available for rpc-server/llama-server on this machine. Skipped outright when the mode is off.
llama-build:…Each binary’s llama.cpp build number against the one relay validates its placement math and flags against. A binary that does not report --version is skipped, not failed.
llama-sha256:…Each binary’s SHA-256 against an embedded known-good release table and your own pin file from relay doctor --pin-llama.
trayWhether a notification area is available on this build and session — nothing more.
tray iconWindows only, and a separate line under its own check name (tray icon, not tray): whether Windows has a record of this icon at all, and whether it is promoted to the taskbar or hidden behind the overflow arrow. Runs only after tray reported supported.
joinedWhether a node credential exists on this machine, and which hub and node id it names.
secretsWhether that credential is sealed with the platform's secret store or still legacy plaintext awaiting its next re-seal.
tls-certWhether this node has migrated onto the mutual-TLS transport, and whether its issued client certificate is still inside its validity window.
hubA GET /healthz against the URL you passed. Skipped with no URL.
tls-handshakeThe mutual-TLS handshake against the node listener, with the CA fingerprint pinned. Skipped (as tls) with no URL.
v1-tlsWhether /v1 answers over TLS and presents the certificate the pinned fingerprint expects. Skipped with no URL.

Logs

Everything goes to stderr through slog. There is no log file — in the foreground it is on your terminal; as a service, journalctl is the log.

bash
journalctl --user -u relay -n 200 -f          # hub, as a service
journalctl --user -u relay-node -n 200 -f     # node

Prompt and completion text is never logged, so output is safe to share — check it for hostnames and model names you would rather not, first.

model_unavailable (503)

This is intended behavior, not a bug: no silent fallback to CPU, no hang — a 503 naming the exact shortfall.

json
{
  "error": {
    "message": "\"qwen3-14b\" needs about 12800 MB VRAM: the host has 6144 MB free, the best node has 0 MB free, and the pool aggregate (6144 MB) is not enough to shard it either — free VRAM, add a node, or pick a smaller/more-quantized model",
    "type": "model_unavailable",
    "code": "model_unavailable"
  }
}

Read the numbers before you change anything. the best node is scoped to nodes that are eligible to run a whole replica, so a shard-only node’s free VRAM is deliberately not named there — it would suggest a placement this pool will never make. the pool aggregate does include shard-only nodes, because sharding is exactly what their trust level allows. Either line may carry the suffix ; N MB are held by busy or pinned residents (…), which is the daemon telling you why eviction already tried and could not clear the room.

Check which models lost their only viable placement with relay status --json | jq '.replicas' — a model that still fits without the departed node (e.g. it only ever ran on the host) is unaffected. Rejoining is relay up with no flags on the node; relay status shows it online again within one RELAY_HEARTBEAT (default 15s), and the next request to an affected model re-places it automatically — nothing to restart on the hub.

The same status for a different reason: a host-only model

A model marked host-only never leaves the hub — not as a node replica, not as a shard — so placement never looks past the host at all. It fails with the same 503 and the same model_unavailable code, but a different body and a different fix: free VRAM on the host, or lift the restriction. A node’s free VRAM is memory this model can never use, which is why the message does not mention any.

json
{
  "error": {
    "message": "\"qwen3-14b\" is host-only (a privacy setting — it never leaves this hub, not even as a shard): needs about 12800 MB VRAM, the host has 6144 MB free — free VRAM on the host or pick a smaller/more-quantized model",
    "type": "model_unavailable",
    "code": "model_unavailable"
  }
}

Confirm which it is with relay status --json | jq '.models' — the flag rides every model row as host_only. Clear it deliberately with relay model set NAME --host-only=false.

A third body, before anything is unloaded
A model larger than the whole fabric is refused up front with "NAME" needs about N MB and the whole pool tops out at M MB. The guard exists so that asking for an oversized model never evicts the models that were working and then fails anyway.

A node will not join

Join tokens are single-use. A second relay up with the same token fails by design — mint another with relay token on the hub rather than retrying the one that already worked.

Use the URL relay token prints, https:// included. On a shared network a hub with RELAY_REQUIRE_MTLS set only accepts nodes that have completed the TLS migration — for an existing node, run relay up --upgrade-tls on a trusted network first. If TLS is the failure, relay doctor https://hub:8383 says so specifically rather than reporting a generic connection error.

Node flapping between online / offline / paused

paused is not the same problem as offline: paused means the owner chose Pause lending in the tray, or enabled --auto-pause and the GPU looked busy — the tunnel is still up. The planner treats a paused node as offline for placement, but it is expected to come back the moment the owner resumes.

auto-pause is the usual cause of real flapping
On a desktop, a live CUDA context is a poor proxy for "the owner needs the card"; this is exactly why --auto-pause ships off by default. If a node bounces between paused and lending with no one touching it, turn --auto-pause off and pause manually instead.

A node whose tunnel stays down for 60 s stops its own replica llama-server, so a replica that vanished after a longer outage is expected: while the tunnel is down the hub cannot reach the node to tell it anything, so the node has to notice on its own that nobody can be routing to it. The next request re-places it once the node is back.

A model almost fits a node

The fit-check reserves KV cache for the full served context across every slot. When the 503 body's "needs about N MB" is only a few hundred MB above a node's free VRAM, set RELAY_KV_CACHE_TYPE=q8_0 (or q4_0) on the hub rather than lowering RELAY_CONTEXT_SIZE; see Concepts. If llama-server exits immediately after that change, check for RELAY_FLASH_ATTN=off: a quantized V cache needs flash attention, and the daemon only warns about the combination rather than refusing to start.

Common doctor findings

SymptomCause
no local offload device at startupllama.cpp built without a GPU backend.
Node LENDING —nvidia-smi not on the node’s PATH, so no VRAM was detected. Fix PATH or the driver; where detection cannot be fixed, RELAY_VRAM_TOTAL_MB and RELAY_VRAM_FREE_MB override the probe on that node.
rpc-server not foundPut it beside relay, or set RELAY_RPC_SERVER_BIN. Ships in llama.cpp’s prebuilt archives as ggml-rpc-server.
Node stuck offlineHub not reachable on its listen address from the node.
sandbox: no sandbox mechanism availableUnder mode auto the helper processes still start, unsandboxed, with a warning in the log; under require they refuse to start. On Linux, install bubblewrap.
llama-sha256: CHANGED since you pinned itThe binary on disk is not the one you recorded. Reinstall from an official release, or re-pin deliberately with relay doctor --pin-llama.
llama-sha256: unpinnedSkipped, not failed: it matched neither the embedded known-good table nor your pin file. Pin it with relay doctor --pin-llama after installing from an official release.
relay status shows nothing under serving despite a running replicaExpected — plain-text status only prints the shard/host-supervised model; use relay status --json.
needs N MB VRAM ... not enough to shard it either (503)The planner is right: nothing in the pool — not the host, not any replica-eligible node, not the aggregate a shard could draw on — has room right now.
is host-only (a privacy setting ...) (503)A different cause with a different fix: the model is marked host-only, so placement never looks past the hub, however much a node has free. Free VRAM on the host, or relay model set NAME --host-only=false.
Very slow first request for a modelExpected — cold load streams the GGUF to whichever device is about to run it.

Size caps warn — they never delete

RELAY_CACHE_MAX_GB (a node’s local file cache) and RELAY_MODELS_MAX_GB (the hub’s models directory) both default to 0, meaning no cap. Crossing either one never removes a file. A node lends disk on hardware its operator does not own, so eviction is not relay’s decision to make silently: the cap makes the situation visible and the explicit, human-invoked action stays the thing that actually reclaims space — relay cache prune on a node, relay model rm on the hub.

So a disk that keeps filling up despite a cap is working as designed. Usage against cap is reported rather than enforced: relay status carries models_used_mb/models_max_mb for the hub and cache_used_mb/cache_max_mb per node. Both values are whole gigabytes — there is no way to express 500 MB.

One thing here does evict, and it is not these
The node cache carries its own physical-disk safety ceiling, derived from free space. That mechanism is older, separate, and untouched by these two variables — do not read an eviction it performed as one of these caps firing.

Cold-load timing over the tunnel

A cold shard load streams gigabytes of layers to remote machines before the server can answer /health — a 14B over a home link can take several minutes, which is why RELAY_LOAD_TIMEOUT defaults to 15 minutes rather than something tighter: a short bound turns a slow-but-working load into a spurious failure.

Reported network share right after a cold load is an overestimate
The tunnel's RTT sample rides the same yamux session as the multi-gigabyte tensor push, so head-of-line blocking inflates it (24–38 ms observed while tailscale ping showed 6 ms). relay bench attributes network cost from that sample. Measure link quality with a plain ping/tailscale ping instead, taken when no transfer is in flight.

Windows icon cache and tray overflow

A build with a genuine icon resource can still show a blank icon in Explorer or Task Manager if the Windows icon cache is holding a previous build — the cache is keyed on path, so a rename also clears it.

bash
ie4uinit.exe -show

Separately, Windows 11 hides every newly-seen notification-area icon behind the overflow arrow by default and records that as IsPromoted=0 — the app registered successfully and reports no error, and is still invisible.

bash
reg add "HKCU\Control Panel\NotifyIconSettings\<key>" /v IsPromoted /t REG_DWORD /d 1 /f
taskkill /IM explorer.exe /F

Or pin it by hand: Settings → Personalization → Taskbar → Other system tray icons.

GPU busy / no VRAM detected

relay doctor's GPU check probes the same way the daemon does at startup; a no VRAM detected result names the detection source it tried (e.g. nvidia-smi) so you know whether to fix PATH or the driver.

When detection cannot be fixed — an unusual driver, a container without the probe tool — a node reads RELAY_VRAM_TOTAL_MB and RELAY_VRAM_FREE_MB and uses them in place of what it probed. Both are read on the node, not the hub, and an unparseable value is ignored rather than treated as zero. They are a last resort: you are telling the pool how much VRAM this machine has, and the planner will believe you.