// ============================================================ // Sidecar 本地冒烟(矩阵 99 · 本地可测部分) // 覆盖:20 次冷启动 / 端口冲突 / 崩溃重启(父进程守护)/ 中文+空格路径 // 只读调用 server/sidecar.py(python -m server.sidecar),不改后端逻辑。 // 任一检查失败 → 非零退出码。 // 用法:node scripts/smoke-sidecar-local.mjs // 可选:APS_SIDECAR_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'), 'APS 冒烟

中文路径 OK

', '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') ?? '') && / !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();