Android logcat storm at app launch: with 3 saved servers, each server node was
re-processed 100+ times within ~150 ms — endless cycles of
loading stored host - updating nodes and executing → Executing node →
collected node ... PROCESSING → connectNode → already connected, plus
LocalSwarmHost init: loading 0 local nodes repeating dozens of times and the
Client node re-collected with a monotonically increasing timestamp on every
cycle. The loop never converged; it just burned CPU on ~20 dispatcher threads.
A self-sustaining dispatch loop across three pieces:
DefaultNodeObserver dispatches the node’s processor on every
StateFlow emission.ClientClientProcessor.process() (the Client node’s processor) re-ran its
entire startup body on every dispatch — fileOperations.load(),
update() + execute() for every stored server host (each execute()
stamps PROCESSING with a fresh timestamp → more emissions → more
ClientServerProcessor.connectNode dispatches), beacon start, and
LocalSwarmHost.init().ClientNodeManager.reset() — called unconditionally at the end of
process() — stamped state = NONE, timestamp = now() even when the
client node was already NONE. The fresh timestamp defeats StateFlow
conflation, so the flow re-emitted, the observer re-dispatched
process(), and the cycle repeated forever.A secondary race: the observer’s collector dispatched node.value (re-read at
dispatch time) instead of the collectedNode it was woken for, so under
concurrent updates the same latest value could be dispatched repeatedly while
the actual transition was skipped.
ClientNodeManager.reset() is now a no-op when the stored node is already
NONE — a reset is a transition into NONE; a timestamp-only re-emission
is pure feedback-loop fuel (shared/.../node/manager/ClientNodeManager.kt).ClientClientProcessor moved the one-time startup work (stored-host
load/execute, LocalSwarmHost.init()) behind a @Volatile one-shot guard;
the PIN-gated beacon start stays per-dispatch (it is idempotent, and the
dispatch after PIN entry is what starts beacons post-FTUE)
(shared/.../krillapp/client/ClientClientProcessor.kt).DefaultNodeObserver dispatches collectedNode instead of re-reading
node.value (shared/.../node/NodeObserver.kt).shared/src/jvmTest:
ClientNodeManagerResetTest, ClientClientProcessorStartupTest.NodeObserver can wake must be idempotent with
respect to its own side effects on the observed node: if process()
writes the node back (reset/update/execute), the write must not produce a
new emission when nothing changed. Timestamp-only bumps on an unchanged
state are the canonical loop fuel — never stamp timestamp = now() without
also changing state or meta.process() twice, assert one-shot work ran
once), as ClientClientProcessorStartupTest now does.