Skip to content

Commit 8301386

Browse files
dzhelezovclaude
andauthored
fix(portal): root-cause the fork-seam S1 timeout — make S1 deterministic + fast (#19)
The `fork-seam` CI job (both ponder versions) was failing on every branch, including main: `portal-shell.test.ts > S1` timed out (15s, then 30s after a timeout bump) while the other 139 tests passed. Bumping the timeout only made it fail slower — the test genuinely takes seconds and blows past any headroom under full-suite CPU contention. Root cause: S1's mock served one block (HTTP 200) for EVERY request with fromBlock < 3M, so the client re-requested one block at a time across each 500k-wide chunk — ~45k sequential round-trips per chunk, 47,037 requests total. The per-fetch row-accounting logic under test was correct; the mock was pathological. Fix: the mock now serves EXACTLY one block per chunk, at the chunk boundary, and 204s a continuation request past it so the stream terminates. Requests drop 47,037 -> 8; S1 runtime 6.9s -> 0.33s. To keep S1's "no orphans" coverage (the original slow version caught the missing `token.freed` guard precisely because read-ahead chunks were still in flight at eviction time), the read-ahead chunk responses are delayed 150ms so they remain in flight when the far-ahead interval evicts and frees their tokens — recreating that race deterministically. Mutation-verified: dropping the eviction free OR the `token.freed` guard both fail S1; clean passes. Full Portal suite 140/140. Co-authored-by: claude-ovh-ops <noreply@anthropic.com>
1 parent 94b804b commit 8301386

1 file changed

Lines changed: 26 additions & 4 deletions

File tree

portal/portal-shell.test.ts

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ import { InvariantViolation } from './portal-invariant.js';
1212
* stash lifecycle (INV-12), and the per-fetch row-accounting model (S1).
1313
*/
1414

15+
// Fixed chunk width for S1's chunk-boundary mock (matches the PORTAL_CHUNK_BLOCKS default).
16+
const CHUNK_BLOCKS = 500_000;
17+
1518
const VAULT = '0x44b3c96db2caf61167a9eab82901139a404cdb6f';
1619
const DEPOSIT_TOPIC0 =
1720
'0xdcbc1c05240f31ff3ad067ef1ee35ce4997762752e3a095284754544f4c709d7';
@@ -668,6 +671,9 @@ test('S1: rows return to baseline after chunks settle and are evicted (no double
668671
// fetches settle: the gate's row count must equal exactly the LIVE cache's rows — here 0, because the
669672
// final far interval has no data and everything behind was evicted + freed via per-fetch tokens.
670673
process.env.PORTAL_READAHEAD = '2';
674+
// Pin the chunk width so the mock's chunk-boundary math below is exact (density scaling is already
675+
// off via PORTAL_CHUNK_FIXED in beforeEach; this fixes the grid to a known 500k regardless).
676+
process.env.PORTAL_CHUNK_BLOCKS = String(CHUNK_BLOCKS);
671677
const srv = http.createServer((req, res) => {
672678
let body = '';
673679
req.on('data', (c) => {
@@ -680,11 +686,26 @@ test('S1: rows return to baseline after chunks settle and are evicted (no double
680686
return;
681687
}
682688
const from = q.fromBlock ?? 0;
683-
// chunks below 3M have one block each at a chunk boundary; beyond → empty
684-
if (from < 3_000_000) {
685-
const n = from + 10;
689+
const to = q.toBlock ?? 0;
690+
// EXACTLY one block per chunk, positioned at the chunk boundary; a continuation request past that
691+
// boundary (from = block+1, no longer chunk-aligned) or a chunk at/after 3M gets a 204 so the
692+
// stream terminates. Returning a block for every `from` instead makes the client re-request one
693+
// block at a time across the whole 500k-wide chunk (~45k round-trips/chunk) — the stream drains
694+
// fine but takes seconds, and under full-suite contention that overran the per-test timeout.
695+
const boundary = Math.ceil(from / CHUNK_BLOCKS) * CHUNK_BLOCKS;
696+
if (boundary < 3_000_000 && boundary <= to) {
686697
res.writeHead(200, { 'content-type': 'application/x-ndjson' });
687-
res.end(`${JSON.stringify(mkBlock(n))}\n`);
698+
const payload = `${JSON.stringify(mkBlock(boundary + 10))}\n`;
699+
// The read-ahead chunks (boundary ≥ CHUNK_BLOCKS — i.e. NOT chunk 0) respond SLOWLY so they are
700+
// still in flight when the far-ahead interval evicts and frees their tokens. That is the exact
701+
// race the per-fetch `token.freed` guard exists to close: a stale stream that outlives its
702+
// eviction must not register orphan rows into an already-freed budget. Chunk 0 (boundary 0)
703+
// answers immediately so the first interval's await returns promptly.
704+
if (boundary >= CHUNK_BLOCKS) {
705+
setTimeout(() => res.end(payload), 150);
706+
return;
707+
}
708+
res.end(payload);
688709
return;
689710
}
690711
res.writeHead(204).end();
@@ -727,5 +748,6 @@ test('S1: rows return to baseline after chunks settle and are evicted (no double
727748
} finally {
728749
srv.close();
729750
delete process.env.PORTAL_READAHEAD;
751+
delete process.env.PORTAL_CHUNK_BLOCKS;
730752
}
731753
});

0 commit comments

Comments
 (0)