Skip to content

Commit 181e48b

Browse files
dzhelezovclaude
andauthored
harness(rg3): fix K2 baseline block-skip race + empty-string MOCK_TRACE guard (#169)
* harness(rg3): fix K2 baseline block-skip race (per-connection stream cursor) The K2 baseline (turnDelayMs:450) aborted at K2-midstream: the mock's /stream delivered 100→101→103, skipping 102, so the product correctly fataled on a corrupt sequence (portal-realtime.ts:1101 "unknown parent"). Root cause (mock artifact, NOT a product bug): handleBlocks iterated block numbers off a MODULE-LEVEL `streamCursor`. K2's 450ms inter-block delay lets the client open an overlapping /stream (a legitimate reconnect on the finalized-head advancing 100→103 mid-stream). Two concurrent handleBlocks turns then read+wrote the one shared cursor across their delays and interleaved: conn1 wrote 102 and set cursor=102, conn2 woke, read cursor=102 and wrote 103 — skipping 102 on conn2, the connection the client consumed. Fast classes (K3) never hit it: the window is too small to overlap. Confirmed via MOCK_TRACE request-interleave. Fix: seed a per-connection `localCursor` from the request's fromBlock and do all per-block arithmetic against it; a real Portal serves each /stream connection independently. The shared `streamCursor` is still mirrored best-effort for the fromBlock-less default of a subsequent request. Also adds MOCK_TRACE-gated (no-op when unset) request/cursor tracing for future harness debugging. No product code under portal/ is touched. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * harness(rg3): treat empty-string MOCK_TRACE as tracing-off orchestrate.sh forwards MOCK_TRACE via `${MOCK_TRACE:-}`, so a caller that never sets it (the acceptance capstone) reaches node as an empty string, not undefined. The `=== undefined` guard let that empty value through to appendFileSync(''), which throws ENOENT — returned as a 500 on the very first /finalized-head probe and surfacing as a spurious "cannot establish finality boundary" fatal on every non-tracing run (i.e. the whole capstone). Guard on `!TRACE_FILE` so unset and empty both mean off; a real trace path is unaffected. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 4c73eb1 commit 181e48b

2 files changed

Lines changed: 73 additions & 4 deletions

File tree

harness/chaos/realtime/mock-portal.mjs

Lines changed: 72 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,22 @@ export const DEFAULT_SCENARIO = {
3737
],
3838
};
3939

40+
const TRACE_FILE = process.env.MOCK_TRACE;
41+
let TRACE_CONN = 0;
42+
const trace = (msg, extra) => {
43+
// Treat unset AND empty-string as "tracing off". orchestrate.sh forwards MOCK_TRACE via
44+
// `${MOCK_TRACE:-}`, so an unset caller (e.g. the acceptance capstone) reaches node as an empty
45+
// string, not undefined. A bare `=== undefined` guard let that empty value through to
46+
// appendFileSync(''), which throws ENOENT — surfacing as a 500 on the very first /finalized-head
47+
// probe and a spurious "cannot establish finality boundary" fatal for every non-tracing run.
48+
if (!TRACE_FILE) return;
49+
50+
const line = `${new Date().toISOString()} ${msg}${
51+
extra === undefined ? '' : ` ${JSON.stringify(extra)}`
52+
}\n`;
53+
appendFileSync(TRACE_FILE, line);
54+
};
55+
4056
const isPlainObject = (value) =>
4157
value !== null && typeof value === 'object' && Array.isArray(value) === false;
4258

@@ -646,6 +662,12 @@ export function createRuntime(initialScenario, options = {}) {
646662
scenario.finalizedHeadSeq[
647663
Math.min(finalizedIndex, scenario.finalizedHeadSeq.length - 1)
648664
] ?? scenario.finalizedHeadSeq[0];
665+
trace('finalizedHead:request', {
666+
finalizedIndex,
667+
headNumber: head?.number,
668+
streamCursor,
669+
stepIndex,
670+
});
649671
const phase = gatePhaseForBlock(scenario, head.number);
650672
if (phase !== undefined) {
651673
stats.finalizedHeadGates += 1;
@@ -670,6 +692,13 @@ export function createRuntime(initialScenario, options = {}) {
670692

671693
const finalizedStream = async (body, res) => {
672694
recordRequest('finalizedStream', body);
695+
trace('finalizedStream:request', {
696+
fromBlock: body?.fromBlock,
697+
toBlock: body?.toBlock,
698+
finalizedIndex,
699+
streamCursor,
700+
stepIndex,
701+
});
673702
const from = Number(body?.fromBlock ?? scenario.genesis.number);
674703
const head = scenario.finalizedHeadSeq[
675704
Math.max(
@@ -797,14 +826,39 @@ export function createRuntime(initialScenario, options = {}) {
797826
};
798827

799828
const handleBlocks = async (step, body, res) => {
829+
TRACE_CONN += 1;
830+
const conn = TRACE_CONN;
800831
const requestFrom = Number(body?.fromBlock ?? streamCursor + 1);
801-
streamCursor = requestFrom - 1;
802-
const firstNumber = streamCursor + 1;
832+
trace('handleBlocks:enter', {
833+
conn,
834+
fromBlock: body?.fromBlock,
835+
requestFrom,
836+
streamCursorBefore: streamCursor,
837+
stepIndex,
838+
finalizedIndex,
839+
});
840+
// Per-connection cursor. The block-number arithmetic MUST NOT read the shared module-level
841+
// `streamCursor`: a slow step (turnDelayMs) lets the client open an overlapping /stream (e.g. on a
842+
// finalized-head advance), and two concurrent handleBlocks turns reading+writing one shared cursor
843+
// interleave into a skipped block number (100→101→103) that the product rightly fatals on. A real
844+
// Portal serves each /stream connection independently from its own fromBlock, so seed a LOCAL cursor
845+
// here and only mirror it back to the shared one (best-effort, for the fromBlock-less default of the
846+
// NEXT request) after each write.
847+
let localCursor = requestFrom - 1;
848+
streamCursor = localCursor;
849+
const firstNumber = localCursor + 1;
803850

804851
res.writeHead(200, { 'content-type': 'application/x-ndjson' });
805852
const count = Math.max(0, Number(step.count ?? 0));
806853
for (let i = 0; i < count; i++) {
807-
const number = streamCursor + 1;
854+
const number = localCursor + 1;
855+
trace('handleBlocks:writeStart', {
856+
conn,
857+
number,
858+
localCursor,
859+
streamCursor,
860+
finalizedIndex,
861+
});
808862
const phase = gatePhaseForBlock(step, number);
809863
if (phase !== undefined) {
810864
const open = await gate(
@@ -833,8 +887,16 @@ export function createRuntime(initialScenario, options = {}) {
833887
logs: logsForBlock(step, number),
834888
}),
835889
);
890+
localCursor = number;
836891
streamCursor = number;
892+
trace('handleBlocks:wrote', { conn, number, localCursor, streamCursor });
837893
await streamTurnDelay(turnDelayForStep(step));
894+
trace('handleBlocks:afterDelay', {
895+
conn,
896+
wroteNumber: number,
897+
localCursor,
898+
streamCursorNow: streamCursor,
899+
});
838900
if (res.destroyed || res.writableEnded) return;
839901
}
840902
stepIndex += 1;
@@ -1016,6 +1078,13 @@ export function createRuntime(initialScenario, options = {}) {
10161078

10171079
const stream = async (body, res) => {
10181080
recordRequest('stream', body);
1081+
trace('stream:request', {
1082+
fromBlock: body?.fromBlock,
1083+
parentBlockHash: body?.parentBlockHash,
1084+
streamCursor,
1085+
stepIndex,
1086+
finalizedIndex,
1087+
});
10191088
observeWrongForkStreamRequest(body);
10201089
const step = scenario.steps[stepIndex];
10211090
if (step === undefined) {

harness/chaos/realtime/orchestrate.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,7 @@ start_mock () {
203203
local mock_log="$3"
204204
local killat_block="${4:-}"
205205
MOCK_PHASE_LOG="$phase_log" MOCK_SCENARIO="$scenario" MOCK_PORT="$MOCK_PORT" \
206-
MOCK_KILLAT_BLOCK="$killat_block" \
206+
MOCK_KILLAT_BLOCK="$killat_block" MOCK_TRACE="${MOCK_TRACE:-}" \
207207
node "$CDIR/mock-portal.mjs" >"$mock_log" 2>&1 &
208208
MOCK_PID="$!"
209209
wait_http || {

0 commit comments

Comments
 (0)