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

242 lines
11 KiB
JavaScript

// Issue #528: Windows-friendly spawn-replacement policy.
//
// On Windows, child_process.spawn(detached: true, windowsHide: true) opens a
// new conhost window every restart, so the v1.78.x and v1.79.0 daemon
// suicide-respawn produced cmd popups whenever a Windows daemon hit
// EVOLVER_MAX_CYCLES (100) or EVOLVER_MAX_RSS_MB (500). v1.79.1 makes the
// in-process respawn opt-in on Windows and falls back to "exit non-zero +
// let the supervisor restart us".
const { describe, it, before, after } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const vm = require('node:vm');
// Keep this VM slice instead of require('..'): loading the full daemon entrypoint
// would run top-level bootstrapping and can start long-lived proxy/evolver side
// effects before these helper-only assertions execute.
function loadSpawnReplacementProcess() {
const indexPath = path.resolve(__dirname, '..', 'index.js');
const source = fs.readFileSync(indexPath, 'utf8');
const start = source.indexOf('function parseBoolEnv(');
const end = source.indexOf('\n// Atomic write of the cycle_progress.json file', start);
assert.ok(start > 0, 'expected parseBoolEnv declaration');
assert.ok(end > start, 'expected spawnReplacementProcess helper block');
const script = [
'const fs = require("fs");',
'const { spawn } = require("child_process");',
source.slice(start, end),
'module.exports = { spawnReplacementProcess };',
].join('\n');
const context = {
module: { exports: {} },
exports: {},
require,
process,
console,
__filename: indexPath,
};
vm.runInNewContext(script, context, { filename: indexPath });
return context.module.exports.spawnReplacementProcess;
}
const spawnReplacementProcess = loadSpawnReplacementProcess();
// Helper: temporarily override process.platform without leaking state.
function withPlatform(value, fn) {
const desc = Object.getOwnPropertyDescriptor(process, 'platform');
Object.defineProperty(process, 'platform', { value, configurable: true });
try {
return fn();
} finally {
Object.defineProperty(process, 'platform', desc);
}
}
function withEnv(key, value, fn) {
const had = Object.prototype.hasOwnProperty.call(process.env, key);
const prev = process.env[key];
if (value === undefined) delete process.env[key];
else process.env[key] = value;
try {
return fn();
} finally {
if (had) process.env[key] = prev;
else delete process.env[key];
}
}
describe('spawnReplacementProcess on Windows by default', () => {
it('returns windows_default_skip without invoking spawn', () => {
const result = withPlatform('win32', () =>
withEnv('EVOLVER_SUICIDE_WINDOWS', undefined, () =>
spawnReplacementProcess({
reason: 'unit-test',
args: ['--loop'],
logPath: '/no/such/log/should-not-be-touched.log',
})
)
);
assert.equal(result.spawned, false);
assert.equal(result.reason, 'windows_default_skip');
});
it('also skips when EVOLVER_SUICIDE_WINDOWS=false (explicit opt-out)', () => {
const result = withPlatform('win32', () =>
withEnv('EVOLVER_SUICIDE_WINDOWS', 'false', () =>
spawnReplacementProcess({
reason: 'unit-test',
args: ['--loop'],
logPath: '/no/such/log/should-not-be-touched.log',
})
)
);
assert.equal(result.spawned, false);
assert.equal(result.reason, 'windows_default_skip');
});
});
describe('spawnReplacementProcess on Windows with EVOLVER_SUICIDE_WINDOWS=true', () => {
it('crosses the gate and reaches the spawn try-block (escape hatch)', () => {
// We force fs.openSync to fail by passing a non-existent dir, which
// makes the helper return spawned:false reason:spawn_error WITHOUT
// actually starting a child process. That is enough to prove the
// env opt-in works -- if the gate were still rejecting we'd see
// windows_default_skip instead.
const result = withPlatform('win32', () =>
withEnv('EVOLVER_SUICIDE_WINDOWS', 'true', () =>
spawnReplacementProcess({
reason: 'unit-test',
args: ['--loop'],
logPath: '/definitely-not-a-real-dir-for-this-test/log.txt',
})
)
);
assert.equal(result.spawned, false);
assert.equal(result.reason, 'spawn_error');
assert.ok(result.error, 'should surface the spawn-side error');
});
});
describe('spawnReplacementProcess on non-Windows platforms', () => {
it('skips the Windows gate (fs.openSync becomes the only failure point here)', () => {
const result = withPlatform('linux', () =>
withEnv('EVOLVER_SUICIDE_WINDOWS', undefined, () =>
spawnReplacementProcess({
reason: 'unit-test',
args: ['--loop'],
logPath: '/definitely-not-a-real-dir-for-this-test/log.txt',
})
)
);
assert.equal(result.spawned, false);
assert.equal(result.reason, 'spawn_error',
'on Linux the gate must not reject; only the spawn side should fail');
});
it('also lets darwin through the gate', () => {
const result = withPlatform('darwin', () =>
withEnv('EVOLVER_SUICIDE_WINDOWS', undefined, () =>
spawnReplacementProcess({
reason: 'unit-test',
args: ['--loop'],
logPath: '/definitely-not-a-real-dir-for-this-test/log.txt',
})
)
);
assert.equal(result.spawned, false);
assert.equal(result.reason, 'spawn_error');
});
});
describe('index.js source-level guards (Issue #528 regression)', () => {
const indexPath = path.resolve(__dirname, '..', 'index.js');
const source = fs.readFileSync(indexPath, 'utf8');
it('no longer hand-rolls spawn(process.execPath, [__filename, ...args]) anywhere', () => {
// The only acceptable place for spawn(process.execPath, ...) is INSIDE
// spawnReplacementProcess(). We spot the helper's body by looking for
// the function declaration line, then assert no other occurrence of the
// raw spawn call exists outside it.
const helperStart = source.indexOf('function spawnReplacementProcess(');
assert.ok(helperStart > 0, 'expected spawnReplacementProcess function declaration');
const helperEnd = source.indexOf('\nfunction ', helperStart + 1);
assert.ok(helperEnd > helperStart, 'expected helper to be followed by another top-level function');
const before = source.slice(0, helperStart);
const after = source.slice(helperEnd);
const offending = (before + after).match(/spawn\(process\.execPath,\s*\[__filename/g);
assert.equal(offending, null,
'every detached respawn must go through spawnReplacementProcess; ' +
'found a raw spawn(process.execPath, [__filename, ...args]) outside the helper');
});
it('cycle hard-timeout branch goes through spawnReplacementProcess', () => {
assert.match(source,
/CYCLE_TIMEOUT[\s\S]*?spawnReplacementProcess\(\{[\s\S]*?reason: 'cycle_hard_timeout'/,
'CYCLE_TIMEOUT branch must call spawnReplacementProcess with reason cycle_hard_timeout');
});
it('cycles>=max / RSS branch goes through spawnReplacementProcess', () => {
assert.match(source,
/Restarting self[\s\S]*?spawnReplacementProcess\(\{[\s\S]*?reason: 'max_cycles_or_rss'/,
'max_cycles branch must call spawnReplacementProcess with reason max_cycles_or_rss');
});
it('windows_default_skip branch exits with code 1 (so supervisor respawns)', () => {
assert.match(source,
/result\.reason === 'windows_default_skip'[\s\S]*?process\.exit\(1\)/,
'when helper returns windows_default_skip the daemon must exit(1)');
});
it('helper documents EVOLVER_SUICIDE_WINDOWS escape hatch', () => {
assert.match(source, /EVOLVER_SUICIDE_WINDOWS/,
'env var name must be referenced in source for discoverability');
});
});
describe('Windows no-console process launch guards', () => {
it('idle scheduler runs PowerShell without shelling through a visible console host', () => {
const source = fs.readFileSync(path.resolve(__dirname, '..', 'src', 'gep', 'idleScheduler.js'), 'utf8');
assert.doesNotMatch(source, /execSync\('powershell\b/,
'Windows idle detection must not use execSync("powershell ..."), which shells through cmd.exe');
assert.match(source, /execFileSync\('powershell',\s*\[[\s\S]*?windowsHide:\s*true/,
'Windows idle detection must use execFileSync(..., windowsHide: true)');
});
it('ops lifecycle hides Windows process scans, daemon start, and taskkill', () => {
const source = fs.readFileSync(path.resolve(__dirname, '..', 'src', 'ops', 'lifecycle.js'), 'utf8');
assert.doesNotMatch(source, /execText\('powershell\b/,
'Windows process scans must not shell through cmd.exe');
assert.match(source, /function execFileText\(file, args\)[\s\S]*?execFileSync\(file, args,[\s\S]*?windowsHide:\s*true/,
'Windows process scans must use execFileSync(..., windowsHide: true)');
assert.match(source, /spawn\(process\.execPath,\s*\[script, '--loop'\],[\s\S]*?windowsHide:\s*true/,
'detached lifecycle start must hide any Windows child window');
assert.match(source, /execFileSync\('taskkill',\s*\['\/F', '\/PID', String\(remaining\[j\]\)\],[\s\S]*?windowsHide:\s*true/,
'Windows taskkill fallback must hide the child window');
});
it('session-start auto-restart does not spawn a visible Windows child', () => {
const source = fs.readFileSync(path.resolve(__dirname, '..', 'src', 'adapters', 'scripts', 'evolver-session-start.js'), 'utf8');
assert.match(source, /spawn\(\s*process\.execPath,\s*\[lifecyclePath, 'start'\],[\s\S]*?windowsHide:\s*true/,
'session-start daemon auto-restart must pass windowsHide: true');
});
it('adapter git probes hide child windows on Windows', () => {
const sessionEnd = fs.readFileSync(path.resolve(__dirname, '..', 'src', 'adapters', 'scripts', 'evolver-session-end.js'), 'utf8');
const runtimePaths = fs.readFileSync(path.resolve(__dirname, '..', 'src', 'adapters', 'scripts', '_runtimePaths.js'), 'utf8');
assert.match(sessionEnd, /spawnSync\('git', args,[\s\S]*?shell:\s*false,[\s\S]*?windowsHide:\s*true/);
assert.match(runtimePaths, /spawnSync\('git', \['rev-parse', '--is-inside-work-tree'\],[\s\S]*?shell:\s*false,[\s\S]*?windowsHide:\s*true/);
});
it('daemon loop git probes hide child windows on Windows', () => {
const indexSource = fs.readFileSync(path.resolve(__dirname, '..', 'index.js'), 'utf8');
const guardsSource = fs.readFileSync(path.resolve(__dirname, '..', 'src', 'evolve', 'guards.js'), 'utf8');
assert.match(indexSource, /execSync\('git --version'[\s\S]*?windowsHide:\s*true/);
assert.match(indexSource, /execSync\('git diff'[\s\S]*?windowsHide:\s*true/);
assert.match(indexSource, /execSync\('git ls-files --others --exclude-standard'[\s\S]*?windowsHide:\s*true/);
assert.match(guardsSource, /git log -1 --pretty=format:%ct%n%s'[\s\S]*?windowsHide:\s*true/);
assert.match(guardsSource, /execSync\('git rev-parse --git-dir'[\s\S]*?windowsHide:\s*true/);
});
});