A server that went offline correctly showed the error (WARN) state. After the user deleted that offline server node, it re-appeared on screen every few seconds and the app kept trying (and failing) to reconnect — “as if it were still getting a beacon,” even though the server was offline.
The client has a delete-tombstone system (ClientNodeManager.deletedServers)
with three guards — update() refuses to re-create a tombstoned id,
ClientServerConnector.performConnectionSanityCheck blocks reconnects, and
EventClient’s retry loop breaks on it. But ClientBeaconWireHandler undercut
all of them: on every incoming beacon it called
nodeManager.allowServer(wire.installId), clearing the tombstone, then
reconnected. A half-online / flapping server keeps multicasting its cheap UDP
beacon even while its HTTPS/SSE endpoint is unreachable, so each ~5s re-probe
cleared the tombstone, re-added the node, and spun a doomed reconnect →
alarm(WARN) → repeat. The beacon path was the only automatic tombstone-clear,
so it was the sole resurrection vector. This contradicted the product rule that
a user’s delete is durable and re-adding is a deliberate action.
In BeaconWireHandler.kt, removed the unconditional
nodeManager.allowServer(wire.installId) from the beacon path and added an
early return when nodeManager.isServerDeleted(wire.installId) is true. A
deleted server is now ignored by beacons entirely; the tombstone is cleared only
by an explicit manual re-add (ClientServerConnector line ~111, the
TEMP_ID_FOR_CONNECTING path), which is unchanged. Because allowServer is a
no-op for ids that were never tombstoned, this changes behavior only for
deleted servers.
When a piece of state encodes a deliberate user decision (a delete tombstone),
no passive/background signal (a beacon, an SSE retry, a poll) should silently
reverse it — only an explicit user action should. Added
ClientBeaconWireHandlerDeletedServerTest: a beacon for a tombstoned server
must neither call allowServer nor connectWire, while a beacon for a live
server still connects. The test injects the installId seam and uses virtual
time, so it is environment-independent.