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