mirror of
https://github.com/EvoMap/evolver.git
synced 2026-09-18 21:47:53 +08:00
285 lines
14 KiB
JavaScript
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');
|
|
});
|
|
});
|