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();
|