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