cron-up 26.10.31 → 26.10.33

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.
package/src/registry.mjs CHANGED
@@ -62,18 +62,18 @@ export function processParentMap() {
62
62
  return map.size > 0 ? map : null;
63
63
  }
64
64
 
65
- // 沿父子表上溯(不含 pid 自身),直到 launchd(1;其父记为 0)。中途父 pid
66
- // 在快照里缺失(ps 退化/进程刚好退出)或成环时 chainComplete=false。
65
+ // 沿父子表上溯(不含 pid 自身),直到 launchd(1;其父记为 0)、父 pid 在
66
+ // 快照里缺失或成环。调用方只消费祖先列表;断链时保留已读到的部分即可。
67
67
  export function ancestorChain(pid, parentMap) {
68
68
  const ancestors = [];
69
69
  const seen = new Set([pid]);
70
70
  let cur = pid;
71
71
  for (;;) {
72
72
  const parent = parentMap?.get(cur);
73
- if (parent === undefined) return { ancestors, chainComplete: false };
73
+ if (parent === undefined) return ancestors;
74
74
  ancestors.push(parent);
75
- if (parent <= 1) return { ancestors, chainComplete: true }; // 0/1 即根
76
- if (seen.has(parent)) return { ancestors, chainComplete: false };
75
+ if (parent <= 1) return ancestors; // 0/1 即根
76
+ if (seen.has(parent)) return ancestors;
77
77
  seen.add(parent);
78
78
  cur = parent;
79
79
  }
@@ -159,9 +159,9 @@ async function readSessionFile(file) {
159
159
  }
160
160
 
161
161
  // 返回 [拥有活消费者会话的工作区集合, 告警字符串|null, 会话详情 Map]。
162
- // 第三个元素是 Map<cwd, Array<{pid, sessionId, procStart, ancestors,
163
- // chainComplete}>>。祖先链恒拍:快照失败给空链,相关预热本轮认不到亲、
164
- // 下轮自动重试;consumers 判定不受影响。
162
+ // 第三个元素是 Map<cwd, Array<{pid, sessionId, procStart, ancestors}>>。
163
+ // 祖先链恒拍:快照失败给空链,相关预热本轮认不到亲、下轮自动重试;
164
+ // consumers 判定不受影响。
165
165
  export async function scanSessions() {
166
166
  let files = [];
167
167
  try {
@@ -217,9 +217,7 @@ export async function scanSessions() {
217
217
  if (typeof cwd === 'string' && path.isAbsolute(cwd)) {
218
218
  const ws = lexReal(cwd);
219
219
  consumers.add(ws);
220
- const chain = parentMap
221
- ? ancestorChain(pid, parentMap)
222
- : { ancestors: [], chainComplete: false };
220
+ const ancestors = parentMap ? ancestorChain(pid, parentMap) : [];
223
221
  const list = details.get(ws) ?? [];
224
222
  list.push({
225
223
  pid,
@@ -227,8 +225,7 @@ export async function scanSessions() {
227
225
  // procStart 是 ps lstart 原文(单位数日期含双空格),只许原样
228
226
  // 传递给任务文件,绝不能自己拼。
229
227
  procStart: typeof s.procStart === 'string' ? s.procStart : null,
230
- ancestors: chain.ancestors,
231
- chainComplete: chain.chainComplete,
228
+ ancestors,
232
229
  });
233
230
  details.set(ws, list);
234
231
  continue;
package/src/service.mjs CHANGED
@@ -22,34 +22,52 @@ import {
22
22
  validateConfig,
23
23
  normalizeRoots,
24
24
  } from './config.mjs';
25
- import { scriptProcLogPath } from './sessions.mjs';
25
+ import { scriptProcLogPath, shellQuote } from './sessions.mjs';
26
26
 
27
27
  function guiTarget() {
28
28
  return `gui/${process.getuid()}`;
29
29
  }
30
30
 
31
31
  // launchctl 调用统一走不抛的 execFile,判成败看 status。与 ps 同款防御:
32
- // launchctl 卡住时命令挂死不如超时报「未加载」——总览页至少还能出来。
32
+ // launchctl 卡住时命令挂死不如超时报「未加载」——总览页至少还能出来;卸载
33
+ // 等破坏性操作另用下方三态 probe,状态未知时拒绝继续。
33
34
  export function launchctl(...args) {
34
35
  return deps.execFile('/bin/launchctl', args, { timeout: 10000 });
35
36
  }
36
37
 
37
- export function launchctlInfo() {
38
- // 走 deps 而非同文件裸名:与 bootout/bootstrap 的打桩面保持一致,测试可
39
- // 在 launchctl 一层整体模拟 launchd。
40
- const r = deps.launchctl('print', `${guiTarget()}/${LABEL}`);
41
- if (r.status !== 0) return null;
38
+ function parseLaunchctlInfo(stdout = '') {
42
39
  const info = {};
43
40
  for (const key of ['state', 'last exit code']) {
44
- const m = r.stdout.match(
41
+ const m = stdout.match(
45
42
  new RegExp(`^\\s*${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*=\\s*(.+)$`, 'm'));
46
43
  if (m) info[key] = m[1].trim();
47
44
  }
48
- const iv = r.stdout.match(/^\s*run interval\s*=\s*(\d+)\s*seconds/m);
45
+ const iv = stdout.match(/^\s*run interval\s*=\s*(\d+)\s*seconds/m);
49
46
  if (iv) info.interval = parseInt(iv[1], 10);
50
47
  return info;
51
48
  }
52
49
 
50
+ // 卸载需要比 info/null 更细的结论:launchctl 对未加载与查询失败都返回非零,
51
+ // 只有明确的「找不到服务」才能安全继续删除安装产物。
52
+ function launchctlProbe() {
53
+ const r = deps.launchctl('print', `${guiTarget()}/${LABEL}`);
54
+ if (r.status === 0) {
55
+ return { kind: 'loaded', info: parseLaunchctlInfo(r.stdout ?? '') };
56
+ }
57
+ const stderr = (r.stderr ?? '').trim();
58
+ if (/could not find service/i.test(stderr)) return { kind: 'unloaded' };
59
+ return {
60
+ kind: 'unknown',
61
+ message: stderr || `launchctl print 退出码 ${r.status ?? '未知'}`,
62
+ };
63
+ }
64
+
65
+ export function launchctlInfo() {
66
+ // 总览页继续只看 info/null;详细三分状态仅供卸载等破坏性操作使用。
67
+ const probe = launchctlProbe();
68
+ return probe.kind === 'loaded' ? probe.info : null;
69
+ }
70
+
53
71
  const LAUNCHD_STATES = new Map([
54
72
  ['running', '运行中'],
55
73
  ['not running', '空闲中(按间隔触发)'],
@@ -90,14 +108,17 @@ function isExecutable(p) {
90
108
  }
91
109
 
92
110
  // 返回 {pathDirs}:启动脚本要写进 PATH 的 bin 目录。注入 home/execPath/
93
- // systemPaths 仅为测试在临时 HOME 里伪造各安装形态。fnm 的
111
+ // cronUpPath/systemPaths 仅为测试在临时 HOME 里伪造各安装形态。fnm 的
94
112
  // aliases/default/bin 是跟随 `fnm default` 切换的稳定 symlink(切勿
95
113
  // realpath 到版本化目录,fnm 升级后那里会失效);volta 的 ~/.volta/bin、
96
114
  // Homebrew 与官网 pkg 的系统 bin 同理是版本无关的稳定入口。都不认识时退
97
- // 回当前安装进程自己的 bin 目录:nvm 等按版本目录安装的形态由此覆盖。
115
+ // 回当前安装进程自己的 node bin 目录:nvm 等按版本目录安装的形态由此覆盖。
116
+ // node 目录与当前实际 cron-up 的 bin 目录都必须保留:fnm 非默认版本、nvm
117
+ // 或独立 npm prefix 下两者经常不同,找到稳定 node 后提前返回会丢掉 cron-up。
98
118
  export function resolveLauncher(opts = {}) {
99
119
  const home = opts.home ?? paths.home;
100
120
  const execPath = opts.execPath ?? process.execPath;
121
+ const cronUpPath = opts.cronUpPath ?? process.argv[1];
101
122
  const systemPaths = opts.systemPaths
102
123
  ?? ['/opt/homebrew/bin/node', '/usr/local/bin/node'];
103
124
 
@@ -111,14 +132,19 @@ export function resolveLauncher(opts = {}) {
111
132
  path.join(home, '.volta', 'bin'),
112
133
  ...systemPaths.map((p) => path.dirname(p)),
113
134
  ];
114
- for (const dir of candidates) {
115
- if (isExecutable(path.join(dir, 'node'))) return { pathDirs: [dir] };
135
+ const stableNodeDir = candidates.find((dir) => isExecutable(path.join(dir, 'node')));
136
+ const nodeDir = stableNodeDir ?? path.dirname(execPath);
137
+ // argv[1] 保留 npm 全局 bin symlink 的调用路径;不要 realpath 到包内
138
+ // bin/cron-up.mjs,否则独立 npm prefix 的 shim 目录会丢。无 argv[1] 时
139
+ // 退回 node 目录,至少覆盖 node 与全局 bin 同目录的常见安装。
140
+ const cronUpDir = cronUpPath
141
+ ? path.dirname(path.resolve(cronUpPath)) : path.dirname(execPath);
142
+ const result = { pathDirs: [...new Set([nodeDir, cronUpDir])] };
143
+ if (stableNodeDir === undefined) {
144
+ result.note = '未能定位版本无关的 node bin 目录,已使用当前 node 所在目录;'
145
+ + '该目录失效(如版本目录被删)时请重新运行 cron-up install';
116
146
  }
117
- return {
118
- pathDirs: [path.dirname(execPath)],
119
- note: '未能定位版本无关的 node bin 目录,已使用当前 node 所在目录;'
120
- + '该目录失效(如版本目录被删)时请重新运行 cron-up install',
121
- };
147
+ return result;
122
148
  }
123
149
 
124
150
  // ---- plist ----
@@ -145,7 +171,9 @@ function xmlUnescape(s) {
145
171
  export function writeServiceScript(launcher) {
146
172
  const script = deps.paths.serviceScriptPath;
147
173
  fs.mkdirSync(path.dirname(script), { recursive: true });
148
- const dirs = launcher.pathDirs.map((d) => `"${d}"`).join(':');
174
+ // 整个冒号前缀作为一个 shell word 引用:健康检查可无损解回同一串目录,
175
+ // 空格、单双引号、$ 等字符不会变成 shell 语法。
176
+ const dirs = shellQuote(launcher.pathDirs.join(':'));
149
177
  fs.writeFileSync(script,
150
178
  '#!/bin/bash\n'
151
179
  + '# 由 cron-up install 生成;重跑 cron-up install 覆盖重生成\n'
@@ -197,6 +225,51 @@ export function readPlistProgramArgs() {
197
225
  return parts.length > 0 ? parts : null;
198
226
  }
199
227
 
228
+ // 解一个 POSIX shell word(不执行它)。覆盖 shellQuote 生成的单引号与
229
+ // '"'"' 拼接,也兼容旧版脚本简单的双引号目录;含未引用空白、命令替换或
230
+ // 变量展开的手改内容拒绝猜测。
231
+ function parseShellWord(source) {
232
+ let value = '';
233
+ for (let i = 0; i < source.length;) {
234
+ const ch = source[i];
235
+ if (ch === "'") {
236
+ const end = source.indexOf("'", i + 1);
237
+ if (end < 0) return null;
238
+ value += source.slice(i + 1, end);
239
+ i = end + 1;
240
+ continue;
241
+ }
242
+ if (ch === '"') {
243
+ i += 1;
244
+ let closed = false;
245
+ while (i < source.length) {
246
+ if (source[i] === '"') {
247
+ closed = true;
248
+ i += 1;
249
+ break;
250
+ }
251
+ if (source[i] === '$' || source[i] === '`') return null;
252
+ if (source[i] === '\\') {
253
+ if (i + 1 >= source.length) return null;
254
+ i += 1;
255
+ }
256
+ value += source[i];
257
+ i += 1;
258
+ }
259
+ if (!closed) return null;
260
+ continue;
261
+ }
262
+ if (/\s/.test(ch) || /[$`;&|<>()]/.test(ch)) return null;
263
+ if (ch === '\\') {
264
+ if (i + 1 >= source.length) return null;
265
+ i += 1;
266
+ }
267
+ value += source[i];
268
+ i += 1;
269
+ }
270
+ return value;
271
+ }
272
+
200
273
  // 从启动脚本内容里解出 PATH 目录;脚本是我们生成的固定格式,解不出(手改
201
274
  // 坏)返回 null。
202
275
  function scriptPathDirs(scriptPath) {
@@ -207,7 +280,11 @@ function scriptPathDirs(scriptPath) {
207
280
  return null;
208
281
  }
209
282
  const m = text.match(/^export PATH=(.+):"\$PATH"$/m);
210
- return m ? m[1].split('":"').map((s) => s.replace(/"/g, '')) : null;
283
+ if (!m) return null;
284
+ const prefix = parseShellWord(m[1]);
285
+ if (!prefix) return null;
286
+ const dirs = prefix.split(':');
287
+ return dirs.every(Boolean) ? dirs : null;
211
288
  }
212
289
 
213
290
  // 总览页健康检查:launchd 每轮按启动脚本拉起巡检,脚本丢失、被手改或
@@ -290,20 +367,37 @@ export async function cmdInstall(args) {
290
367
  + '--roots 指定(见 README 安全说明)');
291
368
  }
292
369
 
293
- const launcher = resolveLauncher();
370
+ const launcher = deps.resolveLauncher();
294
371
  if (launcher.note) deps.print(`提示:${launcher.note}`);
295
372
 
296
373
  const script = writeServiceScript(launcher);
374
+ fs.mkdirSync(path.dirname(deps.paths.plistPath), { recursive: true });
297
375
  fs.writeFileSync(deps.paths.plistPath, renderPlist(script));
298
376
  deps.print(`已写入启动脚本与 LaunchAgent:${script}、${deps.paths.plistPath}`);
299
377
 
378
+ // bootstrap 成功只代表 launchd 收下 plist,不代表脚本真能解析 node 与
379
+ // cron-up。先自检启动链,避免随后的手动首轮巡检掩盖坏脚本。
380
+ const health = launcherHealth();
381
+ if (health) {
382
+ deps.printErr(`启动链自检失败:${health}`);
383
+ throw new ExitError(1);
384
+ }
385
+
300
386
  deps.launchctl('bootout', `${guiTarget()}/${LABEL}`);
301
387
  const r = deps.launchctl('bootstrap', guiTarget(), deps.paths.plistPath);
302
388
  if (r.status !== 0) {
303
389
  deps.printErr(`加载到 launchd 失败:\n${r.stderr.trim()}`);
304
390
  throw new ExitError(1);
305
391
  }
306
- deps.launchctl('enable', `${guiTarget()}/${LABEL}`);
392
+ const enabled = deps.launchctl('enable', `${guiTarget()}/${LABEL}`);
393
+ if (enabled.status !== 0) {
394
+ deps.printErr(`启用 launchd 服务失败:\n${enabled.stderr.trim()}`);
395
+ throw new ExitError(1);
396
+ }
397
+ if (deps.launchctlInfo() === null) {
398
+ deps.printErr('加载后未能向 launchd 核实服务状态,请检查 cron-up 与 launchctl');
399
+ throw new ExitError(1);
400
+ }
307
401
  deps.print('已加载到 launchd');
308
402
 
309
403
  // install 与巡检撞车时首轮会被跳过,不能照常说「完成」。
@@ -353,34 +447,79 @@ export async function cmdUninstall(args) {
353
447
  throw new ExitError(2, 'uninstall 不支持 --config:卸载操作的是本机固定'
354
448
  + `的安装产物(${deps.paths.plistPath} 等)`);
355
449
  }
356
- const r = deps.launchctl('bootout', `${guiTarget()}/${LABEL}`);
357
- if (r.status === 0) deps.print('已从 launchd 卸载');
358
-
359
- // 服务没了就没有回收者:默认结束全部预热会话,不留以 bypass 权限空转
360
- // 的孤儿。各会话的停轮询(SIGTERM→等→SIGKILL)互不依赖,先逐个发信
361
- // 号再并行等齐。
362
- const stopping = [];
363
- for (const { label, ent } of allTrackedWarmups()) {
364
- deps.print(`正在结束预热会话 ${label}`);
365
- stopping.push(deps.stopSession(ent));
450
+ const lock = deps.acquireRunLock();
451
+ if (lock === null) {
452
+ deps.printErr('已有一轮巡检在进行,为避免误删正在使用的产物,本次不卸载;'
453
+ + '请稍后重试');
454
+ throw new ExitError(1);
366
455
  }
367
- await Promise.all(stopping);
456
+ try {
457
+ const target = `${guiTarget()}/${LABEL}`;
458
+ const before = deps.launchctlProbe();
459
+ if (before.kind === 'unknown') {
460
+ deps.printErr(`无法确认 launchd 服务状态:${before.message}\n`
461
+ + '状态未知时不能安全卸载,本次不删除 LaunchAgent、配置或日志');
462
+ throw new ExitError(1);
463
+ }
464
+ if (before.kind === 'unloaded') {
465
+ deps.print('launchd 服务未加载,无需重复卸载');
466
+ } else {
467
+ const r = deps.launchctl('bootout', target);
468
+ if (r.status !== 0) {
469
+ deps.printErr(`从 launchd 卸载失败:\n${r.stderr.trim()}\n`
470
+ + '为避免留下无人回收的预热会话,本次不删除 LaunchAgent、配置或日志');
471
+ throw new ExitError(1);
472
+ }
473
+ const after = deps.launchctlProbe();
474
+ if (after.kind === 'unknown') {
475
+ deps.printErr(`bootout 后无法确认 launchd 服务状态:${after.message}\n`
476
+ + '本次不继续删除 LaunchAgent、配置或日志');
477
+ throw new ExitError(1);
478
+ }
479
+ if (after.kind === 'loaded') {
480
+ deps.printErr('launchctl bootout 已返回,但服务仍能查到;本次不继续清理');
481
+ throw new ExitError(1);
482
+ }
483
+ deps.print('已从 launchd 卸载');
484
+ }
368
485
 
369
- if (fs.existsSync(deps.paths.plistPath)) {
370
- fs.rmSync(deps.paths.plistPath, { force: true });
371
- deps.print(`已删除 ${deps.paths.plistPath}`);
372
- }
486
+ // 服务没了就没有回收者:默认结束全部预热会话,不留以 bypass 权限空转
487
+ // 的孤儿。各会话的停轮询(SIGTERM→等→SIGKILL)互不依赖,先逐个发信
488
+ // 号再并行等齐。
489
+ const stopping = [];
490
+ for (const { label, ent } of allTrackedWarmups()) {
491
+ deps.print(`正在结束预热会话 ${label}`);
492
+ stopping.push(deps.stopSession(ent).then(
493
+ (stopped) => stopped ? null : label,
494
+ ));
495
+ }
496
+ const unsafe = (await Promise.all(stopping))
497
+ .filter((label) => label !== null);
498
+ if (unsafe.length > 0) {
499
+ deps.printErr('警告:以下预热会话仍活着,本次无法安全停止:'
500
+ + `${unsafe.join('、')}。已中止后续清理,`
501
+ + '不会继续删除 LaunchAgent、配置或日志;请人工核对后重试');
502
+ throw new ExitError(1);
503
+ }
373
504
 
374
- if (args.purge) {
375
- for (const p of [deps.paths.dataDir, deps.paths.logDir]) {
376
- fs.rmSync(p, { recursive: true, force: true });
377
- deps.print(`已删除 ${p}`);
505
+ if (fs.existsSync(deps.paths.plistPath)) {
506
+ fs.rmSync(deps.paths.plistPath, { force: true });
507
+ deps.print(`已删除 ${deps.paths.plistPath}`);
378
508
  }
379
- // npm 管的文件不能手删:命令行本身交给 npm 卸载。
380
- deps.print('命令行本身请运行 npm uninstall -g cron-up 卸载');
381
- } else {
382
- deps.print('配置与日志已保留(--purge 可删除全部产物)');
509
+
510
+ if (args.purge) {
511
+ for (const p of [deps.paths.dataDir, deps.paths.logDir]) {
512
+ fs.rmSync(p, { recursive: true, force: true });
513
+ deps.print(`已删除 ${p}`);
514
+ }
515
+ // npm 管的文件不能手删:命令行本身交给 npm 卸载。
516
+ deps.print('命令行本身请运行 npm uninstall -g cron-up 卸载');
517
+ } else {
518
+ deps.print('配置与日志已保留(--purge 可删除全部产物)');
519
+ }
520
+ } finally {
521
+ deps.releaseRunLock(lock);
383
522
  }
384
523
  }
385
524
 
386
- Object.assign(deps, { launchctl, launchctlInfo });
525
+ Object.assign(deps, { launchctl, launchctlInfo, launchctlProbe, resolveLauncher });
package/src/sessions.mjs CHANGED
@@ -81,7 +81,8 @@ export function sessionArgv(ws, logPath) {
81
81
  // 里靠继承现成 PATH 一切正常,交给 launchd 就 fnm: command not found。
82
82
  return [
83
83
  '/usr/bin/script', '-q', logPath,
84
- '/bin/zsh', '-lic', `cd ${shellQuote(ws)} && claude`,
84
+ '/bin/zsh', '-lic',
85
+ `cd ${shellQuote(ws)} && claude --permission-mode bypassPermissions`,
85
86
  ];
86
87
  }
87
88
 
@@ -109,6 +110,12 @@ export function rotateOnce(file) {
109
110
  fs.renameSync(file, old);
110
111
  }
111
112
 
113
+ // 进程组信号只接受明确大于 1 的整数 pid:0/-1 等有广播含义,浮点、
114
+ // 负数或非安全整数也都不能交给 process.kill。
115
+ function canSignalProcessGroup(pid) {
116
+ return Number.isSafeInteger(pid) && pid > 1;
117
+ }
118
+
112
119
  // 为一个任务拉起一个 detached 的 pty TUI,返回 {pid, procStart}。
113
120
  export function spawnSession(ws, taskId) {
114
121
  const logPath = sessionLogPath(ws, taskId);
@@ -149,34 +156,68 @@ export function spawnSession(ws, taskId) {
149
156
  // 拉不起来(spawn 当场失败,pid 是 undefined)时返回 null:没有进程可跟
150
157
  // 踪,调用方按启动失败计数,不把 pid=undefined 写进 state。
151
158
  if (child.pid === undefined) return null;
152
- return { pid: child.pid, procStart: deps.procStartedAt(child.pid) };
159
+ let procStart = null;
160
+ let fingerprintError = null;
161
+ try {
162
+ procStart = deps.procStartedAt(child.pid);
163
+ } catch (e) {
164
+ fingerprintError = e;
165
+ }
166
+ if (procStart) return { pid: child.pid, procStart };
167
+
168
+ // 没有指纹的条目今后无法安全发信号,不能登记成正常会话。这里只清理
169
+ // 刚刚由本次 spawn 创建的进程组;pid 形状异常时宁可留下日志也不冒险。
170
+ const reason = fingerprintError
171
+ ? `读取进程身份指纹失败:${fingerprintError?.message ?? fingerprintError}`
172
+ : '未读到进程身份指纹';
173
+ let cleanup;
174
+ if (canSignalProcessGroup(child.pid)) {
175
+ try {
176
+ process.kill(-child.pid, 'SIGTERM');
177
+ cleanup = '已向刚拉起的进程组发送 SIGTERM';
178
+ } catch (e) {
179
+ cleanup = `清理刚拉起的进程组失败:${e?.code ? `${e.code}:` : ''}`
180
+ + `${e?.message ?? e}`;
181
+ }
182
+ } else {
183
+ cleanup = 'pid 不是大于 1 的整数,未发信号';
184
+ }
185
+ deps.log(`会话启动失败 ${ws} 任务 ${taskId} pid=${child.pid}:${reason};`
186
+ + cleanup);
187
+ return null;
153
188
  }
154
189
 
155
190
  // 按进程组结束我们拉起的会话。发信号前先核身份:state 条目可能比它命名
156
191
  // 的进程活得久,给回收 pid 发组信号会杀掉无关进程组。detached(用户
157
- // resume 接管)时 trackedAlive 为 false:本函数直接返回,绝不碰用户的裸
158
- // claude。
192
+ // resume 接管)时 trackedAlive 为 false:本函数返回 true,绝不碰用户的裸
193
+ // claude。false 表示记录的进程仍活着,但因缺少指纹或信号失败而未能安全
194
+ // 停止;调用方必须保留跟踪并告警,不能假装已经回收。
159
195
  export async function stopSession(ent) {
160
- if (!deps.trackedAlive(ent)) return;
161
- const pid = ent.pid;
196
+ const pid = ent?.pid;
197
+ if (!canSignalProcessGroup(pid)) return true;
198
+ if (!deps.trackedAlive(ent)) return true;
199
+ // trackedAlive 为兼容旧条目会在没有 procStart 时退回 kill -0;读取可以
200
+ // 沿用这个判断,停止却不行。死进程无需处理,活进程只能如实报告未停止。
201
+ if (!ent.procStart) return deps.alive(pid) ? false : true;
162
202
  try {
163
203
  process.kill(-pid, 'SIGTERM');
164
204
  } catch {
165
- return;
205
+ return deps.alive(pid) ? false : true;
166
206
  }
167
207
  for (let i = 0; i < 20; i++) {
168
208
  await deps.sleep(100);
169
- if (!deps.alive(pid)) return;
209
+ if (!deps.alive(pid)) return true;
170
210
  }
171
- // 升级 SIGKILL 前再核一次指纹。
172
- if (deps.trackedAlive(ent)) {
173
- try {
174
- process.kill(-pid, 'SIGKILL');
175
- } catch {
176
- // 组已消失。
177
- }
178
- await deps.sleep(100);
211
+ if (!deps.trackedAlive(ent)) return true;
212
+ // 升级 SIGKILL 前已再核一次指纹;信号失败或结束后仍是同一进程,都不能
213
+ // 向调用方报告停止成功。
214
+ try {
215
+ process.kill(-pid, 'SIGKILL');
216
+ } catch {
217
+ return deps.alive(pid) ? false : true;
179
218
  }
219
+ await deps.sleep(100);
220
+ return deps.trackedAlive(ent) ? false : true;
180
221
  }
181
222
 
182
223
  // typescript 多少秒没被写。文件缺失读作「没有任何活动证据」:Infinity 大