loadtest-agent.mjs 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406
  1. #!/usr/bin/env node
  2. /**
  3. * 容器并发压力测试(多会话)
  4. *
  5. * 用法:
  6. * node scripts/loadtest-agent.mjs --agent agent-node-kangshifu --n 10
  7. * node scripts/loadtest-agent.mjs --agent agent-node-kangshifu --n 10 --timeout 600
  8. *
  9. * ## 为什么需要它
  10. *
  11. * 容器自己造不出 N 个独立会话 —— 它只有自己的 agent 身份,没有用户侧身份
  12. * (2026-11-14 kangshifu 实测:4 条路全不通)。
  13. *
  14. * 本脚本从**外部**用 `RP5Ht55NeX` 给用户签身份、建 N 个独立房间,
  15. * 再**同时**发消息,制造真实并发。
  16. *
  17. * ## 测什么
  18. *
  19. * 1. 完整性:N 条里几条真的回了
  20. * 2. 串台:每个房间的回复里有没有混进别的房间的 token
  21. * 3. 端到端耗时:发出 → 收到 ✅ 用了多久
  22. * 4. 文件产出:回复里出现的 s3 链接
  23. *
  24. * 🔴 **每个房间的指令带唯一 token**,回复里必须出现**自己那条**的 token。
  25. * 出现别人的 token = 串台,这是「一房间一会话」的核心验证。
  26. */
  27. import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
  28. import { homedir } from 'node:os';
  29. import { join, dirname } from 'node:path';
  30. const FUNCTIONS = 'https://server.fmode.cn/api/functions';
  31. const FN_PROVISION = 'RP5Ht55NeX';
  32. const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
  33. function arg(name, fallback) {
  34. const i = process.argv.indexOf(`--${name}`);
  35. return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : fallback;
  36. }
  37. const AGENT = arg('agent', 'agent-node-kangshifu');
  38. const N = Number(arg('n', '10'));
  39. /** 单条等待上限(秒)。容器并发时可能排队,给足 */
  40. const TIMEOUT_S = Number(arg('timeout', '600'));
  41. /** 每轮轮询间隔(毫秒) */
  42. const POLL_MS = Number(arg('poll', '3000'));
  43. const OUT = arg('out', `docs/03-interface/loadtest-${AGENT}-n${N}.md`);
  44. function loadToken() {
  45. /* 允许用 --token 或 LT_TOKEN 覆盖,方便用测试账号(如 ksftest01)跑压测 */
  46. const override = arg('token', process.env.LT_TOKEN);
  47. if (override) {
  48. if (!override.startsWith('r:')) throw new Error('--token 必须以 r: 开头');
  49. return { token: override, src: '--token/环境变量' };
  50. }
  51. const p = join(homedir(), '.fmode', 'config', 'user.json');
  52. const t = JSON.parse(readFileSync(p, 'utf8'))?.sessionToken;
  53. if (typeof t !== 'string' || !t.startsWith('r:')) throw new Error(`读不到会话凭据:${p}`);
  54. return { token: t, src: p };
  55. }
  56. const { token: TOKEN, src: TOKEN_SRC } = loadToken();
  57. async function provision(agentId) {
  58. const r = await fetch(FUNCTIONS, {
  59. method: 'POST',
  60. headers: { 'Content-Type': 'application/json', 'X-Parse-Application-Id': 'ncloudmaster' },
  61. body: JSON.stringify({ id: FN_PROVISION, token: TOKEN, params: { agentId } }),
  62. });
  63. const j = await r.json();
  64. const d = j.result ?? j;
  65. if (!d?.accessToken) throw new Error(`provision 失败:${JSON.stringify(j).slice(0, 300)}`);
  66. return d;
  67. }
  68. /** 生成一个可辨识的 token(8 位) */
  69. function mkToken(i) {
  70. return `T${i}-${Math.random().toString(36).slice(2, 8).toUpperCase()}`;
  71. }
  72. async function main() {
  73. console.log(`\n=== 容器并发压测 ===`);
  74. console.log(`容器 : ${AGENT}`);
  75. console.log(`并发数: ${N}`);
  76. console.log(`凭据 : ${TOKEN_SRC}`);
  77. console.log(`超时 : ${TIMEOUT_S}s\n`);
  78. const prov = await provision(AGENT);
  79. const agentInfo = await provision(AGENT);
  80. console.log(`用户: ${prov.userMxId} | 容器: ${agentInfo.agentMxId}\n`);
  81. const mx = await import('matrix-js-sdk');
  82. const client = mx.createClient({
  83. baseUrl: prov.homeserver,
  84. accessToken: prov.accessToken,
  85. userId: prov.userMxId,
  86. });
  87. const hs = prov.homeserver.replace(/\/$/, '');
  88. const auth = { Authorization: `Bearer ${prov.accessToken}` };
  89. // ── 1. 建 N 个独立房间 ──────────────────────────────────────────────
  90. console.log('=== [1] 建房间 ===');
  91. const peers = [];
  92. for (let i = 1; i <= N; i++) {
  93. const room = (
  94. await client.createRoom({
  95. invite: [agentInfo.agentMxId],
  96. name: `loadtest-n${N}-${i}-${Date.now()}`,
  97. preset: mx.Preset.TrustedPrivateChat,
  98. })
  99. ).room_id;
  100. peers.push({ i, room, token: mkToken(i), sentId: null, ackedAt: null, events: [], joined: false });
  101. console.log(` [${i}] ${room}`);
  102. }
  103. // ── 2. 等容器进房(并发等待,最多 120 秒)───────────────────────────
  104. console.log('\n=== [2] 等容器进房 ===');
  105. const joinDeadline = Date.now() + 120_000;
  106. while (Date.now() < joinDeadline) {
  107. const pend = peers.filter((p) => !p.joined);
  108. if (!pend.length) break;
  109. await Promise.all(
  110. pend.map(async (p) => {
  111. const r = await fetch(
  112. `${hs}/_matrix/client/v3/rooms/${encodeURIComponent(p.room)}/joined_members`,
  113. { headers: auth },
  114. ).catch(() => null);
  115. const j = r ? await r.json().catch(() => ({})) : {};
  116. if ((j.joined ?? {})[agentInfo.agentMxId]) p.joined = true;
  117. }),
  118. );
  119. await sleep(2000);
  120. }
  121. const joinedCount = peers.filter((p) => p.joined).length;
  122. console.log(` 已进房: ${joinedCount}/${N}`);
  123. if (joinedCount === 0) throw new Error('容器一个房间都没进,压测无法进行');
  124. // ── 3. 同时发消息 ────────────────────────────────────────────────────
  125. console.log('\n=== [3] 并发发送 ===');
  126. const t0 = Date.now();
  127. /**
  128. * 🔴 指令必须逼它走**真实交付链路**(2026-11-14 修正)。
  129. *
  130. * 上一版的坑:只说「写文件并上传」,容器用 `uploader.mjs put` 直传,
  131. * 链接是 `https://s3.fmode.cn/user/<uid>/report/...` —— **不是**
  132. * `/deliverables/<agentId>/...`,App 侧 `isAgentOutputUrl()` 不认,
  133. * 等于测了个假链路。
  134. *
  135. * 现在明确要求:写 8KB 内容(足够撑满一次真实上传)+ 回复时用 `MEDIA:` 行,
  136. * 走 `plugin-deliverable-inject` → 出站媒体 → `/deliverables/` 前缀。
  137. */
  138. const payloadFor = (i, token) => {
  139. /* 约 8KB 的可辨识内容:既压上传,又能验证文件内容没错乱 */
  140. const lines = [];
  141. for (let k = 0; k < 120; k++) {
  142. lines.push(`L${String(k).padStart(3, '0')} n=${i} token=${token} payload=${'x'.repeat(40)}`);
  143. }
  144. return { head: `LOADTEST n=${i} token=${token}`, body: lines.join('\n') };
  145. };
  146. await Promise.all(
  147. peers.map(async (p) => {
  148. if (!p.joined) return;
  149. const { head, body } = payloadFor(p.i, p.token);
  150. p.expectBytes = body.length;
  151. const body400 = [
  152. `压测任务 ${p.i}。严格按下面做,不要做别的事,不要解释:`,
  153. ``,
  154. `1. 用 shell 创建文件 /opt/data/work/loadtest-n${N}/msg-${p.i}.md,内容严格为下面 ${body.split('\n').length} 行(每行都要有):`,
  155. ``,
  156. '```',
  157. body,
  158. '```',
  159. ``,
  160. `2. 确认第一行是:${head}`,
  161. `3. 在回复正文里**单独一行**写:MEDIA:/opt/data/work/loadtest-n${N}/msg-${p.i}.md`,
  162. `4. 回复里必须原样出现 token:${p.token}`,
  163. ].join('\n');
  164. p.promptBytes = body400.length;
  165. const sent = await client.sendEvent(p.room, 'm.room.message', {
  166. msgtype: 'm.text',
  167. body: body400,
  168. 'm.mentions': { user_ids: [agentInfo.agentMxId] },
  169. });
  170. p.sentId = sent.event_id;
  171. p.sentAt = Date.now();
  172. }),
  173. );
  174. console.log(` ${peers.filter((p) => p.sentId).length} 条已发出(每条含约 8KB 写入负载),等待回复…\n`);
  175. // ── 4. 轮询收集 ──────────────────────────────────────────────────────
  176. const deadline = t0 + TIMEOUT_S * 1000;
  177. let lastReport = 0;
  178. while (Date.now() < deadline) {
  179. await sleep(POLL_MS);
  180. await Promise.all(
  181. peers.map(async (p) => {
  182. if (!p.sentId || p.ackedAt) return;
  183. const r = await fetch(
  184. `${hs}/_matrix/client/v3/rooms/${encodeURIComponent(p.room)}/messages?dir=b&limit=60`,
  185. { headers: auth },
  186. ).catch(() => null);
  187. if (!r) return;
  188. const j = await r.json().catch(() => ({}));
  189. const seen = new Set(p.events.map((e) => e.id));
  190. for (const ev of (j.chunk ?? []).reverse()) {
  191. if (seen.has(ev.event_id)) continue;
  192. if (ev.type === 'm.reaction') {
  193. const rel = ev.content?.['m.relates_to'];
  194. if (rel?.event_id === p.sentId && String(rel.key ?? '').includes('✅')) {
  195. p.ackedAt = Date.now();
  196. }
  197. p.events.push({ id: ev.event_id, type: 'reaction', body: '' });
  198. continue;
  199. }
  200. if (ev.type !== 'm.room.message' || ev.sender === prov.userMxId) continue;
  201. p.events.push({
  202. id: ev.event_id,
  203. type: ev.content?.msgtype || 'm.text',
  204. body: String(ev.content?.body ?? ''),
  205. file: ev.content?.file,
  206. });
  207. }
  208. }),
  209. );
  210. const done = peers.filter((p) => p.ackedAt).length;
  211. if (Date.now() - lastReport > 15000) {
  212. lastReport = Date.now();
  213. const elapsed = Math.round((Date.now() - t0) / 1000);
  214. console.log(` [${elapsed}s] 完成 ${done}/${peers.filter((p) => p.joined).length}`);
  215. }
  216. if (done === peers.filter((p) => p.joined).length) break;
  217. }
  218. // ── 5. 结果分析 ──────────────────────────────────────────────────────
  219. const bound = peers.filter((p) => p.joined);
  220. const rows = bound.map((p) => {
  221. const bodies = p.events.filter((e) => e.body).map((e) => e.body).join('\n');
  222. /* 串台检测:这个房间的回复里出现了**别的房间**的 token */
  223. const others = bound.filter((q) => q !== p).map((q) => q.token);
  224. const leakedIn = others.filter((t) => bodies.includes(t));
  225. const hasOwn = bodies.includes(p.token);
  226. const urls = [...bodies.matchAll(/https:\/\/s3\.fmode\.cn\/[^\s)`]+/g)].map((m) => m[0]);
  227. const files = p.events.filter((e) => e.file).map((e) => e.file?.url).filter(Boolean);
  228. const allUrls = [...new Set([...urls, ...files])];
  229. /*
  230. * 🔴 链接前缀判定(2026-11-14 新增)。
  231. *
  232. * App 侧 `s3-link.ts:290` 要求 `/deliverables/` 前缀,否则不认成果。
  233. * 上一版压测容器走 `uploader.mjs put`,给出 `/user/<uid>/report/...`,
  234. * 测出来"成功"但 App 根本看不到 —— 这是假测试。
  235. */
  236. const deliverableUrls = allUrls.filter((u) => u.includes('/deliverables/'));
  237. const wrongPrefixUrls = allUrls.filter((u) => !u.includes('/deliverables/'));
  238. return {
  239. i: p.i,
  240. room: p.room,
  241. token: p.token,
  242. ok: !!p.ackedAt,
  243. ms: p.ackedAt ? p.ackedAt - p.sentAt : null,
  244. hasOwn,
  245. leakedIn,
  246. urls: allUrls,
  247. deliverableUrls,
  248. wrongPrefixUrls,
  249. replyLen: bodies.length,
  250. expectBytes: p.expectBytes || 0,
  251. };
  252. });
  253. // ── 5b. S3 三重验证(并发 HEAD,最多 30 秒)─────────────────────────
  254. console.log('\n=== [4b] 验证 s3 链接 ──');
  255. await Promise.all(
  256. rows.map(async (r) => {
  257. r.urlChecks = await Promise.all(
  258. r.urls.map(async (u) => {
  259. try {
  260. const ac = new AbortController();
  261. const to = setTimeout(() => ac.abort(), 15000);
  262. const resp = await fetch(u, { method: 'GET', signal: ac.signal });
  263. clearTimeout(to);
  264. const text = resp.ok ? await resp.text() : '';
  265. return {
  266. url: u,
  267. status: resp.status,
  268. ok: resp.ok,
  269. bytes: text.length,
  270. /* 内容里有没有本房的 token —— 验证「文件内容没错乱/没串台」 */
  271. hasOwnToken: text.includes(r.token),
  272. hasOtherToken: rows.some((q) => q.i !== r.i && text.includes(q.token)),
  273. };
  274. } catch (e) {
  275. return { url: u, status: 0, ok: false, error: String(e?.message || e) };
  276. }
  277. }),
  278. );
  279. r.contentOk = r.urlChecks.some((c) => c.ok && c.hasOwnToken && !c.hasOtherToken);
  280. r.sizeExpectOk = r.urlChecks.some((c) => c.ok && c.bytes >= r.expectBytes * 0.9);
  281. }),
  282. );
  283. const doneN = rows.filter((r) => r.ok).length;
  284. const crossTalk = rows.filter((r) => r.leakedIn.length > 0);
  285. const noOwn = rows.filter((r) => r.ok && !r.hasOwn);
  286. const withDeliverable = rows.filter((r) => r.deliverableUrls.length > 0);
  287. const wrongPrefix = rows.filter((r) => r.urls.length > 0 && r.deliverableUrls.length === 0);
  288. const contentVerified = rows.filter((r) => r.contentOk);
  289. console.log('\n=== [5] 结果 ===');
  290. console.log(`完整性 : ${doneN}/${bound.length}`);
  291. console.log(`串台(消息): ${crossTalk.length} 个房间收到别人的 token`);
  292. console.log(`有回复但无本房 token : ${noOwn.length}`);
  293. console.log(`🔗 /deliverables/ 前缀 : ${withDeliverable.length}/${rows.filter((r) => r.urls.length).length}`);
  294. console.log(`⚠️ 链接前缀错误 : ${wrongPrefix.length}`);
  295. console.log(`📄 内容验证通过 : ${contentVerified.length}/${rows.filter((r) => r.urls.length).length}`);
  296. const times = rows.filter((r) => r.ms).map((r) => r.ms);
  297. if (times.length) {
  298. times.sort((a, b) => a - b);
  299. const p = (q) => times[Math.min(times.length - 1, Math.floor(times.length * q))];
  300. 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)}`);
  301. }
  302. for (const r of rows) {
  303. const chk = r.urlChecks?.[0];
  304. const flags = [
  305. r.ok ? `✅ ${(r.ms / 1000).toFixed(1)}s` : '❌ 超时无 ✅',
  306. r.hasOwn ? '' : '⚠️回复无本房token',
  307. r.leakedIn.length ? `🔴串台:${r.leakedIn.join(',')}` : '',
  308. r.deliverableUrls.length ? `🔗deliverables` : r.urls.length ? '⚠️前缀非deliverables' : '❌无链接',
  309. chk ? (chk.ok ? `S3 ${chk.status}/${chk.bytes}B` : `S3 ${chk.status}`) : 'S3 未测',
  310. r.contentOk ? '内容✓' : r.urls.length ? '内容✗' : '',
  311. ]
  312. .filter(Boolean)
  313. .join(' ');
  314. console.log(` [${String(r.i).padStart(2)}] ${flags}`);
  315. }
  316. // ── 6. 落盘 ──────────────────────────────────────────────────────────
  317. const lines = [
  318. `# 压测结果 — ${AGENT} — 并发 ${N}`,
  319. ``,
  320. `- 时间:${new Date().toISOString()}`,
  321. `- 用户:${prov.userMxId}`,
  322. `- 容器:${agentInfo.agentMxId}`,
  323. `- 超时:${TIMEOUT_S}s`,
  324. ``,
  325. `## 汇总`,
  326. ``,
  327. `| 指标 | 值 |`,
  328. `|---|---|`,
  329. `| 建房间 | ${N} |`,
  330. `| 容器进房 | ${bound.length} |`,
  331. `| 发出 | ${peers.filter((p) => p.sentId).length} |`,
  332. `| **完成** | **${doneN}** |`,
  333. `| 串台房间数 | ${crossTalk.length} |`,
  334. `| 有回复但无本房 token | ${noOwn.length} |`,
  335. `| **/deliverables/ 前缀正确** | **${withDeliverable.length}** |`,
  336. `| 链接前缀错误(App 不认) | ${wrongPrefix.length} |`,
  337. `| **S3 内容验证通过** | **${contentVerified.length}** |`,
  338. times.length ? `| 耗时 p50 | ${(times[Math.floor(times.length * 0.5)] / 1000).toFixed(1)}s |` : '',
  339. times.length ? `| 耗时 max | ${(times[times.length - 1] / 1000).toFixed(1)}s |` : '',
  340. ``,
  341. `## 逐房间`,
  342. ``,
  343. `| # | 房间 | token | 完成 | 耗时 | 本房token | 串台 | deliverables | S3 | 内容 |`,
  344. `|---|---|---|---|---|---|---|---|---|---|`,
  345. ...rows.map(
  346. (r) =>
  347. `| ${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 ? '✅' : '❌'} |`,
  348. ),
  349. ``,
  350. `## 串台详情`,
  351. ``,
  352. crossTalk.length
  353. ? crossTalk.map((r) => `- 房间 ${r.i} 收到了:${r.leakedIn.join(', ')}`).join('\n')
  354. : '无串台。',
  355. ``,
  356. `## 链接前缀问题`,
  357. ``,
  358. wrongPrefix.length
  359. ? wrongPrefix
  360. .map(
  361. (r) => `- 房间 ${r.i}:\n${r.urls.map((u) => ` - \`${u}\``).join('\n')}\n (不是 \`/deliverables/\`,App 侧 \`isAgentOutputUrl()\` 不认)`,
  362. )
  363. .join('\n')
  364. : '全部链接都是 `/deliverables/` 前缀。',
  365. ``,
  366. `## 失败明细`,
  367. ``,
  368. rows.filter((r) => !r.ok).length
  369. ? rows.filter((r) => !r.ok).map((r) => `- 房间 ${r.i}(${r.room}):${r.replyLen} 字符回复,未收到 ✅`).join('\n')
  370. : '无失败。',
  371. ``,
  372. ];
  373. mkdirSync(dirname(OUT), { recursive: true });
  374. writeFileSync(OUT, lines.join('\n'));
  375. console.log(`\n结果已写入 ${OUT}`);
  376. process.exit(doneN === bound.length && crossTalk.length === 0 ? 0 : 1);
  377. }
  378. main().catch((e) => {
  379. console.error('\n❌ 压测失败:', e instanceof Error ? e.message : e);
  380. process.exit(2);
  381. });