A running Krill server logged a tight loop of errors while a Compose app (or any peer) was broadcasting its discovery beacon:
1
2
3
4
5
ERROR [ServerBeaconWireHandler] Error handling beacon from NodeWire(... host=box, port=0, platform=DESKTOP, ...)
java.lang.Exception: Node not found: d51ed48d-1e5a-406c-a115-3da8e17b802a
at krill.zone.server.ServerNodeManager.readNodeState(...)
at krill.zone.server.io.beacon.ServerBeaconSender.sendSignal(...)
at krill.zone.server.io.beacon.ServerBeaconWireHandler$handleIncomingWire$1.invokeSuspend(...)
The id in the error (d51ed48d…) is the server’s own install id — not the
beacon sender’s (8e561032…). The server never replied with its own beacon, so
peers could not discover it; and because the reply ran before the
server-to-server connect, that connect never executed either.
ServerBeaconSender.sendSignal() resolved the local identity with
ServerIdentity.getSelfWithInfo() (which falls back to a freshly built,
port-resolved default when the self-node is not persisted) but then also
re-read its own node out of the database via
nodeManager.readNodeState(installId()) just to pull the port back off it.
readNodeState throws Node not found when the node is absent. Any window
where the server’s own KrillApp.Server node was not yet persisted — e.g. a
beacon arriving before ServerNodeManager.init() saved it — made every beacon
reply throw. ServerBeaconWireHandler.handleIncomingWire calls sendSignal()
before serverConnector.connectWire(wire), so the throw also blocked storing
server peers, not just app discovery.
The persisted node read was both redundant and worse than the value already in
hand: getSelfWithInfo() returns a ServerMetaData whose port is already
resolved to DEFAULT_PORT (8442) when the stored port is 0, whereas the raw
persisted node could carry port 0.
server/.../io/beacon/ServerBeaconSender.kt: build the beacon NodeWire
straight from getSelfWithInfo().meta and drop the
nodeManager.readNodeState(installId()) lookup entirely. The
ServerNodeManager constructor dependency is removed (a self-describing
beacon has no business consulting the node store).ServerBeaconSender.buildSelfWire(identity, installId,
platform, clusterToken, timestamp) companion helper as a deterministic seam.server/.../di/ServerModule.kt: updated the Koin binding to the 2-arg
constructor.ServerBeaconSenderSelfWireTest (JUnit 5, pure helper, no
installId(), no filesystem, no Koin) asserts the wire is derived from the
resolved identity and carries the resolved port — passes under HOME=/nonexistent.readNodeState(installId()) call site in the server guards with
if (!nodeManager.nodeAvailable(installId())) return (see Lifecycle.kt,
Routes.kt) precisely because the self-node may be absent. An unguarded
readNodeState(installId()) is a latent crash — prefer getSelfWithInfo(),
which is the resilient “who am I” resolver, over reading self back from the store.getSelfWithInfo() (or any resolver with a built-in fallback) has already
produced the value, don’t re-read the same fact from a source that can fail.handleIncomingWire, the beacon reply and the peer-connect share one
try/catch; an exception in the reply silently skips the connect. Order
independent side-effects so one failing does not starve the other (kept the
root-cause fix minimal here, but noted as a hardening follow-up).docs/lessons/2026-06-18-server-beacon-one-shot-peer-discovery.md —
same subsystem, also a discovery failure that produced no visible reply.