| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406 |
- #!/usr/bin/env node
- /**
- * 容器并发压力测试(多会话)
- *
- * 用法:
- * node scripts/loadtest-agent.mjs --agent agent-node-kangshifu --n 10
- * node scripts/loadtest-agent.mjs --agent agent-node-kangshifu --n 10 --timeout 600
- *
- * ## 为什么需要它
- *
- * 容器自己造不出 N 个独立会话 —— 它只有自己的 agent 身份,没有用户侧身份
- * (2026-11-14 kangshifu 实测:4 条路全不通)。
- *
- * 本脚本从**外部**用 `RP5Ht55NeX` 给用户签身份、建 N 个独立房间,
- * 再**同时**发消息,制造真实并发。
- *
- * ## 测什么
- *
- * 1. 完整性:N 条里几条真的回了
- * 2. 串台:每个房间的回复里有没有混进别的房间的 token
- * 3. 端到端耗时:发出 → 收到 ✅ 用了多久
- * 4. 文件产出:回复里出现的 s3 链接
- *
- * 🔴 **每个房间的指令带唯一 token**,回复里必须出现**自己那条**的 token。
- * 出现别人的 token = 串台,这是「一房间一会话」的核心验证。
- */
- import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
- import { homedir } from 'node:os';
- import { join, dirname } from 'node:path';
- const FUNCTIONS = 'https://server.fmode.cn/api/functions';
- const FN_PROVISION = 'RP5Ht55NeX';
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
- function arg(name, fallback) {
- const i = process.argv.indexOf(`--${name}`);
- return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : fallback;
- }
- const AGENT = arg('agent', 'agent-node-kangshifu');
- const N = Number(arg('n', '10'));
- /** 单条等待上限(秒)。容器并发时可能排队,给足 */
- const TIMEOUT_S = Number(arg('timeout', '600'));
- /** 每轮轮询间隔(毫秒) */
- const POLL_MS = Number(arg('poll', '3000'));
- const OUT = arg('out', `docs/03-interface/loadtest-${AGENT}-n${N}.md`);
- function loadToken() {
- /* 允许用 --token 或 LT_TOKEN 覆盖,方便用测试账号(如 ksftest01)跑压测 */
- const override = arg('token', process.env.LT_TOKEN);
- if (override) {
- if (!override.startsWith('r:')) throw new Error('--token 必须以 r: 开头');
- return { token: override, src: '--token/环境变量' };
- }
- const p = join(homedir(), '.fmode', 'config', 'user.json');
- const t = JSON.parse(readFileSync(p, 'utf8'))?.sessionToken;
- if (typeof t !== 'string' || !t.startsWith('r:')) throw new Error(`读不到会话凭据:${p}`);
- return { token: t, src: p };
- }
- const { token: TOKEN, src: TOKEN_SRC } = loadToken();
- async function provision(agentId) {
- const r = await fetch(FUNCTIONS, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json', 'X-Parse-Application-Id': 'ncloudmaster' },
- body: JSON.stringify({ id: FN_PROVISION, token: TOKEN, params: { agentId } }),
- });
- const j = await r.json();
- const d = j.result ?? j;
- if (!d?.accessToken) throw new Error(`provision 失败:${JSON.stringify(j).slice(0, 300)}`);
- return d;
- }
- /** 生成一个可辨识的 token(8 位) */
- function mkToken(i) {
- return `T${i}-${Math.random().toString(36).slice(2, 8).toUpperCase()}`;
- }
- async function main() {
- console.log(`\n=== 容器并发压测 ===`);
- console.log(`容器 : ${AGENT}`);
- console.log(`并发数: ${N}`);
- console.log(`凭据 : ${TOKEN_SRC}`);
- console.log(`超时 : ${TIMEOUT_S}s\n`);
- const prov = await provision(AGENT);
- const agentInfo = await provision(AGENT);
- console.log(`用户: ${prov.userMxId} | 容器: ${agentInfo.agentMxId}\n`);
- const mx = await import('matrix-js-sdk');
- const client = mx.createClient({
- baseUrl: prov.homeserver,
- accessToken: prov.accessToken,
- userId: prov.userMxId,
- });
- const hs = prov.homeserver.replace(/\/$/, '');
- const auth = { Authorization: `Bearer ${prov.accessToken}` };
- // ── 1. 建 N 个独立房间 ──────────────────────────────────────────────
- console.log('=== [1] 建房间 ===');
- const peers = [];
- for (let i = 1; i <= N; i++) {
- const room = (
- await client.createRoom({
- invite: [agentInfo.agentMxId],
- name: `loadtest-n${N}-${i}-${Date.now()}`,
- preset: mx.Preset.TrustedPrivateChat,
- })
- ).room_id;
- peers.push({ i, room, token: mkToken(i), sentId: null, ackedAt: null, events: [], joined: false });
- console.log(` [${i}] ${room}`);
- }
- // ── 2. 等容器进房(并发等待,最多 120 秒)───────────────────────────
- console.log('\n=== [2] 等容器进房 ===');
- const joinDeadline = Date.now() + 120_000;
- while (Date.now() < joinDeadline) {
- const pend = peers.filter((p) => !p.joined);
- if (!pend.length) break;
- await Promise.all(
- pend.map(async (p) => {
- const r = await fetch(
- `${hs}/_matrix/client/v3/rooms/${encodeURIComponent(p.room)}/joined_members`,
- { headers: auth },
- ).catch(() => null);
- const j = r ? await r.json().catch(() => ({})) : {};
- if ((j.joined ?? {})[agentInfo.agentMxId]) p.joined = true;
- }),
- );
- await sleep(2000);
- }
- const joinedCount = peers.filter((p) => p.joined).length;
- console.log(` 已进房: ${joinedCount}/${N}`);
- if (joinedCount === 0) throw new Error('容器一个房间都没进,压测无法进行');
- // ── 3. 同时发消息 ────────────────────────────────────────────────────
- console.log('\n=== [3] 并发发送 ===');
- const t0 = Date.now();
- /**
- * 🔴 指令必须逼它走**真实交付链路**(2026-11-14 修正)。
- *
- * 上一版的坑:只说「写文件并上传」,容器用 `uploader.mjs put` 直传,
- * 链接是 `https://s3.fmode.cn/user/<uid>/report/...` —— **不是**
- * `/deliverables/<agentId>/...`,App 侧 `isAgentOutputUrl()` 不认,
- * 等于测了个假链路。
- *
- * 现在明确要求:写 8KB 内容(足够撑满一次真实上传)+ 回复时用 `MEDIA:` 行,
- * 走 `plugin-deliverable-inject` → 出站媒体 → `/deliverables/` 前缀。
- */
- const payloadFor = (i, token) => {
- /* 约 8KB 的可辨识内容:既压上传,又能验证文件内容没错乱 */
- const lines = [];
- for (let k = 0; k < 120; k++) {
- lines.push(`L${String(k).padStart(3, '0')} n=${i} token=${token} payload=${'x'.repeat(40)}`);
- }
- return { head: `LOADTEST n=${i} token=${token}`, body: lines.join('\n') };
- };
- await Promise.all(
- peers.map(async (p) => {
- if (!p.joined) return;
- const { head, body } = payloadFor(p.i, p.token);
- p.expectBytes = body.length;
- const body400 = [
- `压测任务 ${p.i}。严格按下面做,不要做别的事,不要解释:`,
- ``,
- `1. 用 shell 创建文件 /opt/data/work/loadtest-n${N}/msg-${p.i}.md,内容严格为下面 ${body.split('\n').length} 行(每行都要有):`,
- ``,
- '```',
- body,
- '```',
- ``,
- `2. 确认第一行是:${head}`,
- `3. 在回复正文里**单独一行**写:MEDIA:/opt/data/work/loadtest-n${N}/msg-${p.i}.md`,
- `4. 回复里必须原样出现 token:${p.token}`,
- ].join('\n');
- p.promptBytes = body400.length;
- const sent = await client.sendEvent(p.room, 'm.room.message', {
- msgtype: 'm.text',
- body: body400,
- 'm.mentions': { user_ids: [agentInfo.agentMxId] },
- });
- p.sentId = sent.event_id;
- p.sentAt = Date.now();
- }),
- );
- console.log(` ${peers.filter((p) => p.sentId).length} 条已发出(每条含约 8KB 写入负载),等待回复…\n`);
- // ── 4. 轮询收集 ──────────────────────────────────────────────────────
- const deadline = t0 + TIMEOUT_S * 1000;
- let lastReport = 0;
- while (Date.now() < deadline) {
- await sleep(POLL_MS);
- await Promise.all(
- peers.map(async (p) => {
- if (!p.sentId || p.ackedAt) return;
- const r = await fetch(
- `${hs}/_matrix/client/v3/rooms/${encodeURIComponent(p.room)}/messages?dir=b&limit=60`,
- { headers: auth },
- ).catch(() => null);
- if (!r) return;
- const j = await r.json().catch(() => ({}));
- const seen = new Set(p.events.map((e) => e.id));
- for (const ev of (j.chunk ?? []).reverse()) {
- if (seen.has(ev.event_id)) continue;
- if (ev.type === 'm.reaction') {
- const rel = ev.content?.['m.relates_to'];
- if (rel?.event_id === p.sentId && String(rel.key ?? '').includes('✅')) {
- p.ackedAt = Date.now();
- }
- p.events.push({ id: ev.event_id, type: 'reaction', body: '' });
- continue;
- }
- if (ev.type !== 'm.room.message' || ev.sender === prov.userMxId) continue;
- p.events.push({
- id: ev.event_id,
- type: ev.content?.msgtype || 'm.text',
- body: String(ev.content?.body ?? ''),
- file: ev.content?.file,
- });
- }
- }),
- );
- const done = peers.filter((p) => p.ackedAt).length;
- if (Date.now() - lastReport > 15000) {
- lastReport = Date.now();
- const elapsed = Math.round((Date.now() - t0) / 1000);
- console.log(` [${elapsed}s] 完成 ${done}/${peers.filter((p) => p.joined).length}`);
- }
- if (done === peers.filter((p) => p.joined).length) break;
- }
- // ── 5. 结果分析 ──────────────────────────────────────────────────────
- const bound = peers.filter((p) => p.joined);
- const rows = bound.map((p) => {
- const bodies = p.events.filter((e) => e.body).map((e) => e.body).join('\n');
- /* 串台检测:这个房间的回复里出现了**别的房间**的 token */
- const others = bound.filter((q) => q !== p).map((q) => q.token);
- const leakedIn = others.filter((t) => bodies.includes(t));
- const hasOwn = bodies.includes(p.token);
- const urls = [...bodies.matchAll(/https:\/\/s3\.fmode\.cn\/[^\s)`]+/g)].map((m) => m[0]);
- const files = p.events.filter((e) => e.file).map((e) => e.file?.url).filter(Boolean);
- const allUrls = [...new Set([...urls, ...files])];
- /*
- * 🔴 链接前缀判定(2026-11-14 新增)。
- *
- * App 侧 `s3-link.ts:290` 要求 `/deliverables/` 前缀,否则不认成果。
- * 上一版压测容器走 `uploader.mjs put`,给出 `/user/<uid>/report/...`,
- * 测出来"成功"但 App 根本看不到 —— 这是假测试。
- */
- const deliverableUrls = allUrls.filter((u) => u.includes('/deliverables/'));
- const wrongPrefixUrls = allUrls.filter((u) => !u.includes('/deliverables/'));
- return {
- i: p.i,
- room: p.room,
- token: p.token,
- ok: !!p.ackedAt,
- ms: p.ackedAt ? p.ackedAt - p.sentAt : null,
- hasOwn,
- leakedIn,
- urls: allUrls,
- deliverableUrls,
- wrongPrefixUrls,
- replyLen: bodies.length,
- expectBytes: p.expectBytes || 0,
- };
- });
- // ── 5b. S3 三重验证(并发 HEAD,最多 30 秒)─────────────────────────
- console.log('\n=== [4b] 验证 s3 链接 ──');
- await Promise.all(
- rows.map(async (r) => {
- r.urlChecks = await Promise.all(
- r.urls.map(async (u) => {
- try {
- const ac = new AbortController();
- const to = setTimeout(() => ac.abort(), 15000);
- const resp = await fetch(u, { method: 'GET', signal: ac.signal });
- clearTimeout(to);
- const text = resp.ok ? await resp.text() : '';
- return {
- url: u,
- status: resp.status,
- ok: resp.ok,
- bytes: text.length,
- /* 内容里有没有本房的 token —— 验证「文件内容没错乱/没串台」 */
- hasOwnToken: text.includes(r.token),
- hasOtherToken: rows.some((q) => q.i !== r.i && text.includes(q.token)),
- };
- } catch (e) {
- return { url: u, status: 0, ok: false, error: String(e?.message || e) };
- }
- }),
- );
- r.contentOk = r.urlChecks.some((c) => c.ok && c.hasOwnToken && !c.hasOtherToken);
- r.sizeExpectOk = r.urlChecks.some((c) => c.ok && c.bytes >= r.expectBytes * 0.9);
- }),
- );
- const doneN = rows.filter((r) => r.ok).length;
- const crossTalk = rows.filter((r) => r.leakedIn.length > 0);
- const noOwn = rows.filter((r) => r.ok && !r.hasOwn);
- const withDeliverable = rows.filter((r) => r.deliverableUrls.length > 0);
- const wrongPrefix = rows.filter((r) => r.urls.length > 0 && r.deliverableUrls.length === 0);
- const contentVerified = rows.filter((r) => r.contentOk);
- console.log('\n=== [5] 结果 ===');
- console.log(`完整性 : ${doneN}/${bound.length}`);
- console.log(`串台(消息): ${crossTalk.length} 个房间收到别人的 token`);
- console.log(`有回复但无本房 token : ${noOwn.length}`);
- console.log(`🔗 /deliverables/ 前缀 : ${withDeliverable.length}/${rows.filter((r) => r.urls.length).length}`);
- console.log(`⚠️ 链接前缀错误 : ${wrongPrefix.length}`);
- console.log(`📄 内容验证通过 : ${contentVerified.length}/${rows.filter((r) => r.urls.length).length}`);
- const times = rows.filter((r) => r.ms).map((r) => r.ms);
- if (times.length) {
- times.sort((a, b) => a - b);
- const p = (q) => times[Math.min(times.length - 1, Math.floor(times.length * q))];
- console.log(`耗时(秒) : min=${(times[0] / 1000).toFixed(1)} p50=${(p(0.5) / 1000).toFixed(1)} p95=${(p(0.95) / 1000).toFixed(1)} max=${(times[times.length - 1] / 1000).toFixed(1)}`);
- }
- for (const r of rows) {
- const chk = r.urlChecks?.[0];
- const flags = [
- r.ok ? `✅ ${(r.ms / 1000).toFixed(1)}s` : '❌ 超时无 ✅',
- r.hasOwn ? '' : '⚠️回复无本房token',
- r.leakedIn.length ? `🔴串台:${r.leakedIn.join(',')}` : '',
- r.deliverableUrls.length ? `🔗deliverables` : r.urls.length ? '⚠️前缀非deliverables' : '❌无链接',
- chk ? (chk.ok ? `S3 ${chk.status}/${chk.bytes}B` : `S3 ${chk.status}`) : 'S3 未测',
- r.contentOk ? '内容✓' : r.urls.length ? '内容✗' : '',
- ]
- .filter(Boolean)
- .join(' ');
- console.log(` [${String(r.i).padStart(2)}] ${flags}`);
- }
- // ── 6. 落盘 ──────────────────────────────────────────────────────────
- const lines = [
- `# 压测结果 — ${AGENT} — 并发 ${N}`,
- ``,
- `- 时间:${new Date().toISOString()}`,
- `- 用户:${prov.userMxId}`,
- `- 容器:${agentInfo.agentMxId}`,
- `- 超时:${TIMEOUT_S}s`,
- ``,
- `## 汇总`,
- ``,
- `| 指标 | 值 |`,
- `|---|---|`,
- `| 建房间 | ${N} |`,
- `| 容器进房 | ${bound.length} |`,
- `| 发出 | ${peers.filter((p) => p.sentId).length} |`,
- `| **完成** | **${doneN}** |`,
- `| 串台房间数 | ${crossTalk.length} |`,
- `| 有回复但无本房 token | ${noOwn.length} |`,
- `| **/deliverables/ 前缀正确** | **${withDeliverable.length}** |`,
- `| 链接前缀错误(App 不认) | ${wrongPrefix.length} |`,
- `| **S3 内容验证通过** | **${contentVerified.length}** |`,
- times.length ? `| 耗时 p50 | ${(times[Math.floor(times.length * 0.5)] / 1000).toFixed(1)}s |` : '',
- times.length ? `| 耗时 max | ${(times[times.length - 1] / 1000).toFixed(1)}s |` : '',
- ``,
- `## 逐房间`,
- ``,
- `| # | 房间 | token | 完成 | 耗时 | 本房token | 串台 | deliverables | S3 | 内容 |`,
- `|---|---|---|---|---|---|---|---|---|---|`,
- ...rows.map(
- (r) =>
- `| ${r.i} | \`${r.room}\` | ${r.token} | ${r.ok ? '✅' : '❌'} | ${r.ms ? (r.ms / 1000).toFixed(1) + 's' : '—'} | ${r.hasOwn ? '✅' : '❌'} | ${r.leakedIn.length ? '🔴 ' + r.leakedIn.join(',') : '—'} | ${r.deliverableUrls.length ? '✅' : r.urls.length ? '⚠️' : '❌'} | ${r.urlChecks?.[0]?.ok ? `${r.urlChecks[0].status}/${r.urlChecks[0].bytes}B` : '—'} | ${r.contentOk ? '✅' : '❌'} |`,
- ),
- ``,
- `## 串台详情`,
- ``,
- crossTalk.length
- ? crossTalk.map((r) => `- 房间 ${r.i} 收到了:${r.leakedIn.join(', ')}`).join('\n')
- : '无串台。',
- ``,
- `## 链接前缀问题`,
- ``,
- wrongPrefix.length
- ? wrongPrefix
- .map(
- (r) => `- 房间 ${r.i}:\n${r.urls.map((u) => ` - \`${u}\``).join('\n')}\n (不是 \`/deliverables/\`,App 侧 \`isAgentOutputUrl()\` 不认)`,
- )
- .join('\n')
- : '全部链接都是 `/deliverables/` 前缀。',
- ``,
- `## 失败明细`,
- ``,
- rows.filter((r) => !r.ok).length
- ? rows.filter((r) => !r.ok).map((r) => `- 房间 ${r.i}(${r.room}):${r.replyLen} 字符回复,未收到 ✅`).join('\n')
- : '无失败。',
- ``,
- ];
- mkdirSync(dirname(OUT), { recursive: true });
- writeFileSync(OUT, lines.join('\n'));
- console.log(`\n结果已写入 ${OUT}`);
- process.exit(doneN === bound.length && crossTalk.length === 0 ? 0 : 1);
- }
- main().catch((e) => {
- console.error('\n❌ 压测失败:', e instanceof Error ? e.message : e);
- process.exit(2);
- });
|