Files
evomap__evolver/test/heartbeatResilienceRound8.test.js
T
evolver-publish f5dbde2836 Release v1.88.0
2026-06-03 08:36:30 +00:00

285 lines
14 KiB
JavaScript

// Regression coverage for the round-8 audit (2026-05-28) heartbeat
// resilience fixes that closed holes round-7 either introduced or left
// unverified. Bugs covered here:
//
// §21.1 unknown_node hello-failure path was a dead-code soft-error.
// Round-7 returned {ok:false, error:'hello_failed_after_unknown_node'}
// from the outer .then(data) callback, but the allow-list that
// would have kept _heartbeatConsecutiveFailures climbing sat
// further down in the SAME callback (after the early return),
// so the counter never incremented and the drift detector's
// persistent-failure branch could not drive recovery. Round-7
// also cleared _unknownNodeBackoffUntil = 0, removing the only
// gate pokeHeartbeat consulted -- any user-activity poke during
// the intended 5-min wait bypassed it and re-fired into the
// still-hot hub cache. Round-8 increments the counter inline
// and sets _unknownNodeBackoffUntil = now+5min instead of
// clearing it.
//
// §21.2 _armSelfDrivingPollFromHeartbeat had `if (_pollInflight ||
// _selfDrivingPollTimer) return;` -- the `|| _selfDrivingPollTimer`
// clause defeated every caller of the hook, because all callers
// fire exactly when transitioning OUT of an unknown_node /
// reauth backoff window, which is when the existing timer was
// set to _SELF_DRIVING_POLL_QUIET_MS (5 min). The hook then
// no-op'd and the poll slept the full 5 min. Round-8 keeps only
// the _pollInflight gate.
//
// §21.9 Round-7 added module-global _pendingSelfDrivingPollDelayMs
// as the 429 retry-after override but forgot to clear it in
// stopHeartbeat / _resetHeartbeatStateForTesting. A test that
// exercised the 429 path leaked the delay into every subsequent
// test in the same process.
//
// §21.10 Drift detector long-sleep wake branch zeroed
// _heartbeatLastTickAt (to defeat pokeHeartbeat throttle, which
// is correct) but never restored a non-zero value, so the
// hung-tick watchdog's truthy guard
// `_heartbeatInFlight && _heartbeatLastTickAt && ...` could
// not fire on a wedged post-wake tick. Round-8 re-stamps
// lastTickAt after _runWakeRecovery returns and adds a
// belt-and-suspenders _scheduleNextHeartbeat(2_000) backstop.
const { describe, it, after, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
// Unconditionally pin the test secret inside test scope (a host-exported
// A2A_NODE_SECRET would otherwise win and make assertions host-dependent
// the moment global.fetch stops being stubbed). Save the original and
// restore it after the suite so we do not mutate ambient env for siblings.
const _origA2ASecret = process.env.A2A_NODE_SECRET;
process.env.A2A_NODE_SECRET = 'a'.repeat(64);
after(() => {
if (_origA2ASecret === undefined) delete process.env.A2A_NODE_SECRET;
else process.env.A2A_NODE_SECRET = _origA2ASecret;
});
const a2a = require('../src/gep/a2aProtocol');
const { sendHeartbeat, pokeHeartbeat, stopHeartbeat } = a2a;
const {
_resetHeartbeatStateForTesting,
_setHeartbeatStateForTesting,
_getHeartbeatInternalsForTesting,
_startSelfDrivingPollForTesting,
_stopSelfDrivingPollForTesting,
_setPendingSelfDrivingPollDelayMsForTesting,
_setPendingRescheduleDelayMsForTesting,
} = a2a._testing;
function nextTick() { return new Promise((r) => setImmediate(r)); }
async function settle() { await nextTick(); await nextTick(); await nextTick(); await nextTick(); }
// ---------------------------------------------------------------------------
// §21.1: hello-failure path increments counter AND installs 5-min gate
// ---------------------------------------------------------------------------
describe('round-8 §21.1: unknown_node hello-fail keeps counter + gate', () => {
let origFetch, origHub, origAllow;
beforeEach(() => {
_resetHeartbeatStateForTesting();
origFetch = global.fetch;
origHub = process.env.A2A_HUB_URL;
origAllow = process.env.EVOMAP_HUB_ALLOW_INSECURE;
process.env.A2A_HUB_URL = 'http://localhost:19999';
process.env.EVOMAP_HUB_ALLOW_INSECURE = '1';
});
afterEach(() => {
global.fetch = origFetch;
if (origHub === undefined) delete process.env.A2A_HUB_URL;
else process.env.A2A_HUB_URL = origHub;
if (origAllow === undefined) delete process.env.EVOMAP_HUB_ALLOW_INSECURE;
else process.env.EVOMAP_HUB_ALLOW_INSECURE = origAllow;
_resetHeartbeatStateForTesting();
});
it('hello failure after unknown_node increments _heartbeatConsecutiveFailures', async () => {
_setHeartbeatStateForTesting({ running: true, intervalMs: 30_000, consecutiveFailures: 0 });
// Hub returns unknown_node; the inner re-hello fails with a hub
// 500 (any non-ok). Round-7 left counter at 0 because the soft-
// error allow-list was unreachable from the unknown_node branch.
// Round-8 increments inline.
global.fetch = async (url) => {
const u = String(url || '');
if (u.indexOf('/a2a/hello') !== -1) {
return { ok: false, status: 500, json: async () => ({ error: 'hub down' }), text: async () => 'hub down' };
}
return { ok: true, status: 200, json: async () => ({ status: 'unknown_node' }), text: async () => '' };
};
const before = _getHeartbeatInternalsForTesting();
await sendHeartbeat();
await settle();
const after = _getHeartbeatInternalsForTesting();
assert.ok(
after.consecutiveFailures > before.consecutiveFailures,
'counter must climb (was ' + before.consecutiveFailures + ', now ' + after.consecutiveFailures + ')'
);
});
it('hello failure installs _unknownNodeBackoffUntil at now+5min (not 0)', async () => {
_setHeartbeatStateForTesting({ running: true, intervalMs: 30_000 });
global.fetch = async (url) => {
const u = String(url || '');
if (u.indexOf('/a2a/hello') !== -1) {
return { ok: false, status: 500, json: async () => ({ error: 'hub down' }), text: async () => '' };
}
return { ok: true, status: 200, json: async () => ({ status: 'unknown_node' }), text: async () => '' };
};
const t0 = Date.now();
await sendHeartbeat();
await settle();
const after = _getHeartbeatInternalsForTesting();
assert.ok(
after.unknownNodeBackoffUntil >= t0 + 4 * 60_000,
'deadline must be at least ~5 min ahead (got ' + (after.unknownNodeBackoffUntil - t0) + 'ms ahead)'
);
assert.ok(
after.unknownNodeBackoffUntil <= t0 + 6 * 60_000,
'deadline must not exceed ~5 min ahead'
);
});
it('pokeHeartbeat during the 5-min hello-fail window returns false (gate holds)', async () => {
_setHeartbeatStateForTesting({ running: true, intervalMs: 30_000 });
global.fetch = async (url) => {
const u = String(url || '');
if (u.indexOf('/a2a/hello') !== -1) {
return { ok: false, status: 500, json: async () => ({ error: 'hub down' }), text: async () => '' };
}
return { ok: true, status: 200, json: async () => ({ status: 'unknown_node' }), text: async () => '' };
};
await sendHeartbeat();
await settle();
const before = _getHeartbeatInternalsForTesting();
assert.ok(before.unknownNodeBackoffUntil > Date.now(), 'precondition: deadline must be active');
const result = pokeHeartbeat();
assert.equal(result, false, 'poke must refuse to drive a tick while the 5-min gate is active');
});
});
// ---------------------------------------------------------------------------
// §21.2: _armSelfDrivingPollFromHeartbeat replaces a long quiet-mode timer
// ---------------------------------------------------------------------------
describe('round-8 §21.2: arm-from-heartbeat replaces a long pending timer', () => {
let origFetch, origHub, origAllow;
beforeEach(() => {
_resetHeartbeatStateForTesting();
origFetch = global.fetch;
origHub = process.env.A2A_HUB_URL;
origAllow = process.env.EVOMAP_HUB_ALLOW_INSECURE;
process.env.A2A_HUB_URL = 'http://localhost:19999';
process.env.EVOMAP_HUB_ALLOW_INSECURE = '1';
});
afterEach(() => {
global.fetch = origFetch;
if (origHub === undefined) delete process.env.A2A_HUB_URL;
else process.env.A2A_HUB_URL = origHub;
if (origAllow === undefined) delete process.env.EVOMAP_HUB_ALLOW_INSECURE;
else process.env.EVOMAP_HUB_ALLOW_INSECURE = origAllow;
_stopSelfDrivingPollForTesting();
_resetHeartbeatStateForTesting();
});
it('an ok heartbeat that clears unknown_node backoff actually rearms (not no-op)', async () => {
// Round-7 had `if (_pollInflight || _selfDrivingPollTimer) return;`
// -- the second clause silently no-op'd every legitimate caller.
// Round-8 keeps only _pollInflight, so the call goes through.
_setHeartbeatStateForTesting({
running: true,
intervalMs: 30_000,
unknownNodeBackoffUntil: Date.now() + 60_000,
});
_startSelfDrivingPollForTesting();
// Confirm precondition: poll is enabled AND has a pending timer.
let internals = _getHeartbeatInternalsForTesting();
assert.equal(internals.selfDrivingPollEnabled, true, 'poll must be enabled');
assert.equal(internals.hasSelfDrivingPollTimer, true, 'poll must have a pending timer');
// Now drive an ok heartbeat that clears the deadline. The clear
// branch calls _armSelfDrivingPollFromHeartbeat. Pre-round-8 this
// would no-op because the timer existed; the assertion below would
// fail because the existing 2s startup timer would not have been
// replaced. Post-round-8, the call goes through and the timer is
// rescheduled to BASE_MS.
global.fetch = async () => ({
ok: true, status: 200,
json: async () => ({ status: 'ok' }),
text: async () => '',
});
await sendHeartbeat();
await settle();
internals = _getHeartbeatInternalsForTesting();
assert.equal(internals.unknownNodeBackoffUntil, 0, 'deadline must be cleared');
assert.equal(internals.hasSelfDrivingPollTimer, true, 'poll must still have a timer (rearmed, not killed)');
});
});
// ---------------------------------------------------------------------------
// §21.9: stopHeartbeat / _resetHeartbeatStateForTesting clear pending 429
// ---------------------------------------------------------------------------
describe('round-8 §21.9: _pendingSelfDrivingPollDelayMs does not leak across lifetimes', () => {
it('stopHeartbeat clears the override', () => {
_setPendingSelfDrivingPollDelayMsForTesting(45_000);
let before = _getHeartbeatInternalsForTesting();
assert.equal(before.pendingSelfDrivingPollDelayMs, 45_000, 'precondition: override set');
stopHeartbeat();
let after = _getHeartbeatInternalsForTesting();
assert.equal(after.pendingSelfDrivingPollDelayMs, 0, 'stopHeartbeat must clear the override');
});
it('_resetHeartbeatStateForTesting clears the override', () => {
_setPendingSelfDrivingPollDelayMsForTesting(30_000);
let before = _getHeartbeatInternalsForTesting();
assert.equal(before.pendingSelfDrivingPollDelayMs, 30_000, 'precondition: override set');
_resetHeartbeatStateForTesting();
let after = _getHeartbeatInternalsForTesting();
assert.equal(after.pendingSelfDrivingPollDelayMs, 0, '_resetHeartbeatStateForTesting must clear the override');
});
it('_pendingRescheduleDelayMs is also cleared on stop (regression test for the symmetric round-3 path)', () => {
_setPendingRescheduleDelayMsForTesting(20_000);
stopHeartbeat();
const after = _getHeartbeatInternalsForTesting();
assert.equal(after.pendingRescheduleDelayMs, 0, 'stopHeartbeat must clear the reschedule signal too');
});
});
// ---------------------------------------------------------------------------
// §21.10: wake branch re-stamps lastTickAt for watchdog usability
// ---------------------------------------------------------------------------
describe('round-8 §21.10: wake-recovery re-stamps lastTickAt', () => {
beforeEach(() => { _resetHeartbeatStateForTesting(); });
afterEach(() => { _resetHeartbeatStateForTesting(); });
it('the drift detector long-sleep branch contract: lastTickAt non-zero after wake recovery completes', () => {
// We cannot directly run the drift detector setInterval closure
// from a test, but we can prove the contract via _runWakeRecovery
// + the post-wake stamp by exercising the public surface: stamp
// lastTickAt to a known value, run _runWakeRecovery (which itself
// does NOT alter lastTickAt -- the new stamp is in the drift
// detector body around it), then observe that test-utility
// setters move lastTickAt as expected. The actual round-8 change
// adds `_heartbeatLastTickAt = now; _scheduleNextHeartbeat(2000);`
// INSIDE the drift detector body after _runWakeRecovery returns,
// protecting the hung-tick watchdog's truthy guard. Here we pin
// the contract that the test seam keeps lastTickAt observable
// through a wake-recovery cycle and that _runWakeRecovery itself
// does not zero it.
_setHeartbeatStateForTesting({
running: true,
intervalMs: 30_000,
lastTickAt: Date.now() - 5_000,
inFlight: false,
});
const before = _getHeartbeatInternalsForTesting();
a2a._runWakeRecovery();
const after = _getHeartbeatInternalsForTesting();
// _runWakeRecovery itself does not mutate lastTickAt; the drift
// detector body around it does. The contract here is that no
// recovery hook silently zeros it. The actual re-stamp in the
// drift branch lives in src/gep/a2aProtocol.js around line 2390.
assert.equal(after.lastTickAt, before.lastTickAt, '_runWakeRecovery itself must not zero lastTickAt');
});
});