355 lines
13 KiB
JavaScript
355 lines
13 KiB
JavaScript
// ============================================================
|
||
// Sidecar 本地冒烟(矩阵 99 · 本地可测部分)
|
||
// 覆盖:20 次冷启动 / 端口冲突 / 崩溃重启(父进程守护)/ 中文+空格路径
|
||
// 只读调用 server/sidecar.py(python -m server.sidecar),不改后端逻辑。
|
||
// 任一检查失败 → 非零退出码。
|
||
// 用法:node scripts/smoke-sidecar-local.mjs
|
||
// 可选:APS_SIDECAR_PYTHON=<python> 指定解释器(默认 .sidecar-venv → .venv → PATH)
|
||
// ============================================================
|
||
import { spawn, spawnSync } from 'node:child_process';
|
||
import crypto from 'node:crypto';
|
||
import fs from 'node:fs';
|
||
import net from 'node:net';
|
||
import os from 'node:os';
|
||
import path from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||
const isWin = process.platform === 'win32';
|
||
const LOOPBACK_HOST = '127.0.0.1';
|
||
const NONCE_HEADER = 'x-aps-sidecar-nonce';
|
||
const HEALTH_URL = (port) => `http://${LOOPBACK_HOST}:${port}/api/health`;
|
||
|
||
const results = [];
|
||
const liveChildren = new Set();
|
||
|
||
// ---------------------------------------------------------------- helpers
|
||
|
||
function resolvePython() {
|
||
if (process.env.APS_SIDECAR_PYTHON) return process.env.APS_SIDECAR_PYTHON;
|
||
const candidates = [
|
||
path.join(repoRoot, '.sidecar-venv', 'Scripts', 'python.exe'),
|
||
path.join(repoRoot, '.venv', 'Scripts', 'python.exe'),
|
||
path.join(repoRoot, '.venv', 'bin', 'python3'),
|
||
];
|
||
for (const candidate of candidates) {
|
||
if (fs.existsSync(candidate)) return candidate;
|
||
}
|
||
return isWin ? 'python' : 'python3';
|
||
}
|
||
|
||
function reservePort() {
|
||
return new Promise((resolve, reject) => {
|
||
const server = net.createServer();
|
||
server.once('error', reject);
|
||
server.listen({ host: LOOPBACK_HOST, port: 0, exclusive: true }, () => {
|
||
const address = server.address();
|
||
const port = typeof address === 'object' && address ? address.port : 0;
|
||
server.close((error) => error ? reject(error) : resolve(port));
|
||
});
|
||
});
|
||
}
|
||
|
||
function delay(milliseconds) {
|
||
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||
}
|
||
|
||
function nonce() {
|
||
return crypto.randomBytes(32).toString('hex');
|
||
}
|
||
|
||
function probeHealth(port, expectedNonce, timeoutMs = 2_000) {
|
||
return new Promise((resolve) => {
|
||
const controller = new AbortController();
|
||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||
fetch(HEALTH_URL(port), {
|
||
headers: { [NONCE_HEADER]: expectedNonce },
|
||
signal: controller.signal,
|
||
})
|
||
.then(async (response) => {
|
||
const echoed = response.headers.get(NONCE_HEADER);
|
||
clearTimeout(timer);
|
||
resolve(response.status === 200 && echoed === expectedNonce);
|
||
})
|
||
.catch(() => {
|
||
clearTimeout(timer);
|
||
resolve(false);
|
||
});
|
||
});
|
||
}
|
||
|
||
async function waitForHealth(port, expectedNonce, timeoutMs = 60_000, child = null) {
|
||
const deadline = Date.now() + timeoutMs;
|
||
let last = null;
|
||
while (Date.now() < deadline) {
|
||
if (child && child.exitCode !== null) {
|
||
throw new Error(`sidecar exited early code=${child.exitCode}: ${child.stderrTail ?? ''}`);
|
||
}
|
||
last = await probeHealth(port, expectedNonce);
|
||
if (last) return true;
|
||
await delay(250);
|
||
}
|
||
throw new Error(`sidecar did not become healthy within ${timeoutMs}ms (last=${last})`);
|
||
}
|
||
|
||
function stopProcessTree(child) {
|
||
if (!child || !Number.isInteger(child.pid) || child.pid <= 0) return;
|
||
liveChildren.delete(child);
|
||
if (isWin) {
|
||
spawnSync('taskkill.exe', ['/pid', String(child.pid), '/T', '/F'], {
|
||
stdio: 'ignore',
|
||
windowsHide: true,
|
||
});
|
||
return;
|
||
}
|
||
try { child.kill('SIGKILL'); } catch { /* best effort */ }
|
||
}
|
||
|
||
async function waitExit(child, timeoutMs = 15_000) {
|
||
if (child.exitCode !== null) return child.exitCode;
|
||
return new Promise((resolve, reject) => {
|
||
const timer = setTimeout(() => reject(new Error('sidecar did not exit in time')), timeoutMs);
|
||
child.once('exit', (code) => {
|
||
clearTimeout(timer);
|
||
resolve(code ?? -1);
|
||
});
|
||
});
|
||
}
|
||
|
||
function sidecarEnv(overrides) {
|
||
return {
|
||
...process.env,
|
||
// 关键:显式隔离运行数据,绝不触碰仓库 server/data 或用户 ~/.aps
|
||
APS_MODE: 'desktop',
|
||
PYTHONNOUSERSITE: '1',
|
||
PYTHONUTF8: '1',
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
function spawnSidecar({ port, token, home, env = {}, cwd = repoRoot, python, capture = true }) {
|
||
const child = spawn(python, ['-m', 'server.sidecar'], {
|
||
cwd,
|
||
env: sidecarEnv({
|
||
APS_API_HOST: LOOPBACK_HOST,
|
||
APS_API_PORT: String(port),
|
||
APS_SIDECAR_NONCE: token,
|
||
APS_PARENT_PID: String(process.pid),
|
||
APS_HOME: home,
|
||
APS_DB_PATH: path.join(home, 'data', 'master.db'),
|
||
APS_KNOWLEDGE_PATH: path.join(home, 'knowledge'),
|
||
APS_APPROVAL_PATH: path.join(home, 'approval'),
|
||
APS_WORLD_PATH: path.join(home, 'data', 'world.json'),
|
||
APS_PROJECTS_PATH: path.join(home, 'sessions', 'workspace.json'),
|
||
APS_UI_DIR: '',
|
||
LLM_PROVIDER: '', // 离线:禁止任何 LLM 外呼
|
||
PYTHONPATH: repoRoot, // 支持 cwd 为任意目录(中文/空格路径用例)
|
||
...env,
|
||
}),
|
||
stdio: ['ignore', 'pipe', 'pipe'],
|
||
windowsHide: true,
|
||
});
|
||
liveChildren.add(child);
|
||
child.nonce = token;
|
||
let stderrTail = '';
|
||
if (capture) {
|
||
child.stderr.on('data', (chunk) => {
|
||
stderrTail = (stderrTail + String(chunk)).slice(-2000);
|
||
child.stderrTail = stderrTail;
|
||
});
|
||
}
|
||
return child;
|
||
}
|
||
|
||
function check(name, ok, detail) {
|
||
results.push({ name, ok: Boolean(ok), detail: detail ?? '' });
|
||
console.log(`[${ok ? 'PASS' : 'FAIL'}] ${name}${detail ? ` — ${detail}` : ''}`);
|
||
}
|
||
|
||
async function removeDir(dir) {
|
||
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ }
|
||
}
|
||
|
||
// ---------------------------------------------------------------- sections
|
||
|
||
async function sectionColdStarts(python, home) {
|
||
let passed = 0;
|
||
for (let index = 1; index <= 20; index += 1) {
|
||
const port = await reservePort();
|
||
const child = spawnSidecar({ port, token: nonce(), home, python });
|
||
try {
|
||
await waitForHealth(port, child.nonce, 60_000, child);
|
||
passed += 1;
|
||
console.log(` cold-start #${index} ok (port=${port} pid=${child.pid})`);
|
||
} catch (error) {
|
||
stopProcessTree(child);
|
||
check(`冷启动 #${index}/20`, false, String(error.message ?? error));
|
||
return;
|
||
} finally {
|
||
stopProcessTree(child);
|
||
await waitExit(child).catch(() => {});
|
||
}
|
||
}
|
||
check('20 次冷启动(进程起/停)', passed === 20, `${passed}/20 成功`);
|
||
}
|
||
|
||
async function sectionPortConflict(python, home) {
|
||
const port = await reservePort();
|
||
// 预占端口并保持监听,制造冲突
|
||
const blocker = net.createServer();
|
||
await new Promise((resolve, reject) => {
|
||
blocker.once('error', reject);
|
||
blocker.listen({ host: LOOPBACK_HOST, port, exclusive: true }, resolve);
|
||
});
|
||
const child = spawnSidecar({ port, token: nonce(), home, python });
|
||
try {
|
||
const code = await waitExit(child, 25_000);
|
||
const tail = (child.stderrTail ?? '').toLowerCase();
|
||
const explicitFailure = code !== 0
|
||
&& (tail.includes('10048') || tail.includes('address already in use') || tail.includes('eaddrinuse')
|
||
|| tail.includes('bind') || tail.includes('端口') || tail.includes('使用'));
|
||
check('端口冲突(预占后应换端口或显式失败)', explicitFailure,
|
||
`exit=${code}, stderr 尾部: ${(child.stderrTail ?? '').slice(0, 220)}`);
|
||
} catch (error) {
|
||
stopProcessTree(child);
|
||
check('端口冲突(预占后应换端口或显式失败)', false, `未在 25s 内显式失败:${error.message}`);
|
||
} finally {
|
||
blocker.close();
|
||
stopProcessTree(child);
|
||
}
|
||
}
|
||
|
||
async function sectionCrashRestart(python, home) {
|
||
// 崩溃重启:脚本扮演父进程/守护,kill 掉 sidecar 后以新端口+新 nonce 重启
|
||
const port1 = await reservePort();
|
||
const child1 = spawnSidecar({ port: port1, token: nonce(), home, python });
|
||
try {
|
||
await waitForHealth(port1, child1.nonce, 60_000, child1);
|
||
} catch (error) {
|
||
stopProcessTree(child1);
|
||
check('崩溃重启(父进程守护)', false, `首次启动失败:${error.message}`);
|
||
return;
|
||
}
|
||
const firstPid = child1.pid;
|
||
stopProcessTree(child1);
|
||
await waitExit(child1).catch(() => {});
|
||
const port2 = await reservePort();
|
||
const child2 = spawnSidecar({ port: port2, token: nonce(), home, python });
|
||
let restarted = false;
|
||
try {
|
||
await waitForHealth(port2, child2.nonce, 60_000, child2);
|
||
restarted = true;
|
||
} catch (error) {
|
||
check('崩溃重启(父进程守护)', false, `kill 后重启失败:${error.message}`);
|
||
stopProcessTree(child2);
|
||
return;
|
||
} finally {
|
||
stopProcessTree(child2);
|
||
}
|
||
check('崩溃重启(kill 子进程后父进程拉起新实例)', restarted,
|
||
`pid ${firstPid} → 新 pid ${child2.pid},端口 ${port1} → ${port2}`);
|
||
|
||
// 父进程看门狗:父进程退出后 sidecar 应自行退出(守护反向依赖)
|
||
const dummy = spawn(python, ['-c', 'import time; time.sleep(4)'], {
|
||
stdio: 'ignore',
|
||
windowsHide: true,
|
||
});
|
||
const port3 = await reservePort();
|
||
const child3 = spawnSidecar({
|
||
port: port3,
|
||
token: nonce(),
|
||
home,
|
||
python,
|
||
env: { APS_PARENT_PID: String(dummy.pid) },
|
||
});
|
||
try {
|
||
await waitForHealth(port3, child3.nonce, 60_000, child3);
|
||
const code = await waitExit(child3, 20_000);
|
||
check('父进程看门狗(父退出后 sidecar 自退出)', code === 0, `watchdog exit=${code}`);
|
||
} catch (error) {
|
||
stopProcessTree(child3);
|
||
check('父进程看门狗(父退出后 sidecar 自退出)', false, String(error.message ?? error));
|
||
} finally {
|
||
stopProcessTree(dummy);
|
||
stopProcessTree(child3);
|
||
}
|
||
}
|
||
|
||
async function sectionChinesePath(python, home) {
|
||
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'aps 排产 冒烟 测试-'));
|
||
const uiDir = path.join(base, 'ui 界面');
|
||
const dataDir = path.join(base, 'data 数据');
|
||
fs.mkdirSync(uiDir, { recursive: true });
|
||
fs.mkdirSync(dataDir, { recursive: true });
|
||
fs.writeFileSync(path.join(uiDir, 'index.html'), '<!doctype html><title>APS 冒烟</title><h1>中文路径 OK</h1>', 'utf8');
|
||
const port = await reservePort();
|
||
const token = nonce();
|
||
const child = spawnSidecar({
|
||
port,
|
||
token,
|
||
home,
|
||
python,
|
||
cwd: base, // 在含中文+空格的目录启动
|
||
env: {
|
||
APS_UI_DIR: uiDir,
|
||
APS_DB_PATH: path.join(dataDir, 'smoke.db'),
|
||
APS_WORLD_PATH: path.join(dataDir, 'world.json'),
|
||
APS_HOME: path.join(base, 'home'),
|
||
APS_PROJECTS_PATH: path.join(base, 'home', 'sessions', 'workspace.json'),
|
||
},
|
||
});
|
||
try {
|
||
await waitForHealth(port, token, 60_000, child);
|
||
const response = await fetch(`http://${LOOPBACK_HOST}:${port}/`, {
|
||
headers: { [NONCE_HEADER]: token },
|
||
signal: AbortSignal.timeout(8_000),
|
||
});
|
||
const body = await response.text();
|
||
const uiOk = response.status === 200
|
||
&& /text\/html/i.test(response.headers.get('content-type') ?? '')
|
||
&& /<html|<!doctype/i.test(body);
|
||
// APS_HOME 中文/空格路径被 ensure_aps_home 实际创建(config.json 落盘)
|
||
const homeOk = fs.existsSync(path.join(base, 'home', 'config.json'))
|
||
&& fs.existsSync(path.join(base, 'home', 'data'));
|
||
const noTraceback = !(child.stderrTail ?? '').includes('Traceback');
|
||
check('中文/空格路径(cwd + APS_HOME + APS_UI_DIR 全中文空格目录)',
|
||
uiOk && homeOk && noTraceback,
|
||
`ui=${uiOk} home=${homeOk} traceback=${!noTraceback} cwd=${base}(sqlite 懒创建,健康/UI 探针不触发)`);
|
||
} catch (error) {
|
||
check('中文/空格路径(cwd + APS_HOME + APS_UI_DIR 全中文空格目录)', false, String(error.message ?? error));
|
||
} finally {
|
||
stopProcessTree(child);
|
||
await delay(500);
|
||
await removeDir(base);
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- main
|
||
|
||
async function main() {
|
||
const python = resolvePython();
|
||
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'aps-sidecar-smoke-'));
|
||
const startedAt = Date.now();
|
||
console.log(`sidecar smoke: python=${python} platform=${process.platform} home=${home}`);
|
||
|
||
try {
|
||
await sectionColdStarts(python, home);
|
||
await sectionPortConflict(python, home);
|
||
await sectionCrashRestart(python, home);
|
||
await sectionChinesePath(python, home);
|
||
} finally {
|
||
for (const child of [...liveChildren]) stopProcessTree(child);
|
||
await delay(500);
|
||
await removeDir(home);
|
||
}
|
||
|
||
const failed = results.filter((r) => !r.ok);
|
||
console.log(`\nsidecar smoke summary: ${results.length - failed.length}/${results.length} passed in ${((Date.now() - startedAt) / 1000).toFixed(1)}s`);
|
||
for (const r of results) console.log(` [${r.ok ? 'PASS' : 'FAIL'}] ${r.name}`);
|
||
if (failed.length > 0) {
|
||
console.error(`sidecar smoke FAILED: ${failed.map((f) => f.name).join('; ')}`);
|
||
process.exitCode = 1;
|
||
}
|
||
}
|
||
|
||
await main(); |