Skip to content

Commit fa4fe05

Browse files
pathosDevclaude
andcommitted
test(testkit): TEMP CI diagnostics for multi-node non-convergence
The two CI-only failures (ParallelMultiNodeSpec ×4, LeaseMajority) are non-convergence on GitHub's hosted runners — NOT reproducible on any local hardware (they pass at 2 vCPU even under the near-full suite), and timeouts are ruled out (90s still times out). Since it can't be debugged locally, this adds temporary, rate-limited diagnostics so a single CI run reveals the failure mode: - MultiNodeBroker: frame counters (recv / delivered / dropped-by-reason). - ParallelMultiNodeSpec.awaitMembers: logs the observed member view + broker counters over time, tagged [MNS-DEBUG]. - lease-majority.test.ts: per-node total/up member counts + self status across the arbitration poll, tagged [LEASE-DEBUG]. Locally these show gossip starting ~3s after spawn then converging in ~200ms; on CI they'll show whether broker.recv stays 0 (workers never gossip / port wiring dead), rpcErr climbs (workers wedged), or frames flow but convergence stalls. All logging is temporary — revert once the GitHub-runner non-convergence is root-caused. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 14b5902 commit fa4fe05

3 files changed

Lines changed: 61 additions & 6 deletions

File tree

‎src/testkit/ParallelMultiNodeSpec.ts‎

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -281,10 +281,37 @@ export class ParallelMultiNodeSpec {
281281
async awaitMembers(
282282
role: string, expectedCount: number, timeoutMs: number = this.settings.awaitTimeoutMs,
283283
): Promise<void> {
284+
// TEMP diagnostic (#flaky-ci): log the observed member view + broker
285+
// frame counters over time, so a CI-only non-convergence is fully
286+
// debuggable from the workflow log. Rate-limited (on-change + every
287+
// 3s). Remove once the GitHub-runner non-convergence is root-caused.
288+
const startedAt = Date.now();
289+
let lastLog = 0; let lastUp = -2; let polls = 0; let rpcErrs = 0;
284290
await this.awaitCondition(
285291
async () => {
286-
const members = await this.getMembers(role);
287-
return members.filter((m) => m.status === 'up').length === expectedCount;
292+
polls++;
293+
let up = -1; let statuses = '';
294+
try {
295+
const members = await this.getMembers(role);
296+
up = members.filter((m) => m.status === 'up').length;
297+
statuses = members.map((m) => `${m.address}=${m.status}`).join(' ');
298+
} catch (e) {
299+
rpcErrs++; statuses = `RPC-ERR:${(e as Error).message}`;
300+
}
301+
const now = Date.now();
302+
if (up !== lastUp || now - lastLog > 3_000) {
303+
const s = this.broker.stats;
304+
// eslint-disable-next-line no-console
305+
console.error(
306+
`[MNS-DEBUG] awaitMembers(${role} want=${expectedCount}) `
307+
+ `t=${now - startedAt}ms up=${up} polls=${polls} rpcErr=${rpcErrs} `
308+
+ `broker{recv=${s.recv} deliv=${s.delivered} senderGone=${s.dropSenderGone} `
309+
+ `part=${s.dropPartition} noTgt=${s.dropNoTarget} stop=${s.dropStopped}} `
310+
+ `view[${statuses}]`,
311+
);
312+
lastLog = now; lastUp = up;
313+
}
314+
return up === expectedCount;
288315
},
289316
`awaitMembers(${role}, expected=${expectedCount})`,
290317
timeoutMs,

‎src/testkit/internal/MultiNodeBroker.ts‎

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,17 @@ export class MultiNodeBroker {
3232
private readonly blocked = new Set<string>();
3333
private stopped = false;
3434

35+
/**
36+
* TEMP diagnostic counters (#flaky-ci) — frame flow through the broker.
37+
* If `recv` stays ~0 the workers never gossip (worker→broker transport
38+
* dead); if `recv` climbs but `delivered` lags, routing is dropping
39+
* frames. Read by ParallelMultiNodeSpec's awaitMembers debug log.
40+
*/
41+
readonly stats = {
42+
recv: 0, delivered: 0,
43+
dropStopped: 0, dropSenderGone: 0, dropPartition: 0, dropNoTarget: 0,
44+
};
45+
3546
register(address: NodeAddress, port: PortLike): void {
3647
const key = address.toString();
3748
if (this.ports.has(key)) {
@@ -80,13 +91,15 @@ export class MultiNodeBroker {
8091
/* -------------------------------- internals ------------------------- */
8192

8293
private onMessage(sourceKey: string, env: BrokeredMessage): void {
83-
if (this.stopped) return;
84-
if (!this.ports.has(sourceKey)) return; // sender was unregistered
94+
this.stats.recv++;
95+
if (this.stopped) { this.stats.dropStopped++; return; }
96+
if (!this.ports.has(sourceKey)) { this.stats.dropSenderGone++; return; } // sender was unregistered
8597
const targetAddr = NodeAddressCtor.fromJSON(env.to);
8698
const targetKey = targetAddr.toString();
87-
if (this.blocked.has(`${sourceKey}→${targetKey}`)) return; // partition
99+
if (this.blocked.has(`${sourceKey}→${targetKey}`)) { this.stats.dropPartition++; return; } // partition
88100
const target = this.ports.get(targetKey);
89-
if (!target) return; // unknown destination
101+
if (!target) { this.stats.dropNoTarget++; return; } // unknown destination
102+
this.stats.delivered++;
90103
target.postMessage(env);
91104
}
92105
}

‎tests/multi-node/lease-majority.test.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,12 +100,27 @@ describe('LeaseMajority — end-to-end split-brain', () => {
100100
// running this inside the full suite, where it blew past a 10s
101101
// budget (the original flake). The loop still breaks the instant
102102
// one side dies, so the happy path stays quick.
103+
const startedAt = Date.now();
104+
let lastLog = 0;
103105
const deadline = Date.now() + 25_000;
104106
let leftAlive: string[] = [];
105107
let rightAlive: string[] = [];
106108
while (Date.now() < deadline) {
107109
leftAlive = ['a', 'b'].filter(isClusterAlive);
108110
rightAlive = ['c', 'd'].filter(isClusterAlive);
111+
// TEMP diagnostic (#flaky-ci): per-node view over time so a
112+
// CI-only non-convergence is debuggable from the workflow log.
113+
const now = Date.now();
114+
if (now - lastLog > 3_000) {
115+
const snap = ['a', 'b', 'c', 'd'].map((r) => {
116+
const c = spec.clusterFor(r);
117+
const self = c.getMembers().find((m) => m.address.equals(c.selfAddress));
118+
return `${r}{tot=${c.getMembers().length} up=${c.upMembers().length} self=${self?.status ?? '?'}}`;
119+
}).join(' ');
120+
// eslint-disable-next-line no-console
121+
console.error(`[LEASE-DEBUG] t=${now - startedAt}ms leftAlive=[${leftAlive}] rightAlive=[${rightAlive}] ${snap}`);
122+
lastLog = now;
123+
}
109124
// One side fully self-downed → arbitration has converged.
110125
if (leftAlive.length === 0 || rightAlive.length === 0) break;
111126
await Bun.sleep(50);

0 commit comments

Comments
 (0)