Files
evomap__evolver/test/forceUpdateConcurrencyGuard.test.js
evolver-publish f0199b7362 Release v1.89.15
2026-06-22 09:41:37 +08:00

399 lines
17 KiB
JavaScript

// Tests for the concurrency guard added to src/forceUpdate.js (PR #188 fix).
//
// Background: two code paths can observe a pending force_update directive in
// the same scheduler tick:
// (1) heartbeat-thread: _maybeTriggerForceUpdateFromHeartbeat in
// src/gep/a2aProtocol.js, gated by the instance-level
// _forceUpdateInFlight flag.
// (2) evolve-tick: src/evolve/pipeline/enrich.js (Stage 5 enrich) which
// calls executeForceUpdate(forceUpdate) directly with NO guard.
//
// If path (1) drops the directive without consuming it (e.g. cooldown bail in
// the heartbeat handler) the next evolve tick on path (2) re-fires. Worse,
// if a prior heartbeat-thread upgrade is still in flight (slow degit/npm) AND
// the evolve tick reads consumeForceUpdate() before the heartbeat clears the
// pending slot, BOTH paths call executeForceUpdate concurrently. Both fire
// reportForceUpdateOutcome -> both write the same state file via atomic
// rename -> last writer wins, first attempt's telemetry is lost.
//
// The fix moves the mutex into src/forceUpdate.js itself as a module-level
// flag so it is shared by every caller of require('../forceUpdate'). The
// second concurrent invocation returns the FORCE_UPDATE_BUSY sentinel and
// callers treat it as a no-op (no state file write, no process.exit).
const { describe, it, before, after, beforeEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const os = require('os');
const path = require('path');
const childProcess = require('child_process');
const origExecFileSync = childProcess.execFileSync;
const forceUpdateModPath = require.resolve('../src/forceUpdate');
const pathsModPath = require.resolve('../src/gep/paths');
let installRoot;
function freshRequireForceUpdate(execFileStub) {
delete require.cache[forceUpdateModPath];
require.cache[pathsModPath] = {
id: pathsModPath, filename: pathsModPath, loaded: true,
exports: { getEvolverInstallRoot: () => installRoot },
};
childProcess.execFileSync = execFileStub;
const mod = require('../src/forceUpdate');
childProcess.execFileSync = origExecFileSync;
return mod;
}
function writeInstallPkg(version) {
fs.writeFileSync(
path.join(installRoot, 'package.json'),
JSON.stringify({ name: '@evomap/evolver', version }),
'utf8',
);
}
describe('executeForceUpdate: module-level concurrency guard', () => {
before(() => {
installRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'evolver-fu-conc-'));
});
after(() => {
childProcess.execFileSync = origExecFileSync;
delete require.cache[pathsModPath];
delete require.cache[forceUpdateModPath];
try { fs.rmSync(installRoot, { recursive: true, force: true }); } catch (_) {}
});
beforeEach(() => {
try { fs.rmSync(installRoot, { recursive: true, force: true }); } catch (_) {}
fs.mkdirSync(installRoot, { recursive: true });
});
it('exports FORCE_UPDATE_BUSY as a Symbol distinct from FORCE_UPDATE_NOOP and true/false', () => {
const stub = function () { throw new Error('must not invoke degit'); };
const mod = freshRequireForceUpdate(stub);
assert.equal(typeof mod.FORCE_UPDATE_BUSY, 'symbol',
'sentinel must be a Symbol so === checks cannot accidentally collide');
assert.notEqual(mod.FORCE_UPDATE_BUSY, mod.FORCE_UPDATE_NOOP,
'BUSY and NOOP must be distinct sentinels');
assert.notEqual(mod.FORCE_UPDATE_BUSY, true);
assert.notEqual(mod.FORCE_UPDATE_BUSY, false);
assert.notEqual(mod.FORCE_UPDATE_BUSY, null);
assert.notEqual(mod.FORCE_UPDATE_BUSY, undefined);
});
it('re-entrant call during a slow upgrade returns FORCE_UPDATE_BUSY without invoking degit again', () => {
// Force the upgrade path (not the no-op short-circuit).
writeInstallPkg('1.87.0');
// Capture the moment a re-entrant call returns.
let reentrantResult = null;
let reentrantExecCalls = 0;
let outerExecCalls = 0;
// Need a reference to the module before we can stub execFileSync to
// call back into executeForceUpdate. Use a forward-declared holder.
let mod;
const stub = function () {
outerExecCalls++;
// While we are "in the middle" of the first executeForceUpdate (the
// outer call), simulate the concurrent invocation by calling
// executeForceUpdate AGAIN from a different caller (the evolve tick).
// The module-level mutex MUST observe that _inFlight === true and
// return FORCE_UPDATE_BUSY without entering _executeForceUpdateInner
// (which would invoke execFileSync -- we'd see reentrantExecCalls > 0).
reentrantResult = mod.executeForceUpdate({ required_version: '1.88.0' });
// Now let the outer call's degit fail so the upgrade itself returns
// false (we only care about the mutex behavior here, not the upgrade).
throw new Error('simulated degit failure (outer)');
};
mod = freshRequireForceUpdate(stub);
// Track BOTH executions via the same stub: the second invocation, if it
// mistakenly bypassed the mutex, would land here a second time.
const trackingStub = function () {
// First outer call invokes the original stub. Inside that stub, the
// re-entrant call would also land here if the mutex were broken.
// We count separately to distinguish: outerExecCalls is bumped by the
// original stub above; reentrantExecCalls is bumped only if the
// re-entrant path bypassed the mutex.
reentrantExecCalls++;
throw new Error('simulated degit failure (reentrant)');
};
// Rewire the stub for the re-entrant call by swapping execFileSync just
// before re-entering. Simpler: install the trackingStub now and have the
// outer stub increment outerExecCalls itself.
childProcess.execFileSync = function () {
outerExecCalls++;
// First time only: do the re-entrant call.
if (outerExecCalls === 1) {
// Swap to the tracking stub so any re-entrant degit hits it.
const prev = childProcess.execFileSync;
childProcess.execFileSync = trackingStub;
try {
reentrantResult = mod.executeForceUpdate({ required_version: '1.88.0' });
} finally {
childProcess.execFileSync = prev;
}
}
throw new Error('simulated degit failure (outer)');
};
const outerResult = mod.executeForceUpdate({ required_version: '1.88.0' });
childProcess.execFileSync = origExecFileSync;
assert.equal(reentrantResult, mod.FORCE_UPDATE_BUSY,
're-entrant call during in-flight upgrade returns FORCE_UPDATE_BUSY');
assert.equal(reentrantExecCalls, 0,
're-entrant call MUST NOT reach degit (mutex short-circuit before _executeForceUpdateInner)');
assert.equal(outerResult.ok, false,
'outer call still returns its normal failure result (structured fail on degit failure)');
assert.equal(outerResult.code, 'fallback_download_incomplete',
'outer failure aggregates by the terminal fallback failure');
assert.match(outerResult.detail, /^primary_failed=degit_failed \| fallback_failed=download_incomplete:/,
'outer failure carries primary and fallback detail up front');
assert.ok(outerExecCalls >= 1,
'outer call did reach degit at least once');
});
it('mutex resets after the outer call returns: subsequent invocations proceed normally', () => {
writeInstallPkg('1.87.0');
let execCalls = 0;
const stub = function () {
execCalls++;
throw new Error('simulated degit failure');
};
const mod = freshRequireForceUpdate(stub);
// First call: enters _executeForceUpdateInner, fails at degit, then returns
// the structured primary failure (was a bare false before structured failures).
const r1 = mod.executeForceUpdate({ required_version: '1.88.0' });
assert.equal(r1.ok, false);
assert.equal(r1.code, 'fallback_download_incomplete',
'fallback terminal failure is the aggregation prefix');
assert.equal(execCalls, 2, 'first call reached degit and the tarball fallback downloader');
// Second call AFTER first completes: must NOT be blocked by a stuck mutex.
const r2 = mod.executeForceUpdate({ required_version: '1.88.0' });
assert.equal(r2.ok, false, 'mutex released; second call ran normally');
assert.equal(execCalls, 4, 'second call also reached degit and fallback (no stale BUSY)');
});
it('mutex resets even when _executeForceUpdateInner throws (finally semantics)', () => {
writeInstallPkg('1.87.0');
// Throw something exotic that bypasses the inner try/catch -- specifically,
// a synchronous throw from getEvolverInstallRoot itself. Easiest: pollute
// the install root so reading package.json throws AFTER mutex acquired
// but BEFORE returning. We force that by deleting the install dir.
try { fs.rmSync(installRoot, { recursive: true, force: true }); } catch (_) {}
const stub = function () { throw new Error('unreachable'); };
const mod = freshRequireForceUpdate(stub);
// First call: refuses (missing package.json), returns a structured
// { ok:false, code:'install_guard_unreadable' } (was a bare false).
const r1 = mod.executeForceUpdate({ required_version: '1.88.0' });
assert.equal(r1.ok, false, 'missing pkg returns a structured failure');
assert.equal(r1.code, 'install_guard_unreadable',
'missing/unreadable install package.json → install_guard_unreadable');
// Recreate install root for next call.
fs.mkdirSync(installRoot, { recursive: true });
writeInstallPkg('1.88.0');
// Second call (no-op, already at target) must proceed -- proves mutex
// was released by the finally.
const r2 = mod.executeForceUpdate({ required_version: '1.88.0' });
assert.equal(r2, mod.FORCE_UPDATE_NOOP,
'mutex released by finally; second call observed no-op short-circuit');
});
it('_resetInFlightForTesting clears the mutex (test hook hygiene)', () => {
writeInstallPkg('1.87.0');
// Force mutex into stuck state by invoking with a stub that DOES NOT
// throw synchronously -- normally finally would clear it, but we want to
// verify the test hook works.
const stub = function () { throw new Error('simulated'); };
const mod = freshRequireForceUpdate(stub);
// After a normal call, mutex is already clear; assert _resetInFlightForTesting
// is callable and idempotent.
mod.executeForceUpdate({ required_version: '1.88.0' });
mod._resetInFlightForTesting();
// After reset, a fresh call should still proceed.
const r = mod.executeForceUpdate({ required_version: '1.88.0' });
assert.equal(r.ok, false, 'reset hook works and is idempotent');
});
});
// ---------------------------------------------------------------------------
// Integration: the heartbeat-trigger path in a2aProtocol.js MUST defensively
// tolerate a BUSY return without writing a state file or calling exit(78).
// In production, _forceUpdateInFlight (the instance-level cooldown flag in
// a2aProtocol) already gates this — but the BUSY check is a belt-and-braces
// guard. This test exercises the path by stubbing executeForceUpdate to
// return FORCE_UPDATE_BUSY directly.
// ---------------------------------------------------------------------------
describe('a2aProtocol heartbeat-trigger: BUSY return short-circuits telemetry', () => {
const forceUpdatePath = require.resolve('../src/forceUpdate');
const a2aProtocolPath = require.resolve('../src/gep/a2aProtocol');
let tmpDir;
let evomapHomeDir;
let origHubUrl;
let origLogsDir;
let origEvolverHome;
let origInsecure;
let origFetch;
let origExit;
let execCalls;
let execReturn;
function loadProtocolWithSpy() {
delete require.cache[a2aProtocolPath];
const realFU = require('../src/forceUpdate');
require.cache[forceUpdatePath] = {
id: forceUpdatePath,
filename: forceUpdatePath,
loaded: true,
exports: {
executeForceUpdate: function (fu) {
execCalls.push(fu);
if (execReturn === '__BUSY__') return realFU.FORCE_UPDATE_BUSY;
if (execReturn === '__NOOP__') return realFU.FORCE_UPDATE_NOOP;
return execReturn;
},
FORCE_UPDATE_NOOP: realFU.FORCE_UPDATE_NOOP,
FORCE_UPDATE_BUSY: realFU.FORCE_UPDATE_BUSY,
},
};
return require('../src/gep/a2aProtocol');
}
before(() => {
if (!process.env.A2A_NODE_SECRET) process.env.A2A_NODE_SECRET = 'a'.repeat(64);
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'evolver-fu-busy-'));
evomapHomeDir = path.join(tmpDir, 'evomap-home');
fs.mkdirSync(evomapHomeDir, { recursive: true });
origHubUrl = process.env.A2A_HUB_URL;
origLogsDir = process.env.EVOLVER_LOGS_DIR;
origEvolverHome = process.env.EVOLVER_HOME;
origInsecure = process.env.EVOMAP_HUB_ALLOW_INSECURE;
process.env.A2A_HUB_URL = 'http://localhost:19997';
process.env.EVOLVER_LOGS_DIR = tmpDir;
process.env.EVOLVER_HOME = evomapHomeDir;
process.env.EVOMAP_HUB_ALLOW_INSECURE = '1';
origFetch = global.fetch;
origExit = process.exit;
});
after(() => {
global.fetch = origFetch;
process.exit = origExit;
if (origHubUrl === undefined) delete process.env.A2A_HUB_URL;
else process.env.A2A_HUB_URL = origHubUrl;
if (origLogsDir === undefined) delete process.env.EVOLVER_LOGS_DIR;
else process.env.EVOLVER_LOGS_DIR = origLogsDir;
if (origEvolverHome === undefined) delete process.env.EVOLVER_HOME;
else process.env.EVOLVER_HOME = origEvolverHome;
if (origInsecure === undefined) delete process.env.EVOMAP_HUB_ALLOW_INSECURE;
else process.env.EVOMAP_HUB_ALLOW_INSECURE = origInsecure;
delete require.cache[a2aProtocolPath];
delete require.cache[forceUpdatePath];
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {}
});
beforeEach(() => {
execCalls = [];
execReturn = '__BUSY__';
process.env.EVOLVER_FORCE_UPDATE_RETRY_COOLDOWN_MS = '0';
});
it('heartbeat trigger observing BUSY does NOT write state file and does NOT call exit(78)', async () => {
execReturn = '__BUSY__';
const a2a = loadProtocolWithSpy();
a2a.getNodeId();
const statePath = a2a._testing._getLastUpdateStatePathForTesting();
a2a._testing._resetLastUpdateStateForTesting();
a2a._testing._resetForceUpdateStateForTesting();
try { fs.unlinkSync(statePath); } catch (_) {}
const exitCalls = [];
process.exit = function (code) { exitCalls.push(code); };
global.fetch = async function () {
return {
ok: true, status: 200,
json: async () => ({
status: 'ok',
force_update: {
required_version: '>=1.88.0',
directive_id: 'd-busy-hb',
reason: 'test',
},
}),
text: async () => '',
};
};
await a2a.sendHeartbeat();
// Drain the Promise.resolve().then(...) microtask the trigger schedules.
await new Promise(r => setImmediate(r));
assert.equal(execCalls.length, 1, 'executeForceUpdate invoked exactly once');
assert.equal(exitCalls.length, 0,
'process.exit(78) MUST NOT be called when executeForceUpdate returns BUSY');
assert.ok(!fs.existsSync(statePath),
'no state file written for BUSY (in-flight caller owns telemetry; concurrent writes would lose data)');
});
it('heartbeat trigger: BUSY does not interfere with subsequent real success', async () => {
// First heartbeat: BUSY -> no telemetry written.
execReturn = '__BUSY__';
const a2a = loadProtocolWithSpy();
a2a.getNodeId();
const statePath = a2a._testing._getLastUpdateStatePathForTesting();
a2a._testing._resetLastUpdateStateForTesting();
a2a._testing._resetForceUpdateStateForTesting();
try { fs.unlinkSync(statePath); } catch (_) {}
const exitCalls = [];
process.exit = function (code) { exitCalls.push(code); };
global.fetch = async function () {
return {
ok: true, status: 200,
json: async () => ({
status: 'ok',
force_update: {
required_version: '>=1.88.0',
directive_id: 'd1',
reason: 'test',
},
}),
text: async () => '',
};
};
await a2a.sendHeartbeat();
await new Promise(r => setImmediate(r));
assert.ok(!fs.existsSync(statePath), 'BUSY: no state file');
assert.equal(exitCalls.length, 0, 'BUSY: no exit');
// Second heartbeat: real success -> state file + exit(78).
a2a._testing._resetForceUpdateStateForTesting();
execReturn = true;
await a2a.sendHeartbeat();
await new Promise(r => setImmediate(r));
assert.ok(fs.existsSync(statePath), 'real success: state file written');
const payload = JSON.parse(fs.readFileSync(statePath, 'utf8'));
assert.equal(payload.status, 'success');
assert.equal(exitCalls.length, 1, 'real success: exit called');
assert.equal(exitCalls[0], 78);
});
});