multi-tasks 3.2.3 → 3.4.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.
@@ -1,35 +1,110 @@
1
- //终端进度条渲染器(零依赖): 单行进度条, 每帧 \r 回行首 + 清行 + 重写, 不上移光标、不换行。
2
- //纯渲染, 不碰 cluster/fs。单行形态对"两帧之间有外部写入共享终端"鲁棒:
3
- //旧帧最多留一行滚动残留, 下一帧在当前行自愈(多行版靠 ESC[nA 数行, 外部写入挤偏光标会让旧帧永久滞留)。
4
- const ESC = '';
5
-
6
- const create = ({ stream, barWidth }) => {
7
- if (!stream) { throw new Error('progressView needs a stream'); };
8
- const width = barWidth || 30;
9
-
10
- const buildBarLine = ({ done, running, remaining }) => {
11
- let total = done + running + remaining;
12
- let filled = total > 0 ? Math.floor(width * done / total) : width;
13
- let bar = ''.repeat(filled) + '░'.repeat(width - filled);
14
- let pct = (total > 0 ? 100 * done / total : 100).toFixed(2).padStart(6);//两位小数, 定宽防行长抖动
15
- return `[${bar}] ${pct}% ${done}/${total} done, ${running} running`;
16
- };
17
-
18
- const render = ({ done, running, remaining }) => {
19
- //行比终端列宽还长会折行、\r 回不到帧首: 按列宽截断
20
- let cols = stream.columns || 80;
21
- let line = buildBarLine({ done, running, remaining });
22
- if (line.length > cols) {
23
- line = line.slice(0, cols);
24
- };
25
- stream.write(`\r${ESC}[2K${line}`);
26
- };
27
-
28
- const end = () => {//收尾换行: 进度条行定格, 后续输出(shell 提示符等)落在下一行
29
- stream.write('\n');
30
- };
31
-
32
- return { render, end };
33
- };
34
-
35
- module.exports = { create };
1
+ //终端进度条渲染器(零依赖): N 行帧——首行 bar+百分比固定 36 列(约 18 个中文字符宽, 不随终端宽度变), 末行实时计数(动态宽, 封顶 cols-1),
2
+ //中间可接 extraLines 附加行(如 worker 状态行, 同样封顶 cols-1); 帧行数帧间必须恒定(由调用方保证), 是原地重绘的前提。
3
+ //bar 行短则拖动窗口时几乎不触发终端折行重排(reflow), ESC[nA 数行更可信(占满整行方案在 conpty 重排下
4
+ //反复错位, 用户拍板改短行)。首帧直接写整帧(无光标控制); 后续帧 \r 回行首 + 清行 + 逐行 ESC[1A 上移清行(N-1 次) + 重写整帧。
5
+ //resize 重锚定(reanchor)是抹除式: 终端重排(reflow)后旧帧占用行数不可信, 按记录的上帧
6
+ //各行可见长度 ÷ 当前列宽算出实际占用行数, 上移回旧帧首行 + ESC[0J 清到屏尾, 下一帧从锚点重写——
7
+ //不做换行定格: 定格会把重排碎片留在滚动区, 连续拖动即满屏碎片(用户否掉的旧方案)
8
+ //已知取舍: 两帧之间若有外部写入共享终端, ESC[nA 数行会被挤偏, 旧帧可能滞留残留(用户确认接受)。
9
+ const ESC = '\x1B';
10
+ const fs = require('fs');
11
+ const RED = '\x1B[31m';
12
+ const GREEN = '\x1B[32m';
13
+ const YELLOW = '\x1B[33m';
14
+ const RESET_FG = '\x1B[39m';
15
+
16
+ const BAR_OVERHEAD = 10;//'[' + '] ' + 6 位百分比 + '%'
17
+ const BAR_COLS = 36;//bar 行固定 36 列(约 18 个中文字符宽, 用户拍板, 不随终端宽度变): 行短则拖动窗口时几乎不触发折行重排; info 行保持动态宽(计数器全显)
18
+
19
+ //仅渲染帧上色(writeLine/run.log 永远无色): bar 完成段绿 / done 数绿 / succ 数绿 / running 数黄 / failed 整词红 / succ-but-save-failed 数黄,
20
+ //为 0 不着色减少噪音。必须在按列宽截断之后调用: 颜色码不占可见宽度, 也不会被截断拆开
21
+ const colorizeFrame = (line) => {
22
+ return line
23
+ .replace(/\[([█]+)/, (m, blocks) => `[${GREEN}${blocks}${RESET_FG}`)
24
+ .replace(/(\d+) failed/, (m, n) => (n === '0' ? m : `${RED}${n} failed${RESET_FG}`))
25
+ .replace(/(\d+) succ-but-save-failed/, (m, n) => (n === '0' ? m : `${YELLOW}${n}${RESET_FG} succ-but-save-failed`))
26
+ .replace(/(\d+) running/, (m, n) => (n === '0' ? m : `${YELLOW}${n}${RESET_FG} running`))
27
+ .replace(/(\d+) succ(?=,|$)/, (m, n) => (n === '0' ? m : `${GREEN}${n}${RESET_FG} succ`))//(?=,|$) 防误配 succ-but-save-failed 的词头
28
+ .replace(/(\d+)\/(\d+) done/, (m, done, total) => (done === '0' ? m : `${GREEN}${done}${RESET_FG}/${total} done`));
29
+ };
30
+
31
+ const create = ({ stream }) => {
32
+ if (!stream) { throw new Error('progressView needs a stream'); };
33
+ let frameCount = 0;
34
+ let ended = false;
35
+ let lastLineLens = [];//上帧各行可见长度(截断后、上色前): resize 重排后算旧帧实际占用行数用
36
+
37
+ //TTY 现场排查开关: MULTI_TASKS_PROGRESS_DEBUG=文件路径 时, 每帧字节流+当时列宽记成 JSON 行,
38
+ //供 test/terminalSim.js 回放比对真实终端行为(仅排查用, 平时为空不写盘)
39
+ const debugPath = process.env.MULTI_TASKS_PROGRESS_DEBUG;
40
+ const write = (s) => {
41
+ if (debugPath) {
42
+ fs.appendFileSync(debugPath, `${JSON.stringify({ at: Date.now(), cols: stream.columns || 80, data: s })}\n`);
43
+ };
44
+ stream.write(s);
45
+ };
46
+
47
+ const buildLines = ({ done, succ, running, remaining, failed, succButSaveFailed, extraLines }) => {
48
+ //行比终端列宽还长会折行、光标定位错乱: 每行封顶 cols-1(截断后才上色);
49
+ //留 1 空列: 写满整列会触发终端满列换行(deferred wrap), resize 重排时 ESC[1A 数行失准;
50
+ //bar 行固定 BAR_COLS 列(窄终端整行截断, 行尾百分比会被截掉): 行短则拖动窗口时几乎不触发折行重排
51
+ const cols = stream.columns || 80;
52
+ const maxLineLen = Math.max(1, cols - 1);
53
+ const truncate = (line) => (line.length > maxLineLen ? line.slice(0, maxLineLen) : line);
54
+ let total = done + running + remaining;
55
+ let width = BAR_COLS - BAR_OVERHEAD;//固定 26 格, 不随终端宽度变
56
+ let filled = total > 0 ? Math.floor(width * done / total) : width;
57
+ let bar = '█'.repeat(filled) + '░'.repeat(width - filled);
58
+ let pct = (total > 0 ? 100 * done / total : 100).toFixed(2).padStart(6);//两位小数, 定宽防行长抖动
59
+ //末行实时计数器(done/succ/running/failed/succ-but-save-failed), 与收尾计数器行同源(都出自 getFinalStats)
60
+ let barLine = `[${bar}] ${pct}%`;
61
+ let infoLine = `${done}/${total} done, ${succ || 0} succ, ${running} running, ${failed || 0} failed, ${succButSaveFailed || 0} succ-but-save-failed`;
62
+ //extraLines(状态行等)插在 bar 行与计数器行之间(计数器行固定帧尾——用户拍板):
63
+ //帧行数必须帧间恒定(ESC[nA 上移数按新帧行数算, 变行数会错位), 由调用方(progressCtl)保证开启期间每帧都带
64
+ let lines = [barLine];
65
+ if (extraLines) {
66
+ lines = lines.concat(extraLines);
67
+ };
68
+ lines.push(infoLine);
69
+ return lines.map(truncate);
70
+ };
71
+
72
+ const render = (data) => {
73
+ let lines = buildLines(data);
74
+ lastLineLens = lines.map((line) => line.length);
75
+ let frame = lines.map(colorizeFrame).join('\n');
76
+ if (frameCount === 0) {
77
+ write(frame);
78
+ } else {
79
+ //N 行帧同构推广: \r 回行首清当前行(帧末行) + 逐行上移清行(N-1 次) + 重写整帧
80
+ let prefix = `\r${ESC}[2K` + `${ESC}[1A${ESC}[2K`.repeat(lines.length - 1);
81
+ write(`${prefix}${frame}`);
82
+ };
83
+ frameCount++;
84
+ };
85
+
86
+ const end = () => {//收尾换行: 进度区定格, 后续输出(shell 提示符等)落在下一行
87
+ ended = true;
88
+ write('\n');
89
+ };
90
+
91
+ const reanchor = () => {//终端 resize 重排后旧帧行数不可信: 按上帧各行行长 ÷ 当前列宽算出实际占用行数,
92
+ //上移回旧帧首行并 ESC[0J 清到屏尾(抹除旧帧, 不留定格碎片), 下一帧从该锚点按首帧重写
93
+ if (ended || frameCount === 0) {
94
+ return;
95
+ };
96
+ const cols = stream.columns || 80;
97
+ const wrapRows = (len) => Math.max(1, Math.ceil(len / cols));
98
+ const rowsAbove = lastLineLens.reduce((sum, len) => sum + wrapRows(len), 0) - 1;
99
+ write(`\r${ESC}[${rowsAbove}A${ESC}[0J`);
100
+ frameCount = 0;
101
+ };
102
+
103
+ const writeLine = (text) => {//定格后整行输出(计数器行等): 纯文本一行, 不进 run.log(ASCII 契约之外的字符只走 render)
104
+ write(`${text}\n`);
105
+ };
106
+
107
+ return { render, end, reanchor, writeLine };
108
+ };
109
+
110
+ module.exports = { create };
package/utils/randoms.js CHANGED
@@ -1,4 +1,5 @@
1
1
 
2
+ const crypto = require('crypto');
2
3
  const fmtDigit = (n)=>{
3
4
  return n > 9 ? "" + n: "0" + n;
4
5
  }
@@ -13,9 +14,11 @@ const getDateTimeTxt = ()=>{
13
14
  return `${YYYY}-${MM}-${DD}_${HH}-${mm}-${ss}`;
14
15
  };
15
16
  const get36Id = ()=>{
16
- let num1 = (Math.random().toString().match(/\d/g).join('')*1).toString(36);
17
+ //时间戳前缀(保排序可读) + crypto 随机后缀(hex 是 base36 字符集子集):
18
+ //Math.random 拼数字会丢前导零且同毫秒时钟不变, 批量建任务熵不足(勿回退)
17
19
  let num2 = Date.now().toString(36);
18
- let str = num2+num1;
20
+ let rand = crypto.randomBytes(8).toString('hex');
21
+ let str = num2+rand;
19
22
  return str;
20
23
  };
21
24
  module.exports = {getDateTimeTxt, get36Id};
@@ -7,7 +7,7 @@ const runLog = require('./runLog');
7
7
 
8
8
  const VERSION = require('../package.json').version;
9
9
 
10
- const KEY_WIDTH = 17;//键列宽: 需容纳最长键 'monitor start cmd', 改动时同步调
10
+ const KEY_WIDTH = 19;//键列宽: 需容纳最长键 'monitor install cmd', 改动时同步调
11
11
 
12
12
  const renderTitleBox = (mode) => {
13
13
  const left = `multi-tasks v${VERSION}`;
@@ -27,6 +27,7 @@ const renderFieldLines = (info) => {
27
27
  ['new tasks', `${info.newTasks}`],
28
28
  ['run.log', info.sysLogFile || '(not available)'],
29
29
  ['started at', info.startedAt],
30
+ ['monitor install cmd', 'npm install multi-tasks-monitor'],
30
31
  ['monitor start cmd', `npx multi-tasks-monitor --task-dir "${info.taskFolder}" --port 3777`],
31
32
  ];
32
33
  return rows.map(([key, value]) => `${key.padEnd(KEY_WIDTH)} : ${value}`);
@@ -0,0 +1,38 @@
1
+ //worker 端状态探针(零依赖, perf_hooks 为 Node 内置): unref 定时器按间隔自采——
2
+ //两个增量指标: ELU(eventLoopUtilization 快照差值的 utilization, 0~1, JS 线程打没打满的直接答案)与
3
+ //CPU%(process.cpuUsage 增量/墙钟增量, 单核占比, 用户开 worker_threads 时可 >100, 原样上报);
4
+ //两个内存快照: rss(进程物理内存)与 heapUsed(V8 堆), 原始字节上报, 格式化在 master。
5
+ //通过 send 回调发给 master。只在 worker 进程内运行; interval <= 0 不启动(特性关闭)。
6
+ //unref: 不阻碍进程退出, 不干扰 autoClose; 采样/上报异常只 runLog.warn, 绝不影响任务执行。
7
+ const { performance } = require('perf_hooks');
8
+
9
+ const runLog = require('./runLog');
10
+
11
+ const start = (intervalMs, send) => {
12
+ if (!(intervalMs > 0)) {
13
+ return null;
14
+ };
15
+ let lastElu = performance.eventLoopUtilization();
16
+ let lastCpu = process.cpuUsage();
17
+ const timer = setInterval(() => {
18
+ try {
19
+ const wallMs = intervalMs;//定时器间隔即墙钟窗口: fake timers/真实运行一致, 漂移纳入分子分母可忽略
20
+ const curElu = performance.eventLoopUtilization();
21
+ const elu = performance.eventLoopUtilization(curElu, lastElu).utilization;
22
+ const cpuDelta = process.cpuUsage(lastCpu);
23
+ const cpu = ((cpuDelta.user + cpuDelta.system) / (wallMs * 1000)) * 100;
24
+ const mem = process.memoryUsage();//~0.6µs/次, 每帧一次可忽略
25
+ lastElu = curElu;
26
+ lastCpu = process.cpuUsage();
27
+ send({ elu, cpu, rss: mem.rss, heapUsed: mem.heapUsed });
28
+ } catch (e) {
29
+ runLog.warn('[Worker]: status probe failed, skip this frame', e);
30
+ };
31
+ }, intervalMs);
32
+ timer.unref();
33
+ return {
34
+ stop: () => { clearInterval(timer); },
35
+ };
36
+ };
37
+
38
+ module.exports = { start };
@@ -12,22 +12,12 @@ const helper = require('./helper');
12
12
  const { resolveNumberOfWorkers } = require('../utils/resolveWorkers');
13
13
  const { resolveWorkerPriority } = require('../utils/resolvePriority');
14
14
  const runLog = require('../utils/runLog');
15
+ const randomsUtil = require('../utils/randoms');
15
16
  const progressCtl = require('../utils/progressCtl');
17
+ const workerStatusProbe = require('../utils/workerStatusProbe');
16
18
  const startupBanner = require('../utils/startupBanner');
17
19
 
18
- let fmtdigit = (n)=>{
19
- return n > 9 ? "" + n: "0" + n;
20
- };
21
- let getDateTimeTxt = ()=>{
22
- let d=new Date();
23
- let YYYY=d.getFullYear()
24
- let MM=fmtdigit(d.getMonth()+1);
25
- let DD=fmtdigit(d.getDate());
26
- let HH=fmtdigit(d.getHours());
27
- let mm=fmtdigit(d.getMinutes());
28
- let ss=fmtdigit(d.getSeconds());
29
- return `${YYYY}-${MM}-${DD} ${HH}:${mm}:${ss}`;
30
- };
20
+ //时间戳统一走 utils/randoms.getDateTimeTxt(YYYY-MM-DD_HH-mm-ss), asMaster/user_messages 同口径
31
21
 
32
22
  let USER_CONFIG;
33
23
 
@@ -75,25 +65,40 @@ const start = (config)=>{
75
65
  delete workerTaskTimers[workerId];
76
66
  }
77
67
  };
68
+ let terminateAllAndExit = ()=>{
69
+ //shouldTerminate 与 TERMINATE_ALL_WORKERS 共用: 清在飞任务计时器 + 杀 worker + 进度条定格退出
70
+ for (const id in workerTaskTimers) {
71
+ clearTaskTimer(id);
72
+ };
73
+ killAllWorkers();
74
+ //计数器行与正常完成路径一致: 始终进 run.log(progressBar 未接管终端时 echo 上屏);
75
+ //runLog 写流与 stdout 都是异步写, 先写完再延迟 exit, 防计数器行被 process.exit 截断
76
+ let finalStats = TaskMgr.getFinalStats();
77
+ runLog.write(`[Tasks] succeeded=${finalStats.succeeded}, failed=${finalStats.failed}, succ-but-save-failed=${finalStats.succButSaveFailed || 0}`);
78
+ progressCtl.finish();
79
+ setTimeout(()=>{
80
+ process.exit(0);
81
+ }, 10);
82
+ };
78
83
  let finalizeTimeoutTask = (subTask, cost)=>{
79
84
  let subid = subTask.subid;
80
85
  try{
81
86
  let resultType = TaskMgr.getSavedResultType(subid);
82
87
  if(resultType === 'succ'){//超时边界: worker 已成功写盘, 视为已完成
83
- TaskMgr.mvTask(subid, 'running', 'finished', ()=>{});
88
+ TaskMgr.mvTaskSync(subid, 'running', 'finished');
84
89
  }else if(resultType === 'error'){//超时边界: worker 已自行记错误
85
- TaskMgr.mvTask(subid, 'running', 'finished_with_errors', ()=>{});
90
+ TaskMgr.mvTaskSync(subid, 'running', 'finished_with_errors');
86
91
  }else{
87
92
  if(typeof config.maxTaskRetries === 'number' && config.maxTaskRetries > 0){
88
93
  let retryCount = TaskMgr.incrementTaskRetry(subid);
89
94
  if(retryCount <= config.maxTaskRetries){
90
95
  runLog.warn(`[Master]: task "${subid}" timeout, retry (${retryCount}/${config.maxTaskRetries})`);
91
- TaskMgr.mvTask(subid, 'running', 'new', ()=>{});
96
+ TaskMgr.mvTaskSync(subid, 'running', 'new');
92
97
  return;
93
98
  };
94
99
  };
95
100
  TaskMgr.saveTaskResult(subid, true, [{type: 'timeout', timeout: config.taskTimeout, cost}]);
96
- TaskMgr.mvTask(subid, 'running', 'finished_with_errors', ()=>{});
101
+ TaskMgr.mvTaskSync(subid, 'running', 'finished_with_errors');
97
102
  }
98
103
  }catch(e){
99
104
  runLog.warn('[Master]: failed to finalize timeout task', subid, e);
@@ -116,11 +121,11 @@ const start = (config)=>{
116
121
  let retryCount = TaskMgr.incrementTaskRetry(subid);
117
122
  if(retryCount <= config.maxTaskRetries){
118
123
  runLog.warn(`[Master]: worker crashed, retry task "${subid}" (${retryCount}/${config.maxTaskRetries})`);
119
- TaskMgr.mvTask(subid, 'running', 'new', ()=>{});
124
+ TaskMgr.mvTaskSync(subid, 'running', 'new');
120
125
  }else{
121
126
  runLog.warn(`[Master]: task "${subid}" crashed ${retryCount} times, give up`);
122
127
  TaskMgr.saveTaskResult(subid, true, [{type: 'worker_crash', retryCount: config.maxTaskRetries}]);
123
- TaskMgr.mvTask(subid, 'running', 'finished_with_errors', ()=>{});
128
+ TaskMgr.mvTaskSync(subid, 'running', 'finished_with_errors');
124
129
  };
125
130
  }catch(e){
126
131
  runLog.warn('[Master]: failed to handle crashed task', subid, e);
@@ -138,12 +143,14 @@ const start = (config)=>{
138
143
  };
139
144
  };
140
145
  worker.on('message', function(message) {
146
+ if(message && message.__workerStatus){//worker 状态周期自报: master 自己消费进状态表, 不转发 setSysListener, 不触发派发
147
+ progressCtl.workerStatus(worker.id, message.__workerStatus);
148
+ return;
149
+ }
141
150
  if(message && message.__sysEvent){//worker 系统事件: master 自己消费, 不转发
142
151
  if(message.event === 'TERMINATE_ALL_WORKERS'){
143
152
  runLog.warn(`[Master]: TERMINATE_ALL_WORKERS from worker ${worker.id}, killing all workers`);
144
- killAllWorkers();
145
- progressCtl.finish();//单行进度条换行定格, 别让 shell 提示符盖住它
146
- return process.exit(0);
153
+ return terminateAllAndExit();
147
154
  }
148
155
  let meta = {fromWorkerId: worker.id, fromWorkerPid: worker.process.pid};
149
156
  if(typeof USER_CONFIG.setSysListener === 'function'){
@@ -196,17 +203,23 @@ const start = (config)=>{
196
203
  if(config.autoCloseAfterCompletion){
197
204
  killWorker(worker);
198
205
  }else{//waiting for new tasks
199
- worker.send(MSG_NO_TASK_FOUND,()=>{}, {
200
- keepOpen: (typeof config.keepOpen === 'undefined') ? false : config.keepOpen
201
- });
206
+ worker.send(MSG_NO_TASK_FOUND,()=>{});
202
207
  }
203
208
  progressCtl.render();
204
209
  return;
205
210
  }
206
211
  let terminateData = {taskCount,subTask,config, id: worker.id};
207
- if(USER_CONFIG.shouldTerminate && USER_CONFIG.shouldTerminate(terminateData)===true) {
208
- killAllWorkers();
209
- return process.exit(0);
212
+ let userWantsTerminate = false;
213
+ if(typeof USER_CONFIG.shouldTerminate === 'function'){
214
+ try{
215
+ userWantsTerminate = USER_CONFIG.shouldTerminate(terminateData)===true;
216
+ }catch(e){
217
+ //任务已 pop 就必须派出去: 抛错只记日志, 按「不终止」继续(裸 throw 会丢任务并死锁)
218
+ runLog.error('[Master]: shouldTerminate threw, continue dispatching task:', e);
219
+ };
220
+ };
221
+ if(userWantsTerminate) {
222
+ return terminateAllAndExit();
210
223
  }
211
224
  //send new tasks to the worker
212
225
  let startTimestamp = new Date()*1;
@@ -219,9 +232,7 @@ const start = (config)=>{
219
232
  startTimestamp,
220
233
  config: ipcConfig,
221
234
  subTask
222
- },()=>{}, {
223
- keepOpen: (typeof config.keepOpen === 'undefined') ? false : config.keepOpen
224
- });
235
+ },()=>{});
225
236
  progressCtl.taskAssigned(worker.id, subTask.subid);
226
237
  let hasTimeout = typeof config.taskTimeout === 'number' && config.taskTimeout > 0;
227
238
  let hasRetry = typeof config.maxTaskRetries === 'number' && config.maxTaskRetries > 0;
@@ -252,7 +263,7 @@ const start = (config)=>{
252
263
  pid: process.pid,
253
264
  newTasks: TaskMgr.getTasks('new').length,
254
265
  sysLogFile: config.__sysLogFile,
255
- startedAt: getDateTimeTxt(),
266
+ startedAt: randomsUtil.getDateTimeTxt(),
256
267
  };
257
268
  console.log(startupBanner.renderBanner(startupInfo));
258
269
  progressCtl.setup({//TTY 判定 + 渲染器创建都在控制器内; 统计口径注入, 控制器不依赖 TaskMgr
@@ -281,6 +292,7 @@ const start = (config)=>{
281
292
  let rec = workerTaskTimers[worker.id];//先取再清: 崩溃重试要用 rec.subTask
282
293
  clearTaskTimer(worker.id);//worker 退出(含崩溃)时清掉其超时计时器
283
294
  progressCtl.workerExited(worker.id);
295
+ WorkerMgr.removeWorker(worker.id);//清注册表引用: 崩溃补员场景 map 只增不减会累积死 worker
284
296
  if (code !== 0 && !worker.exitedAfterDisconnect) {
285
297
  runLog.write('Worker died unexpectedly starting a new one...');
286
298
  workerCrashCount++;
@@ -311,18 +323,24 @@ const start = (config)=>{
311
323
  succeeded: stats.succeeded,
312
324
  failed: stats.failed,
313
325
  failedTasks: stats.failedTasks,
326
+ succButSaveFailed: stats.succButSaveFailed || 0,//任务成功但结果保存失败(序列化兜底)的任务数, 明细见 .sys/user_messages
314
327
  workers: {count: numOfWorkers, crashed: workerCrashCount},
315
328
  };
316
329
  let onFinishHelper = {
317
330
  foreachSuccResult: (cb) => TaskMgr.forEachSuccResult(cb),
318
331
  foreachErrorResult: (cb) => TaskMgr.forEachErrorResult(cb),
332
+ foreachResult: (cb) => TaskMgr.forEachResult(cb),
319
333
  };
320
334
  USER_CONFIG.onFinish(report, onFinishHelper);
321
335
  delete USER_CONFIG.onFinish;
322
336
  }
337
+ //计数器行先进 run.log(progressBar 未接管终端时 echo 上屏, 接管时由 progressCtl.finish() 打在屏幕);
338
+ //stdout/run.log 都是异步写, 写完留 10ms 再 exit, 防 process.exit 截断未 flush 的输出
339
+ let finalStats = TaskMgr.getFinalStats();
340
+ runLog.write(`[Tasks] succeeded=${finalStats.succeeded}, failed=${finalStats.failed}, succ-but-save-failed=${finalStats.succButSaveFailed || 0}`);
341
+ runLog.write('[Master]: all workers are done, process exit.');
342
+ progressCtl.finish();//单行进度条换行定格 + 计数器行, 别让 shell 提示符盖住它
323
343
  setTimeout(()=>{
324
- runLog.write('[Master]: all workers are done, process exit.')
325
- progressCtl.finish();//单行进度条换行定格, 别让 shell 提示符盖住它
326
344
  process.exit(0);
327
345
  }, 10);
328
346
  }
@@ -338,6 +356,16 @@ function subWorker(config){
338
356
  //共享 stdout 为 TTY 时只写文件(与 master 判定一致), 否则 echo 同时上屏。判定收敛在 initAndHook 内,
339
357
  //无路径时维持 console。入口即生效, 领不到任务的富余 worker 的 waiting 日志同样进 run.log。
340
358
  runLog.initAndHook(config.__sysLogFile, { echo: !(config.progressBar !== false && process.stdout.isTTY) });
359
+ //状态探针: 启动条件与上面 echo 判定同口径(progressBar 开 + TTY), 另需 interval > 0(缺省 2000);
360
+ //非 TTY/关闭时不启动, 零开销零 IPC 噪音。探针 unref, 进程退出随进程销毁, 无需 stop
361
+ //布尔口径: false 等价 <=0 关闭, true 等价缺省(勿直接进 setInterval, true 会被当 1ms)
362
+ let statusInterval = config.worker_status_poll_ms;
363
+ statusInterval = (typeof statusInterval === 'undefined' || statusInterval === true) ? 2000 : (statusInterval === false ? 0 : statusInterval);
364
+ if(config.progressBar !== false && process.stdout.isTTY && statusInterval > 0){
365
+ workerStatusProbe.start(statusInterval, (payload)=>{
366
+ worker.send({__workerStatus: payload});
367
+ });
368
+ };
341
369
  worker.on('message', function(message) {
342
370
  if(message && message.__broadcast){//master 中转来的广播: 分发给 helper.on 注册的 handler
343
371
  helper.__dispatchBroadcast(message);
@@ -358,7 +386,7 @@ function subWorker(config){
358
386
  if(typeof subTask.taskCount === 'undefined') subTask.taskCount = taskCount;
359
387
  subTask.workerId = worker.id;
360
388
  subTask.workerPid = process.pid;
361
- runLog.write(`[Task Start]: "${subTask.subid}"`, `pid=${process.pid}`, getDateTimeTxt());
389
+ runLog.write(`[Task Start]: "${subTask.subid}"`, `pid=${process.pid}`, randomsUtil.getDateTimeTxt());
362
390
  let subTaskLog = {
363
391
  workerId: worker.id,
364
392
  workerPid: process.pid,
@@ -369,7 +397,7 @@ function subWorker(config){
369
397
  if(tasklog.retried) return;//重试任务已回 new 等重跑, 未完成, 不打 [Task Finished]
370
398
  let {cost, startTimestamp, endTimestamp} = tasklog;
371
399
 
372
- runLog.write(`[Task Finished] "${config.masterTaskId}"."${subTask.subid}".`, getDateTimeTxt(), `cost=${cost}ms`)
400
+ runLog.write(`[Task Finished] "${config.masterTaskId}"."${subTask.subid}".`, randomsUtil.getDateTimeTxt(), `cost=${cost}ms`)
373
401
  }).then(()=>{
374
402
  worker.send(MSG_REQUEST_NEW_TASK);//request new task
375
403
  });
@@ -381,17 +409,27 @@ function subWorker(config){
381
409
  worker.send(MSG_REQUEST_NEW_TASK);//request new task
382
410
 
383
411
  };
412
+ //错误对象 → 可 JSON 序列化的持久化形态: Error 取 message/stack, 其余原样;
413
+ //错误值不可 JSON 序列化(循环引用/BigInt 等)时换成机器可读占位, 保住「用户 reject 了什么」的语义
414
+ function serializeError(e){
415
+ let serialized;
416
+ if(e instanceof Error){
417
+ serialized = {message: e.message, stack: e.stack};
418
+ }else{
419
+ serialized = e;
420
+ };
421
+ try{
422
+ JSON.stringify(serialized);
423
+ }catch(stringifyErr){
424
+ serialized = {type: 'unserializable_error_value', message: 'error value is not JSON-serializable'};
425
+ };
426
+ return serialized;
427
+ };
384
428
  function runTask(config, subTask, subTaskLog){
385
- return new Promise((resolve)=>{
429
+ return new Promise((resolve)=>{
386
430
  let resultPromise = Promise.resolve({});
387
431
  let resultData;
388
432
  let errors = [];
389
- let serializeError = (e)=>{
390
- if(e instanceof Error){
391
- return {message: e.message, stack: e.stack};
392
- }
393
- return e;
394
- };
395
433
  helper.load(config);
396
434
  TaskMgr.saveTaskLog(subTask.subid, subTaskLog);
397
435
  try{
@@ -429,6 +467,7 @@ function runTask(config, subTask, subTaskLog){
429
467
  }catch(newPath){
430
468
  runLog.warn(`[Task Retry] "${subTask.subid}" failed to move back to new, left in running (recoverable via resume)`);
431
469
  };
470
+ TaskMgr.deleteUserMessage(subTask.subid);//重跑即新一轮: 清掉该任务前序尝试的遗留消息
432
471
  };
433
472
  };
434
473
  TaskMgr.saveTaskLog(subTask.subid, subTaskLog);
@@ -452,7 +491,7 @@ function runTask(config, subTask, subTaskLog){
452
491
  cost,
453
492
  retried: false
454
493
  });
455
- });
494
+ });
456
495
  });
457
496
  });
458
497
  };