Repository navigation
Conversation
The test read the archive's pendingMaxBlockHeight, then the daemon's blockchainLength through fetchNetworkState, which proves and sends a transaction and so takes seconds. A block produced in that window made the daemon one ahead and failed the strict equality (expected 10, actual 9). Read the archive on both sides of the daemon read, and assert that the daemon value lies in [archiveBefore, archiveAfter]. archiveAfter is polled for up to 30 s until it reaches the daemon value. An archive that stays behind still fails the upper bound, and an archive ahead of the daemon fails the lower one. The 'Advance a block' check had the same race and gets the same fix. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review: changes requestedScope and compatibility. This is test-only: the diff touches only 1. The new assertion passes when
|
| resolver returns | archiveBefore | daemon | archiveAfter | this PR | old strict equality |
|---|---|---|---|---|---|
| correct | 10 | 10 | 11 | PASS | PASS |
| true − 1 (stale) | 9 | 10 | 10 | PASS | FAIL |
| true + 1 | 11 | 10 | 12 | FAIL | FAIL |
Consequences.
- The test exists to catch a one-block-stale or off-by-one-low
networkState, for example a regression in thechain_statusfilter or in the canonical/pending fallback inNetworkService. With this PR it no longer can. - The polling loop is effectively dead code.
- The original
9 !== 10flake is mostly archive ingestion lag: the daemon read lands milliseconds after the archive read, not seconds later.
Reproduction. On Lightnet:
- Temporarily change
src/services/network-service/network-service.tstoconst pendingMaxBlockHeight = (pendingRow ? Number(pendingRow.height) : canonicalMaxBlockHeight) - 1;
- Run
npm run test.
With this PR, both NetworkState height tests pass. On main, they fail.
2. Solution
Sample the daemon tip with a plain fetchLastBlock(), with no transaction and so no wait for inclusion:
- Read the daemon, then the archive, then the daemon again.
- Retry, bounded, until no block landed inside that window and the archive has caught up.
- Then assert exact equality.
The retry condition is "caught up" (>=), not "equal". A wrong value therefore ends in a clean assertion diff instead of retrying until it happens to pass.
@@ imports
UInt64,
+ fetchLastBlock,
} from 'o1js';
@@
emitMultipleFieldsEvents,
- fetchNetworkState,
randomStruct,
@@ replace sampleHeightsAroundDaemon / assertArchiveTracksDaemon with:
+ type HeightSample = {
+ results: NetworkQueryResult;
+ daemonBefore: number;
+ archive: number;
+ daemonAfter: number;
+ };
+
+ async function daemonHeight(): Promise<number> {
+ // A plain bestChain read: no transaction, so no wait for inclusion.
+ return Number((await fetchLastBlock()).blockchainLength.toString());
+ }
+
+ /**
+ * Read the daemon tip, the archive's pendingMaxBlockHeight, then the daemon
+ * tip again. A sample counts only when no block landed inside it
+ * (daemonBefore === daemonAfter) and the archive has ingested that tip
+ * (archive >= daemonBefore). Retries absorb archive ingestion lag and a block
+ * produced mid-sample; the retry predicate is "caught up", not "equal", so a
+ * wrong height still ends in a clean assertion diff, not a silent pass.
+ */
+ async function sampleStableHeights({
+ attempts = 30,
+ delayMs = 1000,
+ } = {}): Promise<HeightSample> {
+ let sample!: HeightSample;
+ for (let attempt = 1; attempt <= attempts; attempt++) {
+ const daemonBefore = await daemonHeight();
+ const results = await executeNetworkStateQuery();
+ const archive =
+ results.data.networkState.maxBlockHeight!.pendingMaxBlockHeight;
+ const daemonAfter = await daemonHeight();
+ sample = { results, daemonBefore, archive, daemonAfter };
+ if (daemonBefore === daemonAfter && archive >= daemonBefore) break;
+ if (attempt < attempts)
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
+ }
+ return sample;
+ }
+
+ function assertArchiveMatchesDaemon({
+ daemonBefore,
+ archive,
+ daemonAfter,
+ }: HeightSample) {
+ const values = `daemon before ${daemonBefore}, archive ${archive}, daemon after ${daemonAfter}`;
+ assert.strictEqual(
+ daemonBefore,
+ daemonAfter,
+ `no block-free sampling window within the retry budget: ${values}`
+ );
+ assert.strictEqual(
+ archive,
+ daemonBefore,
+ `archive pendingMaxBlockHeight must equal the daemon blockchainLength: ${values}`
+ );
+ }
@@ describe('NetworkState')
- let heights: { archiveBefore: number; daemon: number; archiveAfter: number };
+ let heights: HeightSample;
before(async () => {
- const sample = await sampleHeightsAroundDaemon(zkApp, senderKeypair);
- results = sample.results;
+ heights = await sampleStableHeights();
+ results = heights.results;
blockResponse = results.data.networkState;
- heights = sample;
});
...
- assertArchiveTracksDaemon(heights);
+ assertArchiveMatchesDaemon(heights);
// same two substitutions in the 'Advance a block' before() and testWhy this closes both races:
- A block lands mid-sample:
daemonBefore !== daemonAfter, so the sample is retried. - The archive lags:
archive < daemonBefore, so the sample is retried. - The resolver reports one too high: the first stable sample has
archive = daemonBefore + 1, andstrictEqualfails. - The resolver reports one too low: a sample never becomes both stable and caught up. The archive only reports the next block after the daemon already has it, which makes
daemonAfter > daemonBefore. The test fails after about 30 s, and the message shows all three values. - Bounded: at most 30 × 1 s plus three reads per attempt. A Lightnet slot is about 20 s, so a stable window normally appears on the first or second try.
- Faster: it also removes a 20–40 s wait for transaction inclusion from each
NetworkStatebefore().
3. Testing methodology and acceptance criteria
npm run build,npx tsc --noEmitandnpm run test:unitare clean. I checked this locally with the diff applied.- CI
Run-Tests (22)andRun-Tests (24)pass. Re-run them at least 3 times, with noNetworkStatefailure. - Negative check (required). On Lightnet, apply the
- 1mutation from the reproduction. Both "Fetched max block height … should match" tests must fail. Repeat with+ 1; both must fail again. Then revert the mutation. Post the failing output in the PR. - The failure message always prints
daemon before,archiveanddaemon after.
…ality The bracket from 20d7bb4 read the daemon height through fetchNetworkState, which sends a transaction and waits for its inclusion. By the time the archive was polled again it was already at daemon + 1, so a resolver that reported one block low still passed. Read the daemon tip with fetchLastBlock, which sends no transaction, then the archive, then the daemon again. A sample counts only when no block landed inside it and the archive has caught up; retry up to 30 x 1 s. Then assert exact equality. The retry predicate is "caught up", not "equal", so a wrong height ends in an assertion diff that prints all three values. Verified on a local Lightnet: the suite passes as is, and both NetworkState height tests fail when pendingMaxBlockHeight is mutated by -1 or by +1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thank you. Your finding is correct: Your solution is in What changed
Acceptance criteria
One note about the method: with 🤖 Generated with Claude Code |
Problem
The Lightnet test "Fetched max block height from archive node should match with the one from mina node" (
tests/resolvers.test.ts,describe('NetworkState')) fails at random.The test read two values, one after the other:
pendingMaxBlockHeight(the highest block in the archive database).blockchainLength, throughfetchNetworkState.If Lightnet produces a block between step 1 and step 2, or the archive has not ingested the newest block yet, the daemon value is one more than the archive value. The test uses strict equality, so it fails. The archive did nothing wrong.
Evidence
In each case the daemon is exactly one block ahead. Since #235, Lightnet runs two times per PR (Node 22 and Node 24), so the race has two chances to occur in each CI run.
Fix
The test now reads the daemon tip with
fetchLastBlockfrom o1js. This is a plainbestChainread: it sends no transaction, so it does not wait for a block.sampleStableHeightsreads three values, in this order:daemonBefore: the daemonblockchainLength.archive: the archivependingMaxBlockHeight.daemonAfter: the daemonblockchainLengthagain.A sample is accepted only when no block arrived during the sample (
daemonBefore === daemonAfter) and the archive has caught up (archive >= daemonBefore). Otherwise the helper waits 1 s and tries again, a maximum of 30 times.assertArchiveMatchesDaemonthen asserts exact equality:daemonBefore === daemonAfterandarchive === daemonBefore. The failure message gives all three values.The retry condition is "caught up", not "equal". Thus a wrong height gives a clear assertion diff. It does not cause retries until the test passes by chance.
The "Advance a block" test had the same race. It gets the same fix.
fetchNetworkStateis no longer imported by this test.Why the test still finds a wrong height
+1): the first stable sample hasarchive = daemonBefore + 1, so the equality fails.-1): a sample never becomes both stable and caught up. When the retries stop, the equality fails.Verification
npm run build,tsc --noEmit,npm run lintandnpm run test:unitare clean.o1labs/mina-local-network:compatible-latest-lightnet,single-node,PROOF_LEVEL=none), fullresolvers.test.js:pendingMaxBlockHeight - 1: both NetworkState height tests fail (daemon before 40, archive 39, daemon after 40).pendingMaxBlockHeight + 1: both NetworkState height tests fail (daemon before 54, archive 55, daemon after 54).tests/resolvers.test.ts. Prettier wants to reformat other lines in that file, which were not clean before. I did not include those changes.🤖 Generated with Claude Code