fix(networkstate): a node it has not heard of yet is not a node that is absent - #1786
Conversation
…is absent search(), includes() and getRandomSocketAddress() awaited waitIndexesReady, which is the indexing lock. That lock is free before the first fetch has ever run, so the wait returned at once and the lookup read an empty index - and every node in the fleet came back absent. A peer acting on that answer rejects a legitimate node. Observed in an integration gate: a node answered "pubkey not in node list" to four sync requests 58ms before it logged building its first index. The requester is never asked again, so it fell back to the block timer and never completed state sync. The list has three conditions, not two. Never fetched, where the node cannot answer and has to wait; fetched and empty, where "absent" is the truth and it answers immediately; fetched and populated, where it answers from the index. Waiting on the first population instead would have hung the empty case for the life of the process, so what is tracked is the first fetch coming back. Waiters are released when the manager stops, where no fetch is coming. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Cabecinha84
left a comment
There was a problem hiding this comment.
Verdict: safe to ACK. The defect is real, the fix is correct and minimal, and I reproduced both the bug and the fix
independently. There are three things worth raising — none of them blocking, one of them worth a two-line follow-up.
State of the PR right now: 1 commit, 3 files, +208/−12, no prior reviews or comments (this is a fresh, untouched PR),
MERGEABLE, zero commits behind development, CI green (build + GitGuardian).
The defect is genuine
search(), includes() and getRandomSocketAddress() awaited waitIndexesReady, which is #controller.lock.waitReady() —
the indexing lock. Before the first fetch that lock has never been taken, so the await resolves immediately and the
lookup reads an empty Map. Every peer comes back absent, and callers (fluxCommunicationUtils broadcast verification,
peer selection, port testing) act on that as "not a Fluxnode". The diagnosis in the PR body matches the code exactly.
What I verified independently
- The fix works, and the tests genuinely pin it. I swapped development's networkStateManager.js under the new test
file: 3 of the 4 new tests fail (two assert answered === false and get true; the empty-fleet one times out at 2000ms).
The fourth (stop releases waiters) passes on development because there is nothing to release there — which is exactly
what the PR body claims, no overstatement. - Unit suites are green. networkStateManager.test.js → 25 passing. The eight suites that consume these lookups
(fluxCommunication, fluxCommunicationUtils, networkStateService, fluxNetworkHelper, availabilityChecker,
networkHealthMonitor, nodeConfirmationService, placementFeasibility) → 371 passing, no hangs from the new wait. - eslint is clean on all three changed files.
- A fetcher that throws does not hang callers: the .catch() inside the loop turns it into [], which marks answerable
and the lookup returns false promptly. I confirmed this by probe. - The unbounded wait is bounded in practice: daemonServiceUtils.executeCall uses timeout: 40_000, so the first fetch
always settles. Worst case a lookup waits ~40s once, then never again. Mocha runs with --exit, so no lingering-timer
risk in CI.
Findings
- #answerable is never reset — a restarted instance re-exposes the exact bug (low, latent).
stop() → #releaseWaiters() → #markAnswerable() sets the flag permanently, and reset() clears the indexes but not the
flag. I confirmed by probe: start() → stop() → start() on the same instance, and a lookup before the second fetch
returns false immediately — the fixed defect, back.
It is latent today only because networkStateService.stop() nulls stateManager and start() constructs a fresh one, so
production never restarts an instance. But nothing in the class says that, and the class is what the fix lives in. Two
lines in reset() would close it:
this.#answerable = false;
this.#answerableWait = new Promise((resolve) => { this.#onAnswerable = () => { resolve(); this.#onAnswerable = () =>
{}; }; });
This is the one I'd actually ask for, or take as an agreed follow-up.
- A first fetch that comes back empty still answers "absent" (design gap worth naming).
#markAnswerable() fires in the !state.length branch, so an empty first fetch makes the node answer "not a Fluxnode"
for real peers. And the service-level fetcher flattens failure into emptiness — res.status === 'success' ? res.data :
[] — so an RPC error is indistinguishable from a genuinely empty fleet. On a booting node whose fluxd is not yet
ready, that is the common path, and the retry loop's own 15s sleep exists precisely because empty at boot means "ask
again", not "the fleet is empty".
So the fix closes the fetch-in-flight window (~0.5s, which is what the integration gate caught) but not the
daemon-not-ready window. The tension is real and the PR is honest about half of it: waiting on empty would hang
harnesses running nodes: 0. The cleaner resolution — have the fetcher distinguish success-with-empty from failure, and
only mark answerable on the former — preserves the nodes: 0 case while closing the boot window, but reintroduces an
unbounded wait if the daemon errors indefinitely. Worth a comment; not something to hold the PR for, since it is
strictly no worse than today.
- #onStartComplete() inside #releaseWaiters() isn't needed and slightly widens a pre-existing path (nit).
Lookups wait on #answerableWait only, so resolving waitStarted does nothing for the stated purpose. What it does do:
if stop() lands while the initial fetch is in flight and the loop then breaks on aborted without populating, start()
sails past await this.waitStarted and installs a polling loop on a stopped manager. I checked whether this is a
regression — it isn't: on development the same zombie loop already happens via the populate path (I measured the
fetcher still being called 700ms after stop() on both branches). It's a narrow new race into an existing bad state.
I'd just drop the line.
Nit: @type {() => void | null} on #onAnswerable parses as a function returning void|null; it wants {(() => void) |
null}. Matches the existing #onStartComplete style, so take it or leave it.
Side effects worth knowing about, but fine
nodeConfirmationService.poll() re-arms only after it finishes and now awaits getFluxnodeBySocketAddress, so its first
poll can be delayed by the first fetch. Bounded and self-correcting — this is exactly the caller class the waitStarted
docblock already warns about, and a delay is not a retirement. The isReady() docblock rewrite is accurate: the bulk
accessors (networkState(), nodeCount()) still conflate unknown with empty, and the three lookups no longer do. Callers
reaching these before networkStateService.start() still get false from the !stateManager guard — unchanged by this
PR, same class of gap as finding 2.
Recommendation
ACK. If you want one change first, make it finding 1 — resetting #answerable in reset() — since it is two lines and
keeps the class honest about its own contract. Findings 2 and 3 are fine as review comments or a follow-up.
One caveat you should weigh yourself: the full integration gate quoted in the PR body was run on #1778's tree, not on
this branch. The author states that plainly and the reasoning for why it's equivalent is sound, but it is not
literally a gate of this branch.
reset() returns the manager to how it was built - that is what it is for - but left two fields behind. #answerable stayed true, so a restarted instance answered from indexes it had just emptied, which is the defect this branch exists to close arriving by another door. #started stayed true, so a torn-down manager reported itself running and isReady() agreed. start() then only arms the refresh loop for a manager that actually got its list. A stop landing after a retry sleep has fired breaks the fetch loop without populating, and stop() releases everything waiting - start() included - so it woke and armed a loop against a manager that had just been torn down. Measured at ten further fetches after the stop where there should be none. The abort flag cannot be read for that: abort() installs a fresh AbortController on its way out, so by the time start() resumes it may already say it was never aborted. #started is the manager's own record of having a list and is exact. Six tests. Three cover the restart, one is a control proving an empty fleet still answers rather than hanging, and two cover the stop - each red against the change it names. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Confirmation opened the door to inbound peers. Every message one sends is checked against the node list, so a peer admitted before that list arrives is refused however legitimate it is - there is nothing to check it against, and the refusal reads as "not a Fluxnode". The two facts come from different calls to the same daemon: confirmation from one carrying a single record, the list from one carrying every node. So the list lands well after the confirmation, and that gap is the whole of the window peers were being turned away in. Measured on a live node holding 6091 nodes: confirmation and the door opening on the same millisecond, the list 1391ms later. Only the first open waits. Once the list is here isReady() is true and the callback runs inline, so regaining confirmation reconnects immediately. The re-check inside it matters: the list can arrive after confirmation was lost, and without it the callback hands the door back to a node that just dropped every peer. Four tests, three red without the gate. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`{() => void | null}` reads as a function returning `void | null`, not as a
nullable function. Both handles carried it - #onAnswerable was written to match
#onStartComplete - so both are corrected rather than leaving one wrong to keep
them consistent.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Thanks — all three are addressed, and the second one moved further than you proposed once I traced what actually reaches those lookups. Head is 1.
|
Three lookups on
networkStateManageranswered questions about the fleet before the node had ever asked what the fleet was, and the answer they gave was "absent".The defect
search(),includes()andgetRandomSocketAddress()each awaitedwaitIndexesReady. That getter isthis.#controller.lock.waitReady()— the indexing lock. Before the first fetch has ever run the lock is free, so the await returns immediately and the lookup reads an empty index. Every node in the fleet comes back absent, and nothing distinguishes that from a node genuinely not being one.The wait that means "I know the fleet" is a different one.
waitIndexesReadyis about not reading mid-rebuild; it says nothing about whether there has ever been anything to read.How it was found
An integration suite caught a node reporting a legitimate peer as not being in the node list, 58 ms before it logged creating its first index:
The membership question had been answered from an index that did not yet exist. Callers that treat a falsy answer as "not a node" then act on it, and a node that has simply not read the fleet yet is indistinguishable from one that is not in it.
What that cost downstream: the peer refused the sync request, so the requesting node collected no completions and took the block-timer route to readiness instead — which is what that fallback is for. Nothing downstream needed changing. The only thing that was wrong was the answer that sent it down that path.
The fix
A node list has three conditions, not two, and the code modelled two:
What is tracked is the first fetch coming back, not the first population. Those differ exactly when the fleet is legitimately empty: a list that never populates would leave a lookup waiting for the life of the process, which trades a false answer for a hang. Suites that run with
nodes: 0reach that case.#waitAnswerable()keeps the indexing wait on top of the new one. Mid-rebuild it costs ~10 ms and returns the newer state, which is why it was there; it simply was never the whole condition. Waiters are also released when the manager stops, where no fetch is coming and a waiter would otherwise never return.A timeout was considered and rejected: bounding the wait reintroduces the false answer on a slow node and only changes when it happens.
Blast radius
These three lookups back
getFluxnodesByPubkey,socketAddressInNetworkState,pubkeyInNetworkState,getRandomSocketAddressandgetFluxnodeBySocketAddressonnetworkStateService, which are used across peering, messaging, app sync, port testing and availability checking. TheisReady()docblock claimed every accessor on that service conflates unknown with empty; that is now true only of the bulk accessors, and it says so.Testing
Each test was proven to fail without the fix. The two false-answer tests assert immediately against the old code; the empty-fleet test times out.
Unit: 4,873 passing / 18 pending / 0 failing on this branch, four of them new.
eslintclean on all three changed files.Integration, targeted: the nine suites this change most directly touches —
06-daemon-interactionand12-ticker-controlbecause they run with empty and shrinking node lists,19-boundary-conditionsbecause it is the suite that surfaced the defect, and72,73,74,75,81,82becausegetRandomSocketAddressis how a node draws a peer for an image. All nine green, 92 tests, zero failures.Integration, full gate:
suites_pass=72 suites_fail=1at40a10a4ba.19-boundary-conditions— the suite that surfaced this defect, and the one that failed the same gate on the same tree without this fix — passes. The single red is60-volume-mount-ownership, which neither this branch nor #1778 touches and which is already fixed in #1781 higher up the stack: its probe stopped the app container before unmounting, and the stop's own die event drives a reconcile that remounts the volume, so the write it expects to be refused lands on a remounted one. It passes 8/8 run alone.Worth stating plainly about that gate: it runs on #1778's tree with this fix applied, not on this branch. #1778's suite set is a strict superset of
development's — the same 69 suites plus six volume ones, with nothing present only ondevelopment— and of the three files changed here, the manager and its unit test are byte-identical on both branches, while the service file differs only by a test-only addition #1778 makes in a different part of it. So the gate exercises every suite this branch would run and six more. It is not, however, literally a gate of this branch.