@gavinc-cn/touchstone-dsh 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/LICENSE +0 -0
  2. package/README.md +23 -15
  3. package/README.zh.md +17 -12
  4. package/auth.py +0 -0
  5. package/backend/lib/access.js +44 -0
  6. package/backend/lib/agents.js +341 -0
  7. package/backend/lib/args.js +94 -0
  8. package/backend/lib/auth.js +195 -0
  9. package/backend/lib/board.js +6154 -0
  10. package/backend/lib/builtin-assets.js +1177 -0
  11. package/backend/lib/casescan.js +456 -0
  12. package/backend/lib/chat.js +2053 -0
  13. package/backend/lib/cookie.js +76 -0
  14. package/backend/lib/db.js +2260 -0
  15. package/backend/lib/dshdriver.js +1148 -0
  16. package/backend/lib/dshevents.js +851 -0
  17. package/backend/lib/export-cases.js +261 -0
  18. package/backend/lib/feishu.js +1738 -0
  19. package/backend/lib/feishu_cfg.js +228 -0
  20. package/backend/lib/feishu_conv.js +1484 -0
  21. package/backend/lib/feishu_device.js +428 -0
  22. package/backend/lib/feishu_inbound.js +1674 -0
  23. package/backend/lib/feishu_provision.js +855 -0
  24. package/backend/lib/feishu_rest.js +398 -0
  25. package/backend/lib/feishu_slash.js +568 -0
  26. package/backend/lib/feishu_ws.js +950 -0
  27. package/backend/lib/http.js +312 -0
  28. package/backend/lib/lib.js +973 -0
  29. package/backend/lib/loadcase.js +749 -0
  30. package/backend/lib/loadgen.js +1407 -0
  31. package/backend/lib/localbus.js +164 -0
  32. package/backend/lib/lock.js +405 -0
  33. package/backend/lib/proc.js +278 -0
  34. package/backend/lib/prompts.js +1021 -0
  35. package/backend/lib/pybase64.js +114 -0
  36. package/backend/lib/pyjson.js +214 -0
  37. package/backend/lib/query.js +125 -0
  38. package/backend/lib/rag.js +1310 -0
  39. package/backend/lib/runner.js +4520 -0
  40. package/backend/lib/servercaps.js +182 -0
  41. package/backend/lib/serverstate.js +237 -0
  42. package/backend/lib/sessparse.js +1176 -0
  43. package/backend/lib/sse.js +207 -0
  44. package/backend/lib/static.js +84 -0
  45. package/backend/lib/taskjson.js +302 -0
  46. package/backend/lib/waitq.js +2114 -0
  47. package/backend/lib/worktree.js +588 -0
  48. package/backend/lib/zstd-frames.js +218 -0
  49. package/backend/routes/admin.js +553 -0
  50. package/backend/routes/agents.js +149 -0
  51. package/backend/routes/assets.js +145 -0
  52. package/backend/routes/board.js +1220 -0
  53. package/backend/routes/feishu.js +269 -0
  54. package/backend/routes/feishu_app.js +215 -0
  55. package/backend/routes/feishu_hook.js +358 -0
  56. package/backend/routes/index.js +297 -0
  57. package/backend/routes/projects.js +1059 -0
  58. package/backend/routes/sessions.js +946 -0
  59. package/backend/routes/tasks.js +1530 -0
  60. package/backend/schema/fixtures/ancient.sql +189 -0
  61. package/backend/schema/fixtures/pre_alter.sql +177 -0
  62. package/backend/schema/migrations.json +154 -0
  63. package/backend/schema/schema.json +1451 -0
  64. package/backend/schema/schema.sql +216 -0
  65. package/backend/server.js +498 -0
  66. package/board.py +30 -1
  67. package/builtin_assets.py +0 -0
  68. package/builtin_prompts/free_style/flow.md +0 -0
  69. package/builtin_prompts/free_style/load_test.md +0 -0
  70. package/builtin_prompts/free_style/templates/bug_report_template.md +0 -0
  71. package/builtin_prompts/free_style/templates/case_template.md +0 -0
  72. package/builtin_prompts/free_style/templates/index_template.md +0 -0
  73. package/builtin_prompts/free_style/templates/load_charts_template.md +0 -0
  74. package/builtin_prompts/free_style/templates/load_index_template.md +0 -0
  75. package/builtin_prompts/free_style/templates/load_plan_template.md +0 -0
  76. package/builtin_prompts/free_style/templates/load_scenario_template.md +0 -0
  77. package/builtin_prompts/free_style/templates/status_template.md +0 -0
  78. package/db.py +294 -129
  79. package/dsh-plugin/cordis.patch.yml +0 -0
  80. package/dsh-plugin/install.sh +22 -8
  81. package/dsh-plugin/lib/client.bundle.js +0 -0
  82. package/dsh-plugin/lib/index.js +299 -29
  83. package/dsh-plugin/lib/pack-closure.mjs +291 -0
  84. package/dsh-plugin/scripts/build.mjs +0 -0
  85. package/dsh-plugin/scripts/prepack-check.mjs +30 -2
  86. package/dsh-plugin/scripts/select-bundle.mjs +0 -0
  87. package/dsh-plugin/src/client.js +0 -0
  88. package/dshevents.py +0 -0
  89. package/export_cases.py +0 -0
  90. package/extensions/dsh-autocommit/asset.json +0 -0
  91. package/extensions/dsh-autocommit/pkg/lib/index.mjs +0 -0
  92. package/extensions/dsh-autocommit/pkg/package.json +0 -0
  93. package/feishu_conv.py +0 -0
  94. package/lib.py +0 -0
  95. package/lifecycle.py +116 -13
  96. package/loadcase.py +0 -0
  97. package/loadgen.py +0 -0
  98. package/localbus.py +0 -0
  99. package/migrate_p7b.py +0 -0
  100. package/package.json +6 -1
  101. package/platcompat.py +0 -0
  102. package/prompts.py +0 -0
  103. package/rag.py +0 -0
  104. package/requirements.txt +0 -0
  105. package/touchstone.py +0 -0
  106. package/waitq.py +0 -0
  107. package/webui/dist-plugin/assets/index-ChP3sg6y.css +1 -0
  108. package/webui/dist-plugin/assets/{index-JLwyZZxC.js → index-UrMyeAXF.js} +110 -110
  109. package/webui/dist-plugin/index.html +2 -2
  110. package/worktree.py +0 -0
  111. package/webui/dist-plugin/assets/index-tJ0TlvQ3.css +0 -1
@@ -0,0 +1,1407 @@
1
+ /**
2
+ * `loadgen.py`(678 行,仓库根)的 Node 对应物(Node 后端 M6-5)。
3
+ *
4
+ * Touchstone 内置压测引擎:校验场景 JSON → 并发发压 → 秒级快照落盘 → 汇总报告(JSON+Markdown),
5
+ * 外加规范指标通道 `MetricsWriter` 与 `run` 子命令(平台发压轮的统一脚本调用入口)。
6
+ *
7
+ * ── 导出名 ────────────────────────────────────────────────────────────────────
8
+ * 主名**照 Python 名**(`validate_scenario`/`run_load`/`_pct_from_buckets`/…),文件末尾再给一套
9
+ * camelCase 别名(`validateScenario`/`runLoad`/`_pctFromBuckets`/…)供 JS 调用方使用——与
10
+ * `rag.js`(M6-3)同一约定。对拍用例的 `fn` 写 Python 名、`nodeFn` 写本文件的导出名。
11
+ *
12
+ * ── 与 Python 的**形态**差异(都已在调用方或量具里显式对齐) ───────────────────
13
+ * ① **异步**:Python 用「每线程一条 `http.client` 连接」的阻塞模型,Node 的 I/O 只有异步形态
14
+ * ⇒ `run_load`/`_worker`/`_one_request`/`main`/`_cmd_run` 返回 Promise(`await` 同步值无害)。
15
+ * 其余函数保持同步(纯算法/文件)。
16
+ * ② **元组 → 数组**:`validate_scenario` 的 `(scenario, err)`、`write_report_files` 的
17
+ * `(json_path, md_path)` 在 JS 侧就是数组(不用对象),对拍无需 `shape` 适配。`_one_request`
18
+ * 的 4 元组是**内部**约定(连接对象过不了 JSON 边界),用命名对象 `{conn, ok, bucket, err_cls}`。
19
+ * ③ **`**kwargs` → 末位 options**:`MetricsWriter.sample(metric, value, *, type, labels, ts)`
20
+ * 在 Node 侧是 `sample(metric, value, options = {})`;`gauge`/`counter` 的 `(metric, value,
21
+ * ts=None, **labels)` 在 Node 侧是 `gauge(metric, value, kwargs = {})`——末位对象**就是**那批
22
+ * 关键字实参(`{ts, ...labels}`),既复刻 Python 的绑定语义,又不让 `ts` 有机会落进 labels
23
+ * (历史缺陷见 `bug_report/20261004_2040`,Python 侧靠独立形参修掉,这里靠显式解构)。
24
+ * `meta(**fields)` 同理:`meta(fields = {})`。
25
+ *
26
+ * ── 并发模型:Python 线程 + http.client ⇒ Node 异步 + keep-alive Agent ─────────
27
+ * Python(`loadgen.py:414-421`):每档位起 `conc` 个线程,每线程一条 `http.client` 连接
28
+ * (keep-alive 复用,只有连接类错误才 `conn.close()` 后重建)。Node 的等价物:
29
+ * · 每档位起 `conc` 个**并发异步 worker**(在飞请求数 = `conc`,`Promise.all` 即 `t.join()`);
30
+ * · 每 worker **独占一个 keep-alive Agent**(`maxSockets: 1, maxFreeSockets: 1`)——
31
+ * 与「每线程一条连接」同构;连接类错误 `agent.destroy()` 后下轮重建;
32
+ * · 不用 `fetch`:它的连接生命周期不受控,延迟分桶(`_bucket_of`)与 `conn_err` 这一维
33
+ * 会直接不可对齐(`loadgen.py:366-367·376-377·392·396-401`)。
34
+ * 计时:`time.perf_counter()` → `process.hrtime.bigint()`(纳秒整数→毫秒);
35
+ * `time.time()` → `Date.now()/1000`;`time.sleep` → `setTimeout`。
36
+ * 锁:`_Agg` 在 Python 里要 `threading.Lock`,Node 单线程事件循环下 `add`/`take_window`/
37
+ * `copy_total` 全程同步无 `await`,天然原子(保留同名方法,语义一致)。
38
+ *
39
+ * ── 已登记的**残余差异**(原理性;不做粉饰,逐条给理由) ──────────────────────
40
+ * a. **整数值浮点的 JSON 文本**:Python 写 `1.0`,`pyjson.js` 写 `1`(JS 的 number 无 int/float
41
+ * 之分,`pyjson.js` 文件头已列为不可消除)。影响快照/指标/报告的**文本**,不影响 JSON 值。
42
+ * 对拍夹具刻意避开整数值浮点(与 `rag.json` 同法)。
43
+ * b. **JSON 解码错误文案**:CPython 是 `Expecting value: line 1 column 1 (char 0)`,V8 是
44
+ * `Unexpected token …`。分支一致(两侧都判「不是合法 JSON」),文案不同源。
45
+ * c. **`json.load` 接受 `NaN`/`Infinity`/`-Infinity`**(CPython 默认 `allow_nan=True`),
46
+ * `JSON.parse` 不接受 ⇒ 场景文件里出现这三个裸字面量时 Python 能解析、Node 判非法。
47
+ * d. **非 latin-1 的 body/header 值**:Python 的 `http.client` 一律按 **latin-1** 编码(`_encode`)
48
+ * ⇒ 非 latin-1 字符抛 `UnicodeEncodeError`,该异常顺着 `_worker` 逃逸、**线程静默死掉**
49
+ * (其余 worker 照跑)。Node 侧同样按 latin-1 编码、同样只让该 worker 退出(见 `_latin1`),
50
+ * 但不复刻 Python 打到 stderr 的那段 traceback。
51
+ * e. **header 值含 CR/LF**:Python `putheader` 抛 `ValueError`(线程死掉),Node `http` 抛
52
+ * `ERR_INVALID_CHAR`。夹具不覆盖。
53
+ * f. **IDN 主机名**:`urlsplit().hostname` 只做小写化,`new URL()` 还会做 punycode ⇒
54
+ * `http://例え.jp/` 两侧 `_new_conn` 拿到的主机名不同(本模块只在 `validate_scenario`
55
+ * 借用手写 urlsplit,见 `_urlsplit`,故不受影响;`_parse_base` 也走同一实现)。
56
+ */
57
+ import fs from 'node:fs';
58
+ import http from 'node:http';
59
+ import https from 'node:https';
60
+ import path from 'node:path';
61
+ import { pathToFileURL } from 'node:url';
62
+
63
+ import { pyStrip } from './lib.js';
64
+ import { pyJsonDumps, pyJsonDumpsIndent } from './pyjson.js';
65
+ import {
66
+ KeyError, ValueError, pyInt, pyRound, pyStr, pyTruthy,
67
+ } from './rag.js';
68
+
69
+ // 延迟分桶上界(ms):耗时落入首个 >= 耗时的边界桶;超出入 "over"
70
+ export const LAT_BOUNDS = Object.freeze([1, 2, 5, 10, 20, 50, 100, 200, 500, 1000, 2000, 5000, 10000]);
71
+ // 场景字段取值范围(并发上限=平台线程模型容量边界)
72
+ export const MAX_CONC = 512;
73
+ export const MAX_STAGES = 8;
74
+ export const MAX_GROUPS = 16;
75
+ // 快速失败观察窗(秒):第 10 秒回看累计,全部为连接类错误则中止
76
+ export const QUICK_FAIL_SECONDS = 10;
77
+ // 错误分类键(快照 err_breakdown / 报告错误分类使用)
78
+ export const ERR_CLASSES = Object.freeze(['4xx', '5xx', 'timeout', 'conn_err']);
79
+
80
+ /** 快速失败:目标服务不可达,发压中止(runner 据此把任务标 failed)。 */
81
+ export class QuickFailError extends Error {
82
+ constructor(message) {
83
+ super(message);
84
+ this.name = 'QuickFailError';
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Python `str` 的 latin-1 编码错误(`http.client._encode`)——照 CPython 的 `UnicodeEncodeError`
90
+ * 文案:`'latin-1' codec can't encode characters in position 0-1: ordinal not in range(256)`。
91
+ *
92
+ * 为什么要它:`_worker` 靠**这个类型**决定「该 worker 退出、其余照跑」(复刻 Python 的线程死亡)。
93
+ */
94
+ export class UnicodeEncodeError extends Error {
95
+ constructor(message) {
96
+ super(message);
97
+ this.name = 'UnicodeEncodeError';
98
+ }
99
+ }
100
+
101
+ // --------------------------------------------------------------------------
102
+ // Node 侧共用小工具(Python 无对应物;loadcase.js 也 import,避免两份实现)
103
+ // --------------------------------------------------------------------------
104
+
105
+ /**
106
+ * Python `dict.get(k[, d])`:**只有键不存在**才给默认值(键在但为 null 仍返回 null)。
107
+ * 用 `hasOwnProperty` 而不是 `in`/直接取值:`constructor`/`toString` 这类键会撞原型链。
108
+ */
109
+ function pyGet(obj, key, fallback = null) {
110
+ if (Object.prototype.hasOwnProperty.call(obj, key)) return obj[key];
111
+ return fallback;
112
+ }
113
+
114
+ /**
115
+ * Python `float(x)`:bool→1/0、str 走 Python 的空白/正负号/`1_0`/`inf`/`nan` 口径,
116
+ * 其余类型抛 TypeError(照 `float()` 的 `(TypeError, ValueError)` 二分——`MetricsWriter.sample`
117
+ * 就是靠它决定「丢弃样本」还是「抛给调用方」)。
118
+ *
119
+ * 已知残余:只认 ASCII 数字(Python 还认 Unicode 十进制数字,与 `lib.py:pyIsDigits` 同一条备案)。
120
+ */
121
+ export function py_float(value) {
122
+ if (typeof value === 'boolean') return value ? 1.0 : 0.0;
123
+ if (typeof value === 'number') return value;
124
+ if (typeof value === 'string') {
125
+ const text = pyStrip(value);
126
+ const m = /^([+-]?)(inf(?:inity)?|nan|(?:\d(?:_?\d)*)?(?:\.(?:\d(?:_?\d)*)?)?(?:[eE][+-]?\d+)?)$/.exec(text.toLowerCase());
127
+ if (!m || m[2] === '') {
128
+ throw new ValueError(`could not convert string to float: ${_pyReprString(value)}`);
129
+ }
130
+ const body = m[2].replace(/_/g, '').toLowerCase();
131
+ let out;
132
+ if (body === 'inf' || body === 'infinity') out = Infinity;
133
+ else if (body === 'nan') out = NaN;
134
+ else out = Number(body);
135
+ return m[1] === '-' ? -out : out;
136
+ }
137
+ throw new TypeError(`float() argument must be a string or a real number, not '${_pyTypeName(value)}'`);
138
+ }
139
+
140
+ /** Python `repr(str)` 的可用子集(错误文案里用;`rag.js` 的同名助手是私有的) */
141
+ function _pyReprString(text) {
142
+ let out = "'";
143
+ for (const ch of String(text)) {
144
+ if (ch === '\\') out += '\\\\';
145
+ else if (ch === "'") out += "\\'";
146
+ else if (ch === '\n') out += '\\n';
147
+ else if (ch === '\r') out += '\\r';
148
+ else if (ch === '\t') out += '\\t';
149
+ else out += ch;
150
+ }
151
+ return `${out}'`;
152
+ }
153
+
154
+ /** Python 类型名(只用于错误文案) */
155
+ function _pyTypeName(value) {
156
+ if (value === null || value === undefined) return 'NoneType';
157
+ if (Array.isArray(value)) return 'list';
158
+ switch (typeof value) {
159
+ case 'boolean': return 'bool';
160
+ case 'number': return Number.isInteger(value) ? 'int' : 'float';
161
+ case 'string': return 'str';
162
+ case 'object': return 'dict';
163
+ default: return typeof value;
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Python `str(OSError)` 的 Node 还原:`[Errno 2] No such file or directory: '/p'`。
169
+ *
170
+ * 为什么必须做:`validate_scenario` / `load_charts` 把 OSError 文本直接拼进给用户看的
171
+ * 错误说明里(`场景文件不存在或不可读: {e}`),文案不一致 ⇒ 对拍里这两条分支只能靠白名单
172
+ * 盖住。errno→strerror 表按 Linux 的 `strerror(3)` 口径给(Node 的 `e.errno` 是负数)。
173
+ */
174
+ const _ERRNO_TEXT = new Map([
175
+ ['EPERM', [1, 'Operation not permitted']],
176
+ ['ENOENT', [2, 'No such file or directory']],
177
+ ['EIO', [5, 'Input/output error']],
178
+ ['EBADF', [9, 'Bad file descriptor']],
179
+ ['EACCES', [13, 'Permission denied']],
180
+ ['EEXIST', [17, 'File exists']],
181
+ ['ENOTDIR', [20, 'Not a directory']],
182
+ ['EISDIR', [21, 'Is a directory']],
183
+ ['EINVAL', [22, 'Invalid argument']],
184
+ ['EMFILE', [24, 'Too many open files']],
185
+ ['ENOSPC', [28, 'No space left on device']],
186
+ ['EROFS', [30, 'Read-only file system']],
187
+ ['ENAMETOOLONG', [36, 'File name too long']],
188
+ ['ENOTEMPTY', [39, 'Directory not empty']],
189
+ ['ELOOP', [40, 'Too many levels of symbolic links']],
190
+ ]);
191
+
192
+ export function py_error_str(err, pathHint = null) {
193
+ const code = err && err.code;
194
+ const known = _ERRNO_TEXT.get(code);
195
+ // Python 的 OSError 文本总带 open() 的**文件名**;Node 的部分错误(如目录读到一半的
196
+ // `EISDIR ... read`)只有 syscall 没有 path ⇒ 用调用方给的路径兜底(少了它就与 oracle 分叉)
197
+ const where0 = err && err.path !== undefined ? err.path : pathHint;
198
+ const where = where0 !== undefined && where0 !== null ? `: '${where0}'` : '';
199
+ if (!known) {
200
+ const num = err && typeof err.errno === 'number' ? -err.errno : null;
201
+ const text = (err && err.message) || String(err);
202
+ return num === null ? text : `[Errno ${num}] ${text}${where}`;
203
+ }
204
+ return `[Errno ${known[0]}] ${known[1]}${where}`;
205
+ }
206
+
207
+ /** Node 的 fs 错误(带 code/syscall),与「解码/解析」类错误(ValueError)区分开 */
208
+ function _isOsError(err) {
209
+ return Boolean(err) && typeof err.code === 'string' && typeof err.syscall === 'string';
210
+ }
211
+
212
+ /**
213
+ * CPython `UnicodeDecodeError` 文案(utf-8):`'utf-8' codec can't decode byte 0xff in position 0:
214
+ * invalid start byte`。按 CPython 的 UTF-8 解码器逐段复刻「起始位置 + 区间 + 三种 reason」
215
+ * (区间跨多字节时是 `bytes in position a-b`,单字节时是 `byte 0x… in position a`)。
216
+ */
217
+ function _utf8ErrorMessage(buf) {
218
+ const n = buf.length;
219
+ let i = 0;
220
+ while (i < n) {
221
+ const b = buf[i];
222
+ if (b < 0x80) { i += 1; continue; }
223
+ let need;
224
+ let lo = 0x80;
225
+ let hi = 0xBF;
226
+ if (b >= 0xC2 && b <= 0xDF) need = 1;
227
+ else if (b >= 0xE0 && b <= 0xEF) {
228
+ need = 2;
229
+ if (b === 0xE0) lo = 0xA0;
230
+ else if (b === 0xED) hi = 0x9F;
231
+ } else if (b >= 0xF0 && b <= 0xF4) {
232
+ need = 3;
233
+ if (b === 0xF0) lo = 0x90;
234
+ else if (b === 0xF4) hi = 0x8F;
235
+ } else {
236
+ return { start: i, end: i, reason: 'invalid start byte' };
237
+ }
238
+ if (i + 1 >= n) return { start: i, end: n - 1, reason: 'unexpected end of data' };
239
+ if (buf[i + 1] < lo || buf[i + 1] > hi) {
240
+ return { start: i, end: i, reason: 'invalid continuation byte' };
241
+ }
242
+ for (let k = 2; k <= need; k += 1) {
243
+ if (i + k >= n) return { start: i, end: n - 1, reason: 'unexpected end of data' };
244
+ if (buf[i + k] < 0x80 || buf[i + k] > 0xBF) {
245
+ return { start: i, end: i + k - 1, reason: 'invalid continuation byte' };
246
+ }
247
+ }
248
+ i += need + 1;
249
+ }
250
+ return null;
251
+ }
252
+
253
+ /**
254
+ * 读一个 JSON 文件(`open(path, encoding="utf-8")` + `json.load`)。
255
+ *
256
+ * 与 Python 的差异只保留两处**已登记**的残余:JSON 解码错误文案(b)、`NaN`/`Infinity`(c)。
257
+ * 其它逐条对齐:严格 UTF-8(Python 默认 `errors=None`,非法字节抛 UnicodeDecodeError——
258
+ * 那是 ValueError 子类)、**不剥 BOM**(`ignoreBOM: true`,Python 的 utf-8 编解码器保留 U+FEFF)。
259
+ * 抛出的错误分两类:fs 错误(带 code/syscall,调用方按 OSError 处理)与 ValueError。
260
+ */
261
+ export function read_json_file(filePath) {
262
+ const buf = fs.readFileSync(filePath);
263
+ let text;
264
+ try {
265
+ text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(buf);
266
+ } catch {
267
+ const info = _utf8ErrorMessage(buf) || { start: 0, end: 0, reason: 'invalid start byte' };
268
+ const where = info.start === info.end
269
+ ? `byte 0x${buf[info.start].toString(16).padStart(2, '0')} in position ${info.start}`
270
+ : `bytes in position ${info.start}-${info.end}`;
271
+ throw new ValueError(`'utf-8' codec can't decode ${where}: ${info.reason}`);
272
+ }
273
+ try {
274
+ return JSON.parse(text);
275
+ } catch (e) {
276
+ throw new ValueError(e && e.message !== undefined ? e.message : String(e));
277
+ }
278
+ }
279
+
280
+ /** Python `time.strftime(fmt, time.localtime(when))` 的可用子集(%Y %m %d %H %M %S %%) */
281
+ export function strftime_local(date, fmt) {
282
+ const pad = (n, w = 2) => String(n).padStart(w, '0');
283
+ const table = {
284
+ Y: String(date.getFullYear()),
285
+ m: pad(date.getMonth() + 1),
286
+ d: pad(date.getDate()),
287
+ H: pad(date.getHours()),
288
+ M: pad(date.getMinutes()),
289
+ S: pad(date.getSeconds()),
290
+ };
291
+ return String(fmt).replace(/%([YmdHMS%])/g, (whole, ch) => (ch === '%' ? '%' : table[ch]));
292
+ }
293
+
294
+ /** Python `str(float)` / f-string 里的浮点渲染(`0.0`→`"0.0"`、`12.5`→`"12.5"`) */
295
+ function _pyFloatStr(value) {
296
+ if (Number.isNaN(value)) return 'nan';
297
+ if (value === Infinity) return 'inf';
298
+ if (value === -Infinity) return '-inf';
299
+ if (Number.isInteger(value) && Math.abs(value) < 1e16) return `${value}.0`;
300
+ return String(value);
301
+ }
302
+
303
+ /**
304
+ * f-string 里任意值的渲染(`None`→`"None"`、整数值浮点→`"1.0"`,其余同 `pyStr`)。
305
+ *
306
+ * 为什么必须单列:Python 的 f-string 对 float 走 `repr`(`0.0` → `"0.0"`),而 JS 的 `` `${0}` ``
307
+ * 是 `"0"`;报告/摘要里的 RPS、分位数、时长都是这条路径。
308
+ */
309
+ export function py_field_str(value) {
310
+ if (typeof value === 'number') return _pyFloatStr(value);
311
+ return pyStr(value);
312
+ }
313
+
314
+ /**
315
+ * f-string 里的 **int 型**字段(`max`):`None`→`"None"`,其余按整数字面量渲染。
316
+ *
317
+ * 为什么要和 `py_field_str` 分开:JS 的 number 分不出 int/float,而 `py_field_str` 按
318
+ * **float** 渲染(整数值补 `.0`)——那对 `round()` 的产物是对的(Python 侧拿到的是 float,
319
+ * `f"{2.0}"` 就是 `"2.0"`),对 `_max_from_buckets` 的 `int(b)` 产物是错的(Python 是
320
+ * `"50"`,补成 `"50.0"` 就与 oracle 分叉)。故按字段的**已知类型**选渲染器。
321
+ */
322
+ function _pyIntFieldStr(value) {
323
+ if (value === null || value === undefined) return 'None';
324
+ return String(value);
325
+ }
326
+
327
+ /**
328
+ * f-string 的 `{x:.1f}` / `{x:.2f}`:CPython 的定点格式对 double 的**精确十进制值**做半偶舍入,
329
+ * JS 的 `toFixed` 是半**上**入 ⇒ `format(1.25, ".1f")` 是 `"1.2"` 而 `(1.25).toFixed(1)` 是
330
+ * `"1.3"`。错误率 = `round(err/count, 4) * 100` 恰好会踩到 `x.x5`(如 10/800 → 1.25%),
331
+ * 故这里先 `pyRound`(与 CPython 同语义)再补位。
332
+ */
333
+ function _pyFixed(value, digits) {
334
+ return pyRound(value, digits).toFixed(digits);
335
+ }
336
+
337
+ // --------------------------------------------------------------------------
338
+ // urlsplit / 端口(**手写**:Node 的 `new URL()` 语义与 Python 不同,见文件头)
339
+ // --------------------------------------------------------------------------
340
+
341
+ /** Python `urlsplit()` 的结构部分(scheme/netloc/path/query/fragment) */
342
+ function _urlsplit(rawUrl) {
343
+ // CPython 先删掉 URL 里的 \t\r\n(_UNSAFE_URL_BYTES_TO_REMOVE),再按「scheme → # → ?」切
344
+ let url = String(rawUrl).replace(/[\t\r\n]/g, '');
345
+ let scheme = '';
346
+ let fragment = '';
347
+ let query = '';
348
+ let netloc = '';
349
+ let text = url;
350
+ const colon = text.indexOf(':');
351
+ if (colon > 0 && /^[\x00-\x7f]$/.test(text[0]) && /[A-Za-z]/.test(text[0])) {
352
+ const head = text.slice(0, colon);
353
+ if (/^[A-Za-z0-9+.-]*$/.test(head)) {
354
+ scheme = head.toLowerCase();
355
+ text = text.slice(colon + 1);
356
+ }
357
+ }
358
+ const hash = text.indexOf('#');
359
+ if (hash >= 0) { fragment = text.slice(hash + 1); text = text.slice(0, hash); }
360
+ const qm = text.indexOf('?');
361
+ if (qm >= 0) { query = text.slice(qm + 1); text = text.slice(0, qm); }
362
+ if (text.startsWith('//')) {
363
+ const rest = text.slice(2);
364
+ const slash = rest.indexOf('/');
365
+ netloc = slash >= 0 ? rest.slice(0, slash) : rest;
366
+ text = slash >= 0 ? rest.slice(slash) : '';
367
+ if ((netloc.includes('[') && !netloc.includes(']'))
368
+ || (netloc.includes(']') && !netloc.includes('['))) {
369
+ throw new ValueError('Invalid IPv6 URL'); // 照 CPython:urlsplit 直接抛
370
+ }
371
+ }
372
+ url = text;
373
+ return { scheme, netloc, path: url, query, fragment };
374
+ }
375
+
376
+ /**
377
+ * Python `urlsplit(...)._hostinfo` + `hostname`/`username`/`port` 三个属性。
378
+ *
379
+ * `port` 的判定照 CPython 3.12(`urllib/parse.py` 的 `port` property):
380
+ * `port.isdigit() and port.isascii()` 才 `int()`,否则 `ValueError`;再判 `0 <= port <= 65535`。
381
+ * 空端口(`http://h:`)不算错(`None`)。
382
+ */
383
+ function _pyHostInfo(parts) {
384
+ const netloc = parts.netloc;
385
+ const at = netloc.lastIndexOf('@');
386
+ const haveInfo = at >= 0;
387
+ const userinfo = haveInfo ? netloc.slice(0, at) : '';
388
+ const hostinfo = haveInfo ? netloc.slice(at + 1) : netloc;
389
+ const username = haveInfo ? userinfo.split(':')[0] : null;
390
+
391
+ let hostname;
392
+ let port;
393
+ const openBr = hostinfo.indexOf('[');
394
+ if (openBr >= 0) {
395
+ const bracketed = hostinfo.slice(openBr + 1);
396
+ const closeBr = bracketed.indexOf(']');
397
+ hostname = closeBr >= 0 ? bracketed.slice(0, closeBr) : bracketed;
398
+ const after = closeBr >= 0 ? bracketed.slice(closeBr + 1) : '';
399
+ port = after.startsWith(':') ? after.slice(1) : '';
400
+ } else {
401
+ const c = hostinfo.indexOf(':');
402
+ hostname = c >= 0 ? hostinfo.slice(0, c) : hostinfo;
403
+ port = c >= 0 ? hostinfo.slice(c + 1) : '';
404
+ }
405
+ if (!port) port = null;
406
+ // hostname:空 → None;否则小写(zone 段保留大小写,照 CPython)
407
+ let hostOut = null;
408
+ if (hostname) {
409
+ const pct = hostname.indexOf('%');
410
+ hostOut = pct >= 0
411
+ ? hostname.slice(0, pct).toLowerCase() + hostname.slice(pct)
412
+ : hostname.toLowerCase();
413
+ }
414
+ return { username, hostname: hostOut, port };
415
+ }
416
+
417
+ /** `urlsplit(base)` 的常用取用口(`.port` 的访问即这里抛 ValueError,照 Python 的惰性抛) */
418
+ function _parse_base(baseUrl) {
419
+ const parts = _urlsplit(baseUrl);
420
+ const info = _pyHostInfo(parts);
421
+ let port = null;
422
+ if (info.port !== null) {
423
+ if (!/^[0-9]+$/.test(info.port)) {
424
+ throw new ValueError(`Port could not be cast to integer value as ${_pyReprString(info.port)}`);
425
+ }
426
+ port = Number(info.port);
427
+ if (!(port >= 0 && port <= 65535)) throw new ValueError('Port out of range 0-65535');
428
+ }
429
+ return {
430
+ scheme: parts.scheme,
431
+ hostname: info.hostname,
432
+ username: info.username,
433
+ path: parts.path,
434
+ query: parts.query,
435
+ fragment: parts.fragment,
436
+ port,
437
+ };
438
+ }
439
+
440
+ // --------------------------------------------------------------------------
441
+ // 场景校验
442
+ // --------------------------------------------------------------------------
443
+
444
+ /**
445
+ * 校验压测场景 JSON 文件。返回 `[scenario, null]` 或 `[null, 错误说明]`。
446
+ *
447
+ * 校验通过时对 scenario 做规范化(method 转大写、补默认 timeout_ms),调用方(runner)拿到的
448
+ * 即可直接发压的对象。规则与设计文档 §4 一致。
449
+ */
450
+ export function validate_scenario(scenarioPath) {
451
+ let sc;
452
+ try {
453
+ sc = read_json_file(scenarioPath);
454
+ } catch (e) {
455
+ // 照 Python 的 `except OSError` / `except ValueError` 两条:其它异常照原样往外抛
456
+ if (e instanceof ValueError) {
457
+ return [null, `场景不是合法 JSON: ${e && e.message !== undefined ? e.message : e}`];
458
+ }
459
+ if (_isOsError(e)) return [null, `场景文件不存在或不可读: ${py_error_str(e, scenarioPath)}`];
460
+ throw e;
461
+ }
462
+ if (!(sc !== null && typeof sc === 'object' && !Array.isArray(sc))) {
463
+ return [null, '场景顶层必须是 JSON 对象'];
464
+ }
465
+ const base = pyStrip(pyStr(pyGet(sc, 'base_url', '')));
466
+ const u = _urlsplit(base);
467
+ const info = _pyHostInfo(u);
468
+ // 只接受 host[:port] 形式:带路径/query/fragment 时前缀会被引擎静默丢弃,压错目标;
469
+ // user:pass@ 的凭据同样会被引擎静默丢弃(产出「看似正常」的报告),username 非空即拒绝
470
+ if (!(u.scheme === 'http' || u.scheme === 'https') || !info.hostname
471
+ || pyTruthy(info.username) || !(u.path === '' || u.path === '/')
472
+ || pyTruthy(u.query) || pyTruthy(u.fragment)) {
473
+ return [null, 'base_url 必须是 http(s)://host[:port] 形式(不支持 user:pass@,鉴权走 headers)'];
474
+ }
475
+ try {
476
+ // 端口非法(非数字/越界)访问时才抛:提前判掉,否则工作线程会在
477
+ // 建连接处静默死掉,产出「0 请求但正常结束」的假报告(loadgen.py:75-78)
478
+ if (info.port !== null) {
479
+ if (!/^[0-9]+$/.test(info.port)) {
480
+ throw new ValueError(`Port could not be cast to integer value as ${_pyReprString(info.port)}`);
481
+ }
482
+ const p = Number(info.port);
483
+ if (!(p >= 0 && p <= 65535)) throw new ValueError('Port out of range 0-65535');
484
+ }
485
+ } catch {
486
+ return [null, 'base_url 端口非法(必须是 1~65535 的整数)'];
487
+ }
488
+ sc.base_url = base;
489
+ if (!pyStrip(pyStr(pyGet(sc, 'name', '')))) {
490
+ return [null, '缺少 name(场景名)'];
491
+ }
492
+ const groups = pyGet(sc, 'groups', null);
493
+ if (!Array.isArray(groups) || !(groups.length >= 1 && groups.length <= MAX_GROUPS)) {
494
+ return [null, `groups 必须是 1~${MAX_GROUPS} 个的数组`];
495
+ }
496
+ const seen = new Set();
497
+ for (const g of groups) {
498
+ if (!(g !== null && typeof g === 'object' && !Array.isArray(g))) {
499
+ return [null, 'groups 元素必须是对象'];
500
+ }
501
+ const name = pyStrip(pyStr(pyGet(g, 'name', '')));
502
+ if (!name) return [null, 'groups[].name 不能为空'];
503
+ if (seen.has(name)) return [null, `groups[].name 重复: ${name}`];
504
+ seen.add(name);
505
+ g.name = name;
506
+ g.method = pyStr(pyGet(g, 'method', 'GET')).toUpperCase();
507
+ if (!['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'].includes(g.method)) {
508
+ return [null, `${name}: method 非法`];
509
+ }
510
+ const pathText = pyStrip(pyStr(pyGet(g, 'path', '')));
511
+ if (!pathText.startsWith('/')) return [null, `${name}: path 必须以 / 开头`];
512
+ g.path = pathText;
513
+ const headers = pyGet(g, 'headers', {});
514
+ if (!(headers !== null && typeof headers === 'object' && !Array.isArray(headers))) {
515
+ return [null, `${name}: headers 必须是对象`];
516
+ }
517
+ // 值必须是标量:嵌套对象传给 http.client 会抛 TypeError 使工作线程静默死亡
518
+ for (const v of Object.values(headers)) {
519
+ const scalar = typeof v === 'string' || typeof v === 'number';
520
+ if (typeof v === 'boolean' || !scalar) {
521
+ return [null, `${name}: headers 的值必须是标量`];
522
+ }
523
+ }
524
+ const w = pyGet(g, 'weight', 1);
525
+ if (typeof w !== 'number' || !Number.isInteger(w) || w < 1) {
526
+ return [null, `${name}: weight 必须是 >=1 的整数`];
527
+ }
528
+ g.weight = w; // 缺省时补默认值写回,否则 run_load 的 _pick_group 会 KeyError
529
+ if (Object.prototype.hasOwnProperty.call(g, 'body') && typeof g.body !== 'string') {
530
+ return [null, `${name}: body 必须是字符串`];
531
+ }
532
+ }
533
+ const stages = pyGet(sc, 'stages', null);
534
+ if (!Array.isArray(stages) || !(stages.length >= 1 && stages.length <= MAX_STAGES)) {
535
+ return [null, `stages 必须是 1~${MAX_STAGES} 档的数组`];
536
+ }
537
+ for (const s of stages) {
538
+ if (!(s !== null && typeof s === 'object' && !Array.isArray(s))) {
539
+ return [null, 'stages 元素必须是对象'];
540
+ }
541
+ const c = pyGet(s, 'conc', null);
542
+ const sec = pyGet(s, 'seconds', null);
543
+ if (typeof c !== 'number' || !Number.isInteger(c) || !(c >= 1 && c <= MAX_CONC)) {
544
+ return [null, `conc 必须是 1~${MAX_CONC} 的整数(平台容量边界)`];
545
+ }
546
+ if (typeof sec !== 'number' || !Number.isInteger(sec) || !(sec >= 5 && sec <= 3600)) {
547
+ return [null, 'seconds 必须是 5~3600 的整数'];
548
+ }
549
+ }
550
+ const t = pyGet(sc, 'timeout_ms', 10000);
551
+ if (typeof t !== 'number' || !Number.isInteger(t) || !(t >= 100 && t <= 60000)) {
552
+ return [null, 'timeout_ms 必须是 100~60000 的整数'];
553
+ }
554
+ sc.timeout_ms = t;
555
+ return [sc, null];
556
+ }
557
+
558
+ // --------------------------------------------------------------------------
559
+ // 分桶与统计
560
+ // --------------------------------------------------------------------------
561
+
562
+ /** 耗时(ms)→ 分桶键:首个 >= 耗时的边界值字符串;超出归 "over"(`bisect.bisect_left`) */
563
+ export function _bucket_of(ms) {
564
+ let lo = 0;
565
+ let hi = LAT_BOUNDS.length;
566
+ while (lo < hi) {
567
+ const mid = (lo + hi) >> 1;
568
+ if (LAT_BOUNDS[mid] < ms) lo = mid + 1;
569
+ else hi = mid;
570
+ }
571
+ return lo < LAT_BOUNDS.length ? String(LAT_BOUNDS[lo]) : 'over';
572
+ }
573
+
574
+ /** 分桶累计 + 桶内线性插值取分位数(ms);无成功请求返回 None */
575
+ export function _pct_from_buckets(buckets, pct) {
576
+ let total = 0;
577
+ for (const v of Object.values(buckets)) total += v;
578
+ if (!pyTruthy(total)) return null;
579
+ const target = total * pct / 100.0;
580
+ let cum = 0;
581
+ let prevUpper = 0;
582
+ for (const b of [...LAT_BOUNDS.map(String), 'over']) {
583
+ const c = pyGet(buckets, b, 0);
584
+ if (pyTruthy(c) && cum + c >= target) {
585
+ const upper = b === 'over' ? LAT_BOUNDS[LAT_BOUNDS.length - 1] : Number(b);
586
+ const frac = (target - cum) / c;
587
+ return pyRound(prevUpper + (upper - prevUpper) * frac, 1);
588
+ }
589
+ cum += c;
590
+ prevUpper = b === 'over' ? LAT_BOUNDS[LAT_BOUNDS.length - 1] : Number(b);
591
+ }
592
+ return null;
593
+ }
594
+
595
+ /** max 近似:最大非空桶的上界(引擎不存逐请求样本,接受近似) */
596
+ export function _max_from_buckets(buckets) {
597
+ const keys = [...LAT_BOUNDS.map(String), 'over'];
598
+ for (let i = keys.length - 1; i >= 0; i -= 1) {
599
+ const b = keys[i];
600
+ if (pyTruthy(pyGet(buckets, b, 0))) {
601
+ return b === 'over' ? LAT_BOUNDS[LAT_BOUNDS.length - 1] : Number(b);
602
+ }
603
+ }
604
+ return null;
605
+ }
606
+
607
+ /** 从一组累计计数算指标 dict(分位数由分桶插值近似) */
608
+ export function _metrics(stat, duration_s, peak_rps = null) {
609
+ const m = {
610
+ total: stat.count,
611
+ errors: stat.err,
612
+ err_rate: pyTruthy(stat.count) ? pyRound(stat.err / stat.count, 4) : 0.0,
613
+ rps_avg: duration_s > 0 ? pyRound(stat.count / duration_s, 2) : 0.0,
614
+ err_breakdown: { ...stat.err_cls },
615
+ p50: _pct_from_buckets(stat.buckets, 50),
616
+ p90: _pct_from_buckets(stat.buckets, 90),
617
+ p99: _pct_from_buckets(stat.buckets, 99),
618
+ max: _max_from_buckets(stat.buckets),
619
+ };
620
+ if (peak_rps !== null) m.rps_peak = pyRound(peak_rps, 2);
621
+ return m;
622
+ }
623
+
624
+ /**
625
+ * 线程安全计数器:累计(报告用)+ 当秒窗口(快照用)。
626
+ *
627
+ * 结构:`{组名 或 "__all__": {"count", "err", "err_cls", "buckets"}}`;
628
+ * 延迟分桶只收成功请求,错误按 4xx/5xx/timeout/conn_err 分类计数。
629
+ *
630
+ * Node 侧无锁:`add`/`take_window`/`copy_total` 全程同步(不含 `await`),单线程事件循环下
631
+ * 天然原子——Python 的 `threading.Lock` 在这里没有对应物,也没有必要。
632
+ */
633
+ export class _Agg {
634
+ constructor(groupNames) {
635
+ const keys = [...groupNames, '__all__'];
636
+ this.total = {};
637
+ this.window = {};
638
+ for (const k of keys) {
639
+ this.total[k] = _Agg._new_group();
640
+ this.window[k] = _Agg._new_group();
641
+ }
642
+ }
643
+
644
+ static _new_group() {
645
+ const errCls = {};
646
+ for (const k of ERR_CLASSES) errCls[k] = 0;
647
+ const buckets = {};
648
+ for (const b of [...LAT_BOUNDS, 'over']) buckets[String(b)] = 0;
649
+ return { count: 0, err: 0, err_cls: errCls, buckets };
650
+ }
651
+
652
+ /** 记一次请求结果(成功带分桶键,失败带错误分类),累计+当秒窗口各记一次 */
653
+ add(group, ok, bucket = null, errCls = null) {
654
+ for (const tgt of [this.total, this.window]) {
655
+ for (const key of [group, '__all__']) {
656
+ if (!Object.prototype.hasOwnProperty.call(tgt, key)) {
657
+ throw new KeyError(_pyReprString(key)); // Python:tgt[key] → KeyError
658
+ }
659
+ const g = tgt[key];
660
+ g.count += 1;
661
+ if (ok) {
662
+ if (!Object.prototype.hasOwnProperty.call(g.buckets, bucket)) {
663
+ throw new KeyError(_pyReprString(bucket));
664
+ }
665
+ g.buckets[bucket] += 1;
666
+ } else {
667
+ g.err += 1;
668
+ if (!Object.prototype.hasOwnProperty.call(g.err_cls, errCls)) {
669
+ throw new KeyError(_pyReprString(errCls));
670
+ }
671
+ g.err_cls[errCls] += 1;
672
+ }
673
+ }
674
+ }
675
+ }
676
+
677
+ /** 原子取走当秒窗口并换新,返回窗口快照 dict */
678
+ take_window() {
679
+ const win = this.window;
680
+ const fresh = {};
681
+ for (const k of Object.keys(win)) fresh[k] = _Agg._new_group();
682
+ this.window = fresh;
683
+ return win;
684
+ }
685
+
686
+ /** 累计计数的深拷贝(阶段差值/最终报告用) */
687
+ copy_total() {
688
+ const out = {};
689
+ for (const [k, v] of Object.entries(this.total)) {
690
+ out[k] = {
691
+ count: v.count, err: v.err, err_cls: { ...v.err_cls }, buckets: { ...v.buckets },
692
+ };
693
+ }
694
+ return out;
695
+ }
696
+ }
697
+
698
+ // --------------------------------------------------------------------------
699
+ // 规范指标通道
700
+ // --------------------------------------------------------------------------
701
+
702
+ /**
703
+ * 规范化指标通道写入器(一行一样本,逐行 flush)。
704
+ *
705
+ * 契约(与解析侧 `loadcase.normalize_line` 一致):
706
+ * - `sample(metric, value, options)`:point 样本,`gauge`=瞬时值 / `counter`=单调递增累计值;
707
+ * - `meta(fields)`:上下文行(目标地址、档位等),可省略;
708
+ * - `summary(values)`:收尾 KPI 字典,取最后一条。
709
+ *
710
+ * 通道不可写时静默降级——指标落盘失败不该中断压测本身。(Python 用 `threading.Lock`;
711
+ * Node 单线程下 `fs.writeSync` 本身就是一次完整的写,无需锁。)
712
+ */
713
+ export class MetricsWriter {
714
+ constructor(filePath) {
715
+ this.path = filePath;
716
+ this._f = null;
717
+ try {
718
+ this._f = fs.openSync(filePath, 'a'); // Python: open(path, "ab")
719
+ } catch {
720
+ this._f = null;
721
+ }
722
+ }
723
+
724
+ /** 关闭底层文件(幂等;重复调用安全) */
725
+ close() {
726
+ if (this._f !== null) {
727
+ try {
728
+ fs.closeSync(this._f);
729
+ } catch { /* 照 Python:吞 OSError */ }
730
+ this._f = null;
731
+ }
732
+ }
733
+
734
+ /** 写一行 JSON(失败静默,通道是尽力而为的附属产物) */
735
+ _write(obj) {
736
+ if (this._f === null) return;
737
+ try {
738
+ fs.writeSync(this._f, Buffer.from(`${pyJsonDumps(obj)}\n`, 'utf8'));
739
+ } catch { /* 照 Python:吞 OSError */ }
740
+ }
741
+
742
+ /** 标签归一:只保留标量值并统一键为字符串(与校验/解析口径一致) */
743
+ static _norm_labels(labels) {
744
+ const out = {};
745
+ for (const [k, v] of Object.entries(pyTruthy(labels) ? labels : {})) {
746
+ const scalar = typeof v === 'string' || typeof v === 'number';
747
+ if (typeof v === 'boolean' || !scalar) continue;
748
+ out[pyStr(k)] = v;
749
+ }
750
+ return out;
751
+ }
752
+
753
+ /**
754
+ * 写一条样本;value 非数字或 type 非法时丢弃(不抛异常给压测主流程)。
755
+ *
756
+ * `options` = Python 的关键字形参 `(type="gauge", labels=None, ts=None)`;`ts` 非 None 时按
757
+ * `float(ts)` 转(**这一步的异常照 Python 往外抛**,因为它不在丢弃分支里)。
758
+ */
759
+ sample(metric, value, options = {}) {
760
+ let type = pyGet(options, 'type', 'gauge');
761
+ if (!(type === 'gauge' || type === 'counter')) type = 'gauge';
762
+ let val;
763
+ try {
764
+ val = py_float(value);
765
+ } catch (e) {
766
+ if (e instanceof TypeError || e instanceof ValueError) return;
767
+ throw e;
768
+ }
769
+ const tsRaw = pyGet(options, 'ts', null);
770
+ const obj = {
771
+ ts: tsRaw !== null ? py_float(tsRaw) : Date.now() / 1000,
772
+ metric: pyStr(metric),
773
+ value: val,
774
+ type,
775
+ };
776
+ const lb = MetricsWriter._norm_labels(pyGet(options, 'labels', null));
777
+ if (pyTruthy(lb)) obj.labels = lb;
778
+ this._write(obj);
779
+ }
780
+
781
+ /**
782
+ * 瞬时值样本(标签以末位对象给出,如 `w.gauge("rps", 10, {group: "下单"})`)。
783
+ *
784
+ * `ts` 是**样本时间戳**(缺省取写入时刻),不是标签——末位对象的 `ts` 键被显式解构掉,
785
+ * 绝不会落进 labels(历史缺陷:签名缺 ts 时 `w.gauge(..., ts=t)` 会把 t 写成维度,
786
+ * 见 bug_report/20261004_2040)。Python 是 `(metric, value, ts=None, **labels)`。
787
+ */
788
+ gauge(metric, value, kwargs = {}) {
789
+ const { ts = null, ...labels } = kwargs ?? {};
790
+ this.sample(metric, value, { type: 'gauge', labels, ts });
791
+ }
792
+
793
+ /** 累计计数样本(值须单调不减,如委托数/总请求数);`ts` 语义同 gauge */
794
+ counter(metric, value, kwargs = {}) {
795
+ const { ts = null, ...labels } = kwargs ?? {};
796
+ this.sample(metric, value, { type: 'counter', labels, ts });
797
+ }
798
+
799
+ /** 写一条 meta 上下文行(None 值字段丢弃) */
800
+ meta(fields = {}) {
801
+ const obj = { kind: 'meta', ts: Date.now() / 1000 };
802
+ for (const [k, v] of Object.entries(fields ?? {})) {
803
+ if (v !== null && v !== undefined) obj[k] = v;
804
+ }
805
+ this._write(obj);
806
+ }
807
+
808
+ /** 写收尾 KPI 行(values 必须是 dict;空白/非 dict 忽略) */
809
+ summary(values) {
810
+ if (!(values !== null && typeof values === 'object' && !Array.isArray(values))
811
+ || Object.keys(values).length === 0) {
812
+ return;
813
+ }
814
+ const out = {};
815
+ for (const [k, v] of Object.entries(values)) out[pyStr(k)] = v;
816
+ this._write({ kind: 'summary', ts: Date.now() / 1000, values: out });
817
+ }
818
+ }
819
+
820
+ /**
821
+ * 把当秒窗口 + 累计计数写成一屏规范样本(口径与默认面板/模板一致)。
822
+ *
823
+ * 全局:rps / err.rate / lat.p50|p90|p99 / conc + 累计 req.total / req.errors;
824
+ * 分接口:rps / err.rate / lat.p95,带 labels={"group": 组名}。
825
+ */
826
+ export function _emit_tick(w, ts, conc, win, total) {
827
+ const allw = win.__all__;
828
+ const count = py_float(allw.count);
829
+ w.sample('rps', count, { ts });
830
+ w.sample('err.rate', pyTruthy(count) ? allw.err / count : 0.0, { ts });
831
+ w.sample('conc', pyInt(conc), { ts });
832
+ for (const pct of [50, 90, 99]) {
833
+ const val = _pct_from_buckets(allw.buckets, pct);
834
+ if (val !== null) w.sample(`lat.p${pct}`, val, { ts });
835
+ }
836
+ for (const [name, g] of Object.entries(win)) {
837
+ if (name === '__all__') continue;
838
+ const gCount = py_float(g.count);
839
+ w.sample('rps', gCount, { labels: { group: name }, ts });
840
+ w.sample('err.rate', pyTruthy(gCount) ? g.err / gCount : 0.0, { labels: { group: name }, ts });
841
+ const gP95 = _pct_from_buckets(g.buckets, 95);
842
+ if (gP95 !== null) w.sample('lat.p95', gP95, { labels: { group: name }, ts });
843
+ }
844
+ const tAll = total.__all__;
845
+ w.counter('req.total', tAll.count, { ts });
846
+ w.counter('req.errors', tAll.err, { ts });
847
+ }
848
+
849
+ // --------------------------------------------------------------------------
850
+ // 发压
851
+ // --------------------------------------------------------------------------
852
+
853
+ /**
854
+ * Python `str.encode("latin-1")`:非 latin-1 字符抛 `UnicodeEncodeError`(照 `http.client._encode`)。
855
+ *
856
+ * 为什么必须按 latin-1 而不是 utf-8:`http.client` 对 str body / str header 值一律走
857
+ * `_encode(...) = data.encode("latin-1")`(RFC 2616 §3.7.1 的默认字符集),utf-8 会在线上
858
+ * 发出**不同的字节**;非 latin-1 字符则是 Python 抛 `UnicodeEncodeError`、线程死掉。
859
+ * 文案照 CPython:单个字符 `character in position N`,连续多个 `characters in position a-b`。
860
+ */
861
+ function _latin1(text) {
862
+ const s = String(text);
863
+ let start = -1;
864
+ for (let i = 0; i <= s.length; i += 1) {
865
+ const bad = i < s.length && s.charCodeAt(i) > 0xff;
866
+ if (bad && start < 0) start = i;
867
+ if (!bad && start >= 0) {
868
+ const span = i - 1 === start ? `character in position ${start}`
869
+ : `characters in position ${start}-${i - 1}`;
870
+ throw new UnicodeEncodeError(`'latin-1' codec can't encode ${span}: `
871
+ + 'ordinal not in range(256)');
872
+ }
873
+ }
874
+ return Buffer.from(s, 'latin1');
875
+ }
876
+
877
+ /** 按解析后的 base 建一条 keep-alive 连接(Python: `HTTPConnection/HTTPSConnection`) */
878
+ function _new_conn(base, timeout_s) {
879
+ const mod = base.scheme === 'https' ? https : http;
880
+ const port = pyTruthy(base.port) ? base.port : (base.scheme === 'https' ? 443 : 80);
881
+ const agent = new mod.Agent({ keepAlive: true, maxSockets: 1, maxFreeSockets: 1 });
882
+ return {
883
+ mod, agent, hostname: base.hostname, port, timeout_s, broken: false,
884
+ };
885
+ }
886
+
887
+ /** 关掉一条连接(Python: `conn.close()`) */
888
+ function _close_conn(conn) {
889
+ try {
890
+ conn.agent.destroy();
891
+ } catch { /* 已关 */ }
892
+ }
893
+
894
+ /**
895
+ * 在给定连接上发一次请求,返回 `{conn, ok, bucket, err_cls}`。
896
+ *
897
+ * conn 传 null 或遇到连接类错误时返回 `conn: null`(调用方下轮重建连接);
898
+ * 响应体必须读掉(`res.resume()`),否则 keep-alive 连接无法复用(照 `resp.read()`)。
899
+ *
900
+ * 一处照抄 Python 的关键细节:**超时后连接留在「半开」状态**(Python 的 `HTTPConnection`
901
+ * 停在 `_CS_REQ_SENT`,sock 未关),下一次 `request()` 抛 `CannotSendRequest`(HTTPException)
902
+ * ⇒ 记一次 `conn_err` 再重建。不照抄的话,超时后的 err_breakdown 会比 oracle 少一次 conn_err。
903
+ * Node 侧额外 destroy 掉那条 socket(Python 把它留到下次 `close()`):只为不让悬挂 handle
904
+ * 拖住进程退出,分类结果不变。
905
+ */
906
+ function _one_request(base, conn, group, timeout_s) {
907
+ if (conn === null) conn = _new_conn(base, timeout_s);
908
+ else if (conn.broken) {
909
+ _close_conn(conn);
910
+ return Promise.resolve({
911
+ conn: null, ok: false, bucket: null, err_cls: 'conn_err',
912
+ });
913
+ }
914
+ const method = group.method;
915
+ const reqPath = group.path;
916
+ const withBody = !(method === 'GET' || method === 'HEAD');
917
+ const body = withBody ? pyGet(group, 'body', null) : null;
918
+ const headers = {};
919
+ for (const [k, v] of Object.entries(pyGet(group, 'headers', null) ?? {})) {
920
+ headers[k] = typeof v === 'string' ? _latin1(v) : pyStr(v);
921
+ }
922
+ let bodyBuf = null;
923
+ if (body !== null && body !== undefined) bodyBuf = _latin1(body);
924
+
925
+ const t0 = process.hrtime.bigint();
926
+ return new Promise((resolve) => {
927
+ let settled = false;
928
+ let timedOut = false;
929
+ const finish = (payload) => {
930
+ if (settled) return;
931
+ settled = true;
932
+ resolve(payload);
933
+ };
934
+ const connErr = () => {
935
+ if (settled) return; // 已结算(成功/4xx/5xx/超时)后的事件不再动连接
936
+ _close_conn(conn);
937
+ conn.broken = true;
938
+ finish({
939
+ conn: null, ok: false, bucket: null, err_cls: 'conn_err',
940
+ });
941
+ };
942
+ let req;
943
+ try {
944
+ req = conn.mod.request({
945
+ hostname: conn.hostname,
946
+ port: conn.port,
947
+ method,
948
+ path: reqPath,
949
+ headers,
950
+ agent: conn.agent,
951
+ }, (res) => {
952
+ const status = res.statusCode;
953
+ res.resume(); // 丢弃响应体(照 resp.read())
954
+ res.on('end', () => {
955
+ const ms = Number(process.hrtime.bigint() - t0) / 1e6;
956
+ if (status >= 200 && status < 400) { // 3xx 视为成功(压测不跟随重定向)
957
+ finish({
958
+ conn, ok: true, bucket: _bucket_of(ms), err_cls: null,
959
+ });
960
+ } else {
961
+ finish({
962
+ conn, ok: false, bucket: null, err_cls: status < 500 ? '4xx' : '5xx',
963
+ });
964
+ }
965
+ });
966
+ res.on('aborted', connErr);
967
+ res.on('error', connErr);
968
+ });
969
+ } catch (e) {
970
+ connErr();
971
+ return;
972
+ }
973
+ req.setTimeout(timeout_s * 1000, () => {
974
+ if (settled) return; // 响应已结算:这是 keep-alive 空闲 socket 的过期 timeout,不动它
975
+ timedOut = true;
976
+ // Python 超时后不关闭连接(留在 _CS_REQ_SENT);这里只销毁 socket 释放 handle,
977
+ // 并用 broken 标记让下一次请求产出同样的 conn_err。
978
+ try {
979
+ req.destroy();
980
+ } catch { /* 已销毁 */ }
981
+ conn.broken = true;
982
+ finish({
983
+ conn, ok: false, bucket: null, err_cls: 'timeout',
984
+ });
985
+ });
986
+ req.on('error', (e) => {
987
+ if (timedOut || settled) return; // 已按 timeout/响应结算
988
+ connErr();
989
+ });
990
+ if (bodyBuf !== null) req.write(bodyBuf);
991
+ req.end();
992
+ });
993
+ }
994
+
995
+ /** 按 weight 加权随机选一组(权重越大被压得越多) */
996
+ export function _pick_group(groups) {
997
+ let total = 0;
998
+ for (const g of groups) total += g.weight;
999
+ let x = Math.floor(Math.random() * total); // Python: random.randrange(total)
1000
+ for (const g of groups) {
1001
+ x -= g.weight;
1002
+ if (x < 0) return g;
1003
+ }
1004
+ return groups[groups.length - 1];
1005
+ }
1006
+
1007
+ /**
1008
+ * 单工作 worker:keep-alive 连接循环发请求,直到档位截止或 stop() 为真。
1009
+ *
1010
+ * `UnicodeEncodeError` 照 Python 的线程语义**只结束本 worker**(Python 里该异常顺着 `_worker`
1011
+ * 逃逸、线程静默死掉,`t.join()` 照样返回、其余 worker 照跑)。
1012
+ */
1013
+ async function _worker(base, groups, timeout_s, deadline, agg, stop) {
1014
+ let conn = null;
1015
+ while (Date.now() / 1000 < deadline && !stop()) {
1016
+ const g = _pick_group(groups);
1017
+ let res;
1018
+ try {
1019
+ res = await _one_request(base, conn, g, timeout_s); // eslint-disable-line no-await-in-loop
1020
+ } catch (e) {
1021
+ if (e instanceof UnicodeEncodeError) return;
1022
+ throw e;
1023
+ }
1024
+ conn = res.conn;
1025
+ agg.add(g.name, res.ok, res.bucket, res.err_cls);
1026
+ }
1027
+ }
1028
+
1029
+ /** 追加文本到日志文件(utf-8,失败静默——日志是尽力而为的附属产物) */
1030
+ function _append(filePath, text) {
1031
+ try {
1032
+ fs.appendFileSync(filePath, Buffer.from(text, 'utf8'));
1033
+ } catch { /* 照 Python:吞 OSError */ }
1034
+ }
1035
+
1036
+ const _sleep = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); });
1037
+
1038
+ /**
1039
+ * 执行压测(直到全部档位完成 / should_stop / 快速失败)。
1040
+ *
1041
+ * 返回 `[report, summary_line]`。快照每秒一行追加 snap_path(旧格式,保留);metrics_path 非空
1042
+ * 时同步产出规范指标行(新前端读它),开头补 meta 行、收尾补 summary 行(快速失败/停止同样
1043
+ * 收尾)。人类可读进度每 10 秒追加 log_path。目标不可达抛 QuickFailError。
1044
+ *
1045
+ * `options` = Python 的关键字形参 `(log_path, snap_path, should_stop=None, metrics_path=None)`。
1046
+ * Node 侧是 async(见文件头 ①)。
1047
+ */
1048
+ export async function run_load(scenario, options = {}) {
1049
+ const logPath = pyGet(options, 'log_path', null);
1050
+ const snapPath = pyGet(options, 'snap_path', null);
1051
+ const shouldStop = pyGet(options, 'should_stop', null);
1052
+ const metricsPath = pyGet(options, 'metrics_path', null);
1053
+ const stop = shouldStop || (() => false);
1054
+ const base = _parse_base(scenario.base_url);
1055
+ const groups = scenario.groups;
1056
+ const timeoutS = scenario.timeout_ms / 1000.0;
1057
+ const agg = new _Agg(groups.map((g) => g.name));
1058
+ const started = Date.now() / 1000;
1059
+ let peakRps = 0.0;
1060
+ let aborted = false; // 快速失败标志
1061
+ let done = false; // 整个发压结束标志(通知 ticker 退出)
1062
+ const curStage = [0, 0]; // 当前 [档位号, 并发数](ticker 读)
1063
+ const stageStats = []; // 每档结束时的差值指标
1064
+
1065
+ const snapFd = fs.openSync(snapPath, 'a'); // Python: open(snap_path, "ab"),失败即抛
1066
+ const w = pyTruthy(metricsPath) ? new MetricsWriter(metricsPath) : null;
1067
+ if (w) {
1068
+ w.meta({
1069
+ target: scenario.base_url,
1070
+ case: scenario.name,
1071
+ stages: scenario.stages.map((s) => ({ conc: s.conc, seconds: s.seconds })),
1072
+ });
1073
+ }
1074
+
1075
+ /** 每秒:取走当秒窗口 → 快照行 + 规范指标行 + 每 10s 进度日志 + 快速失败判定 */
1076
+ const ticker = (async () => {
1077
+ let n = 0;
1078
+ const quick = { count: 0, err: 0, conn_like: 0 };
1079
+ while (!done) {
1080
+ await _sleep(1000.0); // eslint-disable-line no-await-in-loop
1081
+ if (done) break;
1082
+ const win = agg.take_window();
1083
+ const allw = win.__all__;
1084
+ n += 1;
1085
+ peakRps = Math.max(peakRps, py_float(allw.count));
1086
+ const now = Math.trunc(Date.now() / 1000); // Python: int(time.time())
1087
+ const line = {
1088
+ ts: now,
1089
+ stage: curStage[0],
1090
+ conc: curStage[1],
1091
+ rps: py_float(allw.count),
1092
+ err: allw.err,
1093
+ groups: Object.fromEntries(Object.entries(win)
1094
+ .filter(([k]) => k !== '__all__')
1095
+ .map(([k, v]) => [k, { rps: py_float(v.count), err: v.err, lat: { ...v.buckets } }])),
1096
+ };
1097
+ try {
1098
+ fs.writeSync(snapFd, Buffer.from(`${pyJsonDumps(line)}\n`, 'utf8'));
1099
+ } catch { /* 照 Python:吞 OSError */ }
1100
+ if (w) _emit_tick(w, now, curStage[1], win, agg.copy_total());
1101
+ if (n % 10 === 0) {
1102
+ _append(logPath, `### 进度 t=${n}s 档${curStage[0]} RPS=${allw.count} 错误=${allw.err}\n`);
1103
+ }
1104
+ // 快速失败观察窗累计(连接超时/失败 视为「目标不可达」类)
1105
+ if (n <= QUICK_FAIL_SECONDS) {
1106
+ quick.count += allw.count;
1107
+ quick.err += allw.err;
1108
+ quick.conn_like += allw.err_cls.conn_err + allw.err_cls.timeout;
1109
+ if (n === QUICK_FAIL_SECONDS && quick.count > 0
1110
+ && quick.err === quick.count && quick.conn_like === quick.err) {
1111
+ aborted = true;
1112
+ done = true;
1113
+ }
1114
+ }
1115
+ }
1116
+ })();
1117
+
1118
+ try {
1119
+ for (let i = 0; i < scenario.stages.length; i += 1) {
1120
+ const st = scenario.stages[i];
1121
+ if (stop() || aborted) break;
1122
+ curStage[0] = i + 1;
1123
+ curStage[1] = st.conc;
1124
+ const t0Copy = agg.copy_total();
1125
+ const deadline = Date.now() / 1000 + st.seconds;
1126
+ const workers = [];
1127
+ for (let k = 0; k < st.conc; k += 1) {
1128
+ // 挂一个兜底 catch:Python 的线程异常只打 traceback、run_load 照常返回;
1129
+ // 不吞掉的话 Promise.all 会把整轮发压判失败(那是**更响**但不等价的行为)。
1130
+ const p = _worker(base, groups, timeoutS, deadline, agg, () => stop() || aborted)
1131
+ .catch((e) => {
1132
+ process.stderr.write(`Exception in thread worker: ${e && e.message ? e.message : e}\n`);
1133
+ });
1134
+ workers.push(p);
1135
+ }
1136
+ await Promise.all(workers); // eslint-disable-line no-await-in-loop
1137
+ const dt = agg.copy_total();
1138
+ // 档位指标一律取当档差值(count/err/err_cls/buckets 逐键 dt − t0),
1139
+ // 否则多档位时各档分位数/错误分类会混入前档累计数据
1140
+ const t0a = t0Copy.__all__;
1141
+ const dta = dt.__all__;
1142
+ const deltaAll = {
1143
+ count: dta.count - t0a.count,
1144
+ err: dta.err - t0a.err,
1145
+ err_cls: Object.fromEntries(Object.keys(dta.err_cls)
1146
+ .map((k) => [k, dta.err_cls[k] - t0a.err_cls[k]])),
1147
+ buckets: Object.fromEntries(Object.keys(dta.buckets)
1148
+ .map((k) => [k, dta.buckets[k] - t0a.buckets[k]])),
1149
+ };
1150
+ stageStats.push({
1151
+ conc: st.conc, seconds: st.seconds, ..._metrics(deltaAll, st.seconds),
1152
+ });
1153
+ }
1154
+ } finally {
1155
+ done = true;
1156
+ await ticker;
1157
+ fs.closeSync(snapFd);
1158
+ }
1159
+ if (aborted) {
1160
+ if (w) {
1161
+ const t = agg.copy_total().__all__;
1162
+ w.summary({ 总请求: t.count, 错误数: t.err, 结果: '快速失败' });
1163
+ w.close();
1164
+ }
1165
+ throw new QuickFailError('快速失败:发压前 10 秒请求全部为连接超时/失败,'
1166
+ + '目标服务可能不可达');
1167
+ }
1168
+
1169
+ const duration = Date.now() / 1000 - started;
1170
+ const total = agg.copy_total();
1171
+ const report = {
1172
+ scenario: scenario.name,
1173
+ base_url: scenario.base_url,
1174
+ started_at: strftime_local(new Date(started * 1000), '%Y-%m-%d %H:%M:%S'),
1175
+ duration_sec: pyRound(duration, 1),
1176
+ stopped: pyTruthy(stop()),
1177
+ stages: stageStats,
1178
+ overall: _metrics(total.__all__, duration, peakRps),
1179
+ groups: Object.fromEntries(groups.map((g) => [g.name, _metrics(total[g.name], duration)])),
1180
+ };
1181
+ const o = report.overall;
1182
+ const summaryLine = `LOAD 总请求=${o.total} 错误率=${_pyFixed(o.err_rate * 100, 1)}%`
1183
+ + ` P50=${py_field_str(o.p50)}ms P99=${py_field_str(o.p99)}ms 平均RPS=${py_field_str(o.rps_avg)}`
1184
+ + (report.stopped ? '(用户停止)' : '');
1185
+ if (w) {
1186
+ // 收尾 KPI:与 report.overall 同源,供面板单值卡与脚本驱动报告共用
1187
+ w.summary({
1188
+ 总请求: o.total,
1189
+ 错误率: `${_pyFixed(o.err_rate * 100, 2)}%`,
1190
+ 平均RPS: o.rps_avg,
1191
+ 峰值RPS: pyGet(o, 'rps_peak', 0),
1192
+ P50: o.p50,
1193
+ P90: o.p90,
1194
+ P99: o.p99,
1195
+ });
1196
+ w.close();
1197
+ }
1198
+ return [report, summaryLine];
1199
+ }
1200
+
1201
+ // --------------------------------------------------------------------------
1202
+ // 报告
1203
+ // --------------------------------------------------------------------------
1204
+
1205
+ /** 错误分类 dict → "4xx=1 5xx=0 超时=2 连接=3" 形式的短文本 */
1206
+ export function _fmt_errs(err_cls) {
1207
+ return `4xx=${pyGet(err_cls, '4xx', 0)} 5xx=${pyGet(err_cls, '5xx', 0)}`
1208
+ + ` 超时=${pyGet(err_cls, 'timeout', 0)} 连接=${pyGet(err_cls, 'conn_err', 0)}`;
1209
+ }
1210
+
1211
+ /** 报告 Markdown 文本:总览 + 档位 + 分接口三张表 */
1212
+ export function _report_md(report) {
1213
+ const o = report.overall;
1214
+ const lines = [
1215
+ `# 压测报告:${report.scenario}`, '',
1216
+ `- 目标:${report.base_url}`,
1217
+ `- 开始:${report.started_at} · 时长 ${py_field_str(report.duration_sec)}s`
1218
+ + (pyTruthy(pyGet(report, 'stopped', null)) ? '(用户停止,部分结果)' : ''), '',
1219
+ '## 总览', '',
1220
+ '| 指标 | 值 |', '|---|---|',
1221
+ `| 总请求 | ${o.total} |`,
1222
+ `| 错误率 | ${_pyFixed(o.err_rate * 100, 2)}% |`,
1223
+ `| 平均 RPS | ${py_field_str(o.rps_avg)} |`,
1224
+ `| 峰值 RPS | ${py_field_str(pyGet(o, 'rps_peak', '-'))} |`,
1225
+ `| P50 / P90 / P99 | ${py_field_str(o.p50)} / ${py_field_str(o.p90)} / ${py_field_str(o.p99)} ms |`,
1226
+ `| max(近似) | ${_pyIntFieldStr(o.max)} ms |`,
1227
+ `| 错误分类 | ${_fmt_errs(o.err_breakdown)} |`, '',
1228
+ '## 档位', '',
1229
+ '| 并发 | 时长(s) | RPS | 错误率 |', '|---|---|---|---|',
1230
+ ];
1231
+ for (const s of report.stages) {
1232
+ lines.push(`| ${s.conc} | ${s.seconds} | ${py_field_str(s.rps_avg)} | ${_pyFixed(s.err_rate * 100, 2)}% |`);
1233
+ }
1234
+ lines.push('', '## 分接口', '',
1235
+ '| 接口 | 请求 | 错误率 | P50 | P90 | P99 | max |', '|---|---|---|---|---|---|---|');
1236
+ for (const [name, g] of Object.entries(report.groups)) {
1237
+ lines.push(`| ${name} | ${g.total} | ${_pyFixed(g.err_rate * 100, 2)}%`
1238
+ + ` | ${py_field_str(g.p50)} | ${py_field_str(g.p90)} | ${py_field_str(g.p99)} | ${_pyIntFieldStr(g.max)} |`);
1239
+ }
1240
+ lines.push('');
1241
+ return lines.join('\n');
1242
+ }
1243
+
1244
+ /**
1245
+ * 报告落盘:`<report_dir>/task<id>_<运行键>.{json,md}`。返回 `[json_path, md_path]`。
1246
+ *
1247
+ * 文件名只含 task id + 运行键(场景名可能含中文/特殊字符,不进文件名);
1248
+ * 同任务重跑产生新运行键的文件,读侧按运行键/最新取用。
1249
+ * run_key 为空时回落分钟级命名(存量路径与单测用)。
1250
+ */
1251
+ export function write_report_files(report, report_dir, task_id, run_key = '') {
1252
+ fs.mkdirSync(report_dir, { recursive: true });
1253
+ const stem = path.join(report_dir,
1254
+ `task${task_id}_${run_key || strftime_local(new Date(), '%Y%m%d_%H%M')}`);
1255
+ fs.writeFileSync(`${stem}.json`, pyJsonDumpsIndent(report, 1), 'utf8');
1256
+ fs.writeFileSync(`${stem}.md`, _report_md(report), 'utf8');
1257
+ return [`${stem}.json`, `${stem}.md`];
1258
+ }
1259
+
1260
+ // --------------------------------------------------------------------------
1261
+ // 命令行入口(平台发压轮的 scenario 驱动调用)
1262
+ // --------------------------------------------------------------------------
1263
+
1264
+ const _DEVNULL = process.platform === 'win32' ? 'nul' : '/dev/null';
1265
+
1266
+ /**
1267
+ * run 子命令:校验场景 → 发压 → 报告落盘,返回进程退出码。
1268
+ *
1269
+ * 退出码 0=正常 / 2=场景非法 / 3=快速失败 / 4=其它异常(runner 据此判 failed)。
1270
+ * stdout 输出一行 LOAD 摘要(平台把它留在发压日志里),stderr 输出失败原因。
1271
+ */
1272
+ export async function _cmd_run(args) {
1273
+ const [scenario, err] = validate_scenario(args.scenario);
1274
+ if (err) {
1275
+ process.stderr.write(`场景校验失败:${err}\n`);
1276
+ return 2;
1277
+ }
1278
+ const shouldStop = args.stop_file
1279
+ ? (() => fs.existsSync(args.stop_file))
1280
+ : null;
1281
+ let report;
1282
+ let summaryLine;
1283
+ try {
1284
+ [report, summaryLine] = await run_load(scenario, {
1285
+ log_path: args.log || _DEVNULL,
1286
+ snap_path: args.snapshot || _DEVNULL,
1287
+ should_stop: shouldStop,
1288
+ metrics_path: args.metrics || null,
1289
+ });
1290
+ } catch (e) {
1291
+ if (e instanceof QuickFailError) {
1292
+ process.stderr.write(`${e.message}\n`);
1293
+ return 3;
1294
+ }
1295
+ process.stderr.write(`发压异常:${e && e.message !== undefined ? e.message : e}\n`);
1296
+ return 4;
1297
+ }
1298
+ if (args.report_dir && args.task_id) {
1299
+ try {
1300
+ write_report_files(report, args.report_dir, args.task_id, args.run_key || '');
1301
+ } catch (e) {
1302
+ if (!_isOsError(e)) throw e;
1303
+ process.stderr.write(`报告写盘失败:${py_error_str(e)}\n`);
1304
+ }
1305
+ }
1306
+ process.stdout.write(`${summaryLine}\n`);
1307
+ return 0;
1308
+ }
1309
+
1310
+ /** `run` 子命令的形参(argparse 的 `--flag value` / `--flag=value` 两种写法都认) */
1311
+ const RUN_FLAGS = new Map([
1312
+ ['--scenario', 'scenario'],
1313
+ ['--metrics', 'metrics'],
1314
+ ['--snapshot', 'snapshot'],
1315
+ ['--log', 'log'],
1316
+ ['--stop-file', 'stop_file'],
1317
+ ['--report-dir', 'report_dir'],
1318
+ ['--task-id', 'task_id'],
1319
+ ['--run-key', 'run_key'],
1320
+ ]);
1321
+
1322
+ /**
1323
+ * 手写 argv 解析(Python 用 argparse)。
1324
+ *
1325
+ * 只复刻**对外行为**:`run` 子命令必填、`--scenario` 必填、未知参数报错,错误一律
1326
+ * stderr + 退出码 2(argparse 的 `usage:` 文案不逐字节复刻——那是帮助文本不是行为契约,
1327
+ * 对拍用例里两条 argparse 错误路径只钉退出码)。
1328
+ */
1329
+ function _parseRunArgv(argv) {
1330
+ const out = {
1331
+ scenario: null, metrics: '', snapshot: '', log: '', stop_file: '',
1332
+ report_dir: '', task_id: 0, run_key: '',
1333
+ };
1334
+ const bad = (msg) => {
1335
+ process.stderr.write(`loadgen.js: error: ${msg}\n`);
1336
+ return null;
1337
+ };
1338
+ for (let i = 0; i < argv.length; i += 1) {
1339
+ let key = argv[i];
1340
+ let value = null;
1341
+ const eq = key.indexOf('=');
1342
+ if (key.startsWith('--') && eq > 0) {
1343
+ value = key.slice(eq + 1);
1344
+ key = key.slice(0, eq);
1345
+ }
1346
+ if (key === '-h' || key === '--help') {
1347
+ process.stdout.write('usage: loadgen.js run --scenario SCENARIO [--metrics M] '
1348
+ + '[--snapshot S] [--log L] [--stop-file F] [--report-dir D] [--task-id N] [--run-key K]\n');
1349
+ return 'help';
1350
+ }
1351
+ const field = RUN_FLAGS.get(key);
1352
+ if (!field) return bad(`unrecognized arguments: ${argv[i]}`);
1353
+ if (value === null) {
1354
+ i += 1;
1355
+ if (i >= argv.length) return bad(`argument ${key}: expected one argument`);
1356
+ value = argv[i];
1357
+ }
1358
+ if (field === 'task_id') {
1359
+ if (!/^[+-]?[0-9]+$/.test(value)) return bad(`argument --task-id: invalid int value: '${value}'`);
1360
+ out.task_id = Number(value);
1361
+ } else {
1362
+ out[field] = value;
1363
+ }
1364
+ }
1365
+ if (out.scenario === null) return bad('the following arguments are required: --scenario');
1366
+ return out;
1367
+ }
1368
+
1369
+ /**
1370
+ * 命令行入口:`node backend/lib/loadgen.js run --scenario … --metrics …`。
1371
+ * 返回退出码(照 Python 的 `sys.exit(main())`)。
1372
+ */
1373
+ export async function main(argv = null) {
1374
+ const args = argv === null ? process.argv.slice(2) : argv;
1375
+ if (args.length === 0 || args[0] !== 'run') {
1376
+ process.stderr.write(`loadgen.js: error: the following arguments are required: cmd`
1377
+ + `(可用:run;收到 ${JSON.stringify(args[0] ?? null)})\n`);
1378
+ return 2;
1379
+ }
1380
+ const parsed = _parseRunArgv(args.slice(1));
1381
+ if (parsed === null) return 2;
1382
+ if (parsed === 'help') return 0;
1383
+ return _cmd_run(parsed);
1384
+ }
1385
+
1386
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
1387
+ process.exitCode = await main();
1388
+ }
1389
+
1390
+ // --------------------------------------------------------------------------
1391
+ // camelCase 别名(JS 调用方用;量具按 Python 名反射,用不到这一套)
1392
+ // --------------------------------------------------------------------------
1393
+
1394
+ export {
1395
+ validate_scenario as validateScenario,
1396
+ write_report_files as writeReportFiles,
1397
+ run_load as runLoad,
1398
+ _cmd_run as _cmdRun,
1399
+ _bucket_of as _bucketOf,
1400
+ _pct_from_buckets as _pctFromBuckets,
1401
+ _max_from_buckets as _maxFromBuckets,
1402
+ _metrics as _metricsOf,
1403
+ _fmt_errs as _fmtErrs,
1404
+ _report_md as _reportMd,
1405
+ _pick_group as _pickGroup,
1406
+ _emit_tick as _emitTick,
1407
+ };