@xioflow/kernel 0.1.5 → 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 (46) hide show
  1. package/README.md +50 -5
  2. package/dist/domain.d.ts +60 -6
  3. package/dist/domain.d.ts.map +1 -1
  4. package/dist/domain.js +405 -24
  5. package/dist/domain.js.map +1 -1
  6. package/dist/driver/node-driver.d.ts +10 -24
  7. package/dist/driver/node-driver.d.ts.map +1 -1
  8. package/dist/driver/node-driver.js +201 -280
  9. package/dist/driver/node-driver.js.map +1 -1
  10. package/dist/driver/process-facts.d.ts +25 -0
  11. package/dist/driver/process-facts.d.ts.map +1 -0
  12. package/dist/driver/process-facts.js +164 -0
  13. package/dist/driver/process-facts.js.map +1 -0
  14. package/dist/driver/sampler.d.ts +51 -0
  15. package/dist/driver/sampler.d.ts.map +1 -0
  16. package/dist/driver/sampler.js +224 -0
  17. package/dist/driver/sampler.js.map +1 -0
  18. package/dist/driver/types.d.ts +21 -1
  19. package/dist/driver/types.d.ts.map +1 -1
  20. package/dist/index.d.ts +1 -0
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +1 -0
  23. package/dist/index.js.map +1 -1
  24. package/dist/quick-run.d.ts +27 -0
  25. package/dist/quick-run.d.ts.map +1 -0
  26. package/dist/quick-run.js +82 -0
  27. package/dist/quick-run.js.map +1 -0
  28. package/dist/recovery/engine.d.ts.map +1 -1
  29. package/dist/recovery/engine.js +103 -19
  30. package/dist/recovery/engine.js.map +1 -1
  31. package/dist/store/sqlite.d.ts +15 -0
  32. package/dist/store/sqlite.d.ts.map +1 -1
  33. package/dist/store/sqlite.js +210 -12
  34. package/dist/store/sqlite.js.map +1 -1
  35. package/dist/supervisor/supervisor.d.ts +16 -0
  36. package/dist/supervisor/supervisor.d.ts.map +1 -1
  37. package/dist/supervisor/supervisor.js +820 -308
  38. package/dist/supervisor/supervisor.js.map +1 -1
  39. package/dist/testing/contract-suite.d.ts.map +1 -1
  40. package/dist/testing/contract-suite.js +479 -11
  41. package/dist/testing/contract-suite.js.map +1 -1
  42. package/dist/types.d.ts +54 -0
  43. package/dist/types.d.ts.map +1 -1
  44. package/dist/types.js +59 -0
  45. package/dist/types.js.map +1 -1
  46. package/package.json +2 -2
@@ -1,44 +1,106 @@
1
- import { spawn, execFileSync } from 'node:child_process';
1
+ import { spawn } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { readBootId, readStartTime, getGroupMembers } from './process-facts.js';
5
+ import { ProcessSampler } from './sampler.js';
6
+ function resolveExecutable(bin, envPath) {
7
+ if (bin.includes(path.sep)) {
8
+ try {
9
+ fs.accessSync(bin, fs.constants.X_OK);
10
+ return bin;
11
+ }
12
+ catch {
13
+ return null;
14
+ }
15
+ }
16
+ const dirs = (envPath || process.env.PATH || '').split(path.delimiter);
17
+ for (const dir of dirs) {
18
+ if (!dir)
19
+ continue;
20
+ const full = path.join(dir, bin);
21
+ try {
22
+ fs.accessSync(full, fs.constants.X_OK);
23
+ return full;
24
+ }
25
+ catch { }
26
+ }
27
+ return null;
28
+ }
2
29
  export class NodePlatformDriver {
3
30
  name = 'node-default';
31
+ cachedBootId = readBootId();
32
+ sampler = ProcessSampler.getInstance();
33
+ async readBootId() {
34
+ return this.cachedBootId;
35
+ }
36
+ async getGroupEvidence(pgid) {
37
+ return getGroupMembers(pgid);
38
+ }
4
39
  capabilities = {
5
40
  // Windows 无进程组语义:process.kill(-pid) 会失败并退化为只杀直接子进程,
6
41
  // 所以必须如实声明不支持,让准入期拒绝 hard 请求而不是静默降级。
7
42
  processGroupKill: process.platform !== 'win32',
8
43
  accurateStartTime: true,
44
+ startTimeSource: process.platform === 'linux' ? 'procfs' : process.platform === 'darwin' ? 'ps_lstart' : 'none',
9
45
  memoryHardLimit: false, // macOS/Node.js 默认不支持进程树 cgroup 内存硬限
10
46
  pidsLimit: false,
11
47
  cpuLimit: false,
12
48
  // 后代枚举依赖 ps(1):Windows 上无法枚举,不能谎报 full。
13
49
  descendantEnumeration: process.platform === 'win32' ? 'none' : 'full',
50
+ // 门管道受控启动:POSIX 平台下通过 /bin/sh 与 stdio[3] 实现
51
+ gatedSpawn: process.platform !== 'win32',
14
52
  };
15
53
  // 内存中追踪当前驱动启动的所有活跃进程
16
54
  activeHandles = new Map();
17
55
  // 持续累积后代 PID 集合:leaderPid -> Set<descendantPid>
18
56
  cumulativeDescendantsMap = new Map();
19
- // 活跃进程树监控定时器:leaderPid -> Timeout
20
- descendantPollers = new Map();
57
+ pollerTimer = null;
21
58
  async spawn(command) {
22
59
  const stdinPayload = command.stdin === undefined
23
60
  ? undefined
24
61
  : typeof command.stdin === 'string'
25
62
  ? Buffer.from(command.stdin, 'utf8')
26
63
  : Buffer.from(command.stdin);
64
+ const childEnv = command.envWhiteList
65
+ ? { ...command.envWhiteList }
66
+ : command.inheritEnv === false
67
+ ? {}
68
+ : process.env;
69
+ const useGatedSpawn = Boolean(this.capabilities.gatedSpawn);
70
+ // 门管道模式下,先验证二进制是否存在与可执行(P0-3 / 契约 #1 保证)
71
+ if (useGatedSpawn) {
72
+ const resolved = resolveExecutable(command.execPath, childEnv.PATH);
73
+ if (!resolved) {
74
+ const err = new Error(`spawn ${command.execPath} ENOENT`);
75
+ err.code = 'ENOENT';
76
+ err.syscall = `spawn ${command.execPath}`;
77
+ err.path = command.execPath;
78
+ return Promise.reject(err);
79
+ }
80
+ }
27
81
  return new Promise((resolve, reject) => {
28
82
  let child;
83
+ let gatePipe = null;
84
+ let gateReleased = false;
29
85
  try {
30
- // envWhiteList 是精确语义:给了什么就是什么,不静默补 PATH。
31
- const childEnv = command.envWhiteList
32
- ? { ...command.envWhiteList }
33
- : command.inheritEnv === false
34
- ? {}
35
- : process.env;
36
- child = spawn(command.execPath, command.args, {
37
- cwd: command.cwd,
38
- env: childEnv,
39
- detached: true,
40
- stdio: [stdinPayload === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'],
41
- });
86
+ if (useGatedSpawn) {
87
+ const springboard = 'IFS= read -r _ <&3 || exit 125; exec 3<&-; exec "$0" "$@";';
88
+ child = spawn('/bin/sh', ['-c', springboard, command.execPath, ...command.args], {
89
+ cwd: command.cwd,
90
+ env: childEnv,
91
+ detached: true,
92
+ stdio: [stdinPayload === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe', 'pipe'],
93
+ });
94
+ gatePipe = child.stdio[3];
95
+ }
96
+ else {
97
+ child = spawn(command.execPath, command.args, {
98
+ cwd: command.cwd,
99
+ env: childEnv,
100
+ detached: true,
101
+ stdio: [stdinPayload === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'],
102
+ });
103
+ }
42
104
  }
43
105
  catch (err) {
44
106
  return reject(err);
@@ -49,41 +111,28 @@ export class NodePlatformDriver {
49
111
  reject(err);
50
112
  }
51
113
  });
52
- child.on('spawn', () => {
114
+ child.on('spawn', async () => {
53
115
  hasSpawned = true;
54
116
  const pid = child.pid;
55
117
  const startTimeMonotonic = Number(process.hrtime.bigint() / 1000n);
118
+ const bootId = await this.cachedBootId;
56
119
  const identity = {
57
120
  pid,
58
121
  pgid: pid,
59
122
  startTimeMonotonic,
60
123
  spawnTime: new Date().toISOString(),
61
124
  commandFingerprint: `${command.execPath}:${command.args.join(' ')}`,
125
+ bootId: bootId ?? undefined,
62
126
  };
63
127
  const cumSet = new Set();
64
128
  this.cumulativeDescendantsMap.set(pid, cumSet);
65
- const scanDescendants = () => {
66
- const currentCum = this.cumulativeDescendantsMap.get(pid);
67
- if (!currentCum)
68
- return;
69
- const tree = this.getProcessTree(pid);
70
- for (const d of tree.allDescendants) {
71
- currentCum.add(d);
72
- }
73
- };
74
- scanDescendants();
75
- const poller = setInterval(scanDescendants, 30);
76
- if (poller.unref)
77
- poller.unref();
78
- this.descendantPollers.set(pid, poller);
129
+ this.ensureSharedPoller();
79
130
  const exitPromise = new Promise((exitResolve) => {
80
131
  const cleanup = () => {
81
- const p = this.descendantPollers.get(pid);
82
- if (p) {
83
- clearInterval(p);
84
- this.descendantPollers.delete(pid);
85
- }
86
132
  this.activeHandles.delete(pid);
133
+ if (this.activeHandles.size === 0) {
134
+ this.stopSharedPoller();
135
+ }
87
136
  };
88
137
  child.on('close', (exitCode, signal) => {
89
138
  cleanup();
@@ -107,6 +156,25 @@ export class NodePlatformDriver {
107
156
  child.on('error', () => settle(1, null));
108
157
  child.on('close', (exitCode, signal) => settle(exitCode, signal));
109
158
  });
159
+ const releaseGate = () => {
160
+ if (!gateReleased && gatePipe && !gatePipe.destroyed) {
161
+ gateReleased = true;
162
+ try {
163
+ gatePipe.write('GO\n');
164
+ gatePipe.end();
165
+ }
166
+ catch { }
167
+ }
168
+ };
169
+ const destroyGate = () => {
170
+ if (!gateReleased && gatePipe && !gatePipe.destroyed) {
171
+ gateReleased = true;
172
+ try {
173
+ gatePipe.destroy();
174
+ }
175
+ catch { }
176
+ }
177
+ };
110
178
  const handle = {
111
179
  identity,
112
180
  stdout: child.stdout,
@@ -114,6 +182,8 @@ export class NodePlatformDriver {
114
182
  onRootExit: rootExitPromise,
115
183
  onExit: exitPromise,
116
184
  rawProcess: child,
185
+ releaseGate: useGatedSpawn ? releaseGate : undefined,
186
+ destroyGate: useGatedSpawn ? destroyGate : undefined,
117
187
  };
118
188
  this.activeHandles.set(pid, { handle, child, startTime: startTimeMonotonic });
119
189
  // 一次性 stdin:写入后关闭。子进程不读就退出的 EPIPE 不是启动失败,由退出码体现。
@@ -125,14 +195,45 @@ export class NodePlatformDriver {
125
195
  });
126
196
  });
127
197
  }
198
+ ensureSharedPoller() {
199
+ if (this.pollerTimer)
200
+ return;
201
+ this.pollerTimer = setInterval(async () => {
202
+ if (this.cumulativeDescendantsMap.size === 0) {
203
+ this.stopSharedPoller();
204
+ return;
205
+ }
206
+ try {
207
+ const snapshot = await this.sampler.getSnapshot(30);
208
+ for (const [leaderPid, cumSet] of this.cumulativeDescendantsMap.entries()) {
209
+ const tree = await this.sampler.getProcessTree(leaderPid, snapshot, Array.from(cumSet));
210
+ for (const d of tree.allDescendants) {
211
+ cumSet.add(d);
212
+ }
213
+ }
214
+ }
215
+ catch { }
216
+ }, 35);
217
+ if (this.pollerTimer.unref)
218
+ this.pollerTimer.unref();
219
+ }
220
+ stopSharedPoller() {
221
+ if (this.pollerTimer) {
222
+ clearInterval(this.pollerTimer);
223
+ this.pollerTimer = null;
224
+ }
225
+ }
128
226
  async verifyIdentity(identity) {
129
- // 1. 检查操作系统中该 PID 是否存活。僵尸进程(已退出、等待父进程收尸)不能再算
130
- // 存活:它既不会继续工作,ps 里的命令也已经变成 `<defunct>`,若按存活处理就会
131
- // 走到下面的指纹校验并得出 cannot_determine,把一个已经确定的崩溃错判成不可知。
132
- if (!this.isPidAlive(identity.pid) || this.isZombie(identity.pid)) {
227
+ // 1. 检查操作系统中该 PID 是否存活或为僵尸
228
+ if (!this.isPidAlive(identity.pid) || (await this.sampler.isZombie(identity.pid))) {
133
229
  return 'not_original_process';
134
230
  }
135
- // 2. 核对内存中本驱动登记的启动记录
231
+ // 2. bootId 核对:宿主若已重启,绝不盲目信任 PID (P0-1)
232
+ const currentBootId = await this.readBootId();
233
+ if (identity.bootId && currentBootId && identity.bootId !== currentBootId) {
234
+ return 'cannot_determine';
235
+ }
236
+ // 3. 核对内存中本驱动登记的启动记录
136
237
  const tracked = this.activeHandles.get(identity.pid);
137
238
  if (tracked) {
138
239
  if (identity.startTimeMonotonic &&
@@ -142,21 +243,33 @@ export class NodePlatformDriver {
142
243
  }
143
244
  return 'cannot_determine';
144
245
  }
145
- // 3. 跨崩溃/重启进程恢复:查 OS 进程命令行指纹验证真实身份
246
+ // 4. 跨崩溃/重启进程恢复:查 OS 进程创建时间与命令行指纹 (P0-1)
247
+ const snapshot = await this.sampler.getSnapshot(100);
248
+ const proc = snapshot.get(identity.pid);
249
+ // 进程在进程表中已不存在,或处于僵尸态(<defunct> / Z),直接判定为死亡
250
+ if (!proc || proc.state === 'Z' || proc.command.includes('<defunct>')) {
251
+ return 'not_original_process';
252
+ }
253
+ // 若命令行指纹不匹配,直接否定为 not_original_process
146
254
  if (identity.commandFingerprint) {
147
- try {
148
- const cmdLine = execFileSync('ps', ['-p', String(identity.pid), '-o', 'command='], {
149
- encoding: 'utf8',
150
- stdio: ['ignore', 'pipe', 'ignore'],
151
- }).trim();
152
- const baseExec = identity.commandFingerprint.split(':')[0];
153
- if (cmdLine && cmdLine.includes(baseExec)) {
255
+ const baseExec = identity.commandFingerprint.split(':')[0];
256
+ if (!proc.command.includes(baseExec)) {
257
+ return 'not_original_process';
258
+ }
259
+ }
260
+ // 若登记了 spawnTime,核对 OS 实际启动时间
261
+ const actualStartTimeMs = await readStartTime(identity.pid);
262
+ if (identity.spawnTime && actualStartTimeMs !== null) {
263
+ const expectedTimeMs = new Date(identity.spawnTime).getTime();
264
+ if (!isNaN(expectedTimeMs)) {
265
+ // macOS ps lstart 为 1 秒精度,允许 3 秒宽限
266
+ if (Math.abs(actualStartTimeMs - expectedTimeMs) > 3000) {
267
+ return 'not_original_process';
268
+ }
269
+ if (proc && identity.commandFingerprint && proc.command.includes(identity.commandFingerprint.split(':')[0])) {
154
270
  return 'is_original_process';
155
271
  }
156
272
  }
157
- catch {
158
- return 'cannot_determine';
159
- }
160
273
  }
161
274
  return 'cannot_determine';
162
275
  }
@@ -164,18 +277,25 @@ export class NodePlatformDriver {
164
277
  const pid = identity.pid;
165
278
  const cumSet = this.cumulativeDescendantsMap.get(pid);
166
279
  // 1. 终止前先枚举后代进程树,捕获所有组内进程与逃逸进程
167
- const initialTree = this.getProcessTree(pid);
280
+ this.sampler.clearCache();
281
+ const initialTree = await this.sampler.getProcessTree(pid, undefined, cumSet ? Array.from(cumSet) : undefined);
282
+ if (cumSet) {
283
+ for (const d of initialTree.allDescendants) {
284
+ cumSet.add(d);
285
+ }
286
+ }
168
287
  const allTrackedPids = new Set([pid, ...initialTree.allDescendants]);
169
288
  if (cumSet) {
170
289
  for (const cp of cumSet) {
171
290
  allTrackedPids.add(cp);
172
291
  }
173
292
  }
174
- // 若原主进程已死且无任何后代存活,直接收尾
293
+ // 若原主进程已死且无任何进程组或后代存活,直接收尾
294
+ const isGroupAlive = await this.sampler.isGroupAlive(pid);
175
295
  const aliveBeforeSignals = Array.from(allTrackedPids).filter((p) => p !== pid && this.isPidAlive(p));
176
- if (!this.isPidAlive(pid) && aliveBeforeSignals.length === 0) {
296
+ if (!this.isPidAlive(pid) && !isGroupAlive && aliveBeforeSignals.length === 0) {
177
297
  this.cumulativeDescendantsMap.delete(pid);
178
- return { stopped: true, scope: 'direct_child' };
298
+ return { stopped: 'confirmed_stopped', scope: 'direct_child' };
179
299
  }
180
300
  // 第一阶段:SIGINT 中断信号至整组
181
301
  this.sendSignalToGroup(pid, 'SIGINT');
@@ -191,7 +311,8 @@ export class NodePlatformDriver {
191
311
  exited = await this.waitForGroupAndPids(pid, initialTree.inGroupDescendants, 1000);
192
312
  }
193
313
  // 组空后再次扫描后代,核查是否有任何残留进程或 setsid 逃逸孤儿
194
- const postTree = this.getProcessTree(pid);
314
+ this.sampler.clearCache();
315
+ const postTree = await this.sampler.getProcessTree(pid, undefined, cumSet ? Array.from(cumSet) : undefined);
195
316
  for (const p of postTree.allDescendants) {
196
317
  allTrackedPids.add(p);
197
318
  }
@@ -203,191 +324,65 @@ export class NodePlatformDriver {
203
324
  // 探活所有曾属于该进程树的后代
204
325
  const aliveResiduals = Array.from(allTrackedPids).filter((p) => this.isPidAlive(p));
205
326
  if (aliveResiduals.length > 0) {
206
- // 存在逃逸或残留孤儿,如实标记 stopped: false 与 residualPids
327
+ // 存在逃逸或残留孤儿,如实标记 cannot_determine 与 residualPids
207
328
  return {
208
- stopped: false,
329
+ stopped: 'cannot_determine',
209
330
  scope: 'unknown',
210
331
  residualPids: aliveResiduals,
211
332
  errorDetails: `Residual or escaped processes still alive after termination: ${aliveResiduals.join(', ')}`,
212
333
  };
213
334
  }
214
335
  this.cumulativeDescendantsMap.delete(pid);
215
- return { stopped: true, scope: 'process_group' };
336
+ return { stopped: 'confirmed_stopped', scope: 'process_group' };
216
337
  }
217
- /**
218
- * 崩溃恢复专用:leader 已经死了(通常是 SIGKILL 后的僵尸),但组里可能还有后代
219
- * 进程活着。这些进程没有任何 owner,唯一诚实的处置是按 pgid 定向清场,并在返回前
220
- * 确认组已经空了——否则残留进程会被继续留在这个域里。
221
- */
222
338
  async terminateGroup(pgid, graceMs = 2000) {
223
339
  if (!this.capabilities.processGroupKill) {
224
340
  return {
225
- stopped: false,
341
+ stopped: 'not_stopped',
226
342
  scope: 'unknown',
227
343
  errorDetails: 'process groups are not supported on this platform',
228
344
  };
229
345
  }
230
- if (!this.isGroupAlive(pgid)) {
231
- return { stopped: true, scope: 'process_group' };
346
+ if (!(await this.sampler.isGroupAlive(pgid))) {
347
+ return { stopped: 'confirmed_stopped', scope: 'process_group' };
232
348
  }
233
349
  this.sendSignalToGroup(pgid, 'SIGKILL');
234
350
  const start = Date.now();
235
351
  while (Date.now() - start < graceMs) {
236
- if (!this.isGroupAlive(pgid)) {
352
+ this.sampler.clearCache();
353
+ if (!(await this.sampler.isGroupAlive(pgid))) {
237
354
  this.cumulativeDescendantsMap.delete(pgid);
238
- return { stopped: true, scope: 'process_group' };
355
+ return { stopped: 'confirmed_stopped', scope: 'process_group' };
239
356
  }
240
357
  await new Promise((r) => setTimeout(r, 25));
241
358
  }
359
+ const snapshot = await this.sampler.getSnapshot(10);
242
360
  const survivors = [];
243
- for (const entry of this.processTable().values()) {
361
+ for (const entry of snapshot.values()) {
244
362
  if (entry.state === 'Z')
245
363
  continue;
246
364
  if (entry.pgid === pgid)
247
365
  survivors.push(entry.pid);
248
366
  }
249
367
  return {
250
- stopped: survivors.length === 0,
368
+ stopped: survivors.length === 0 ? 'confirmed_stopped' : 'cannot_determine',
251
369
  scope: 'process_group',
252
370
  residualPids: survivors.length > 0 ? survivors : undefined,
253
371
  };
254
372
  }
255
373
  async sampleMetrics(identity) {
256
374
  try {
257
- const tree = this.getProcessTree(identity.pid);
375
+ const tree = await this.sampler.getProcessTree(identity.pid);
258
376
  const targetPids = [identity.pid, ...tree.allDescendants].filter((p) => this.isPidAlive(p));
259
377
  if (targetPids.length === 0) {
260
378
  return { rssBytes: 0, pidsCount: 0, cpuTimeMs: 0 };
261
379
  }
262
- const out = execFileSync('ps', ['-p', targetPids.join(','), '-o', 'pid=,rss=,time='], {
263
- encoding: 'utf8',
264
- stdio: ['ignore', 'pipe', 'ignore'],
265
- });
266
- let totalRssKb = 0;
267
- let totalCpuMs = 0;
268
- let count = 0;
269
- const lines = out.split('\n');
270
- for (const line of lines) {
271
- const trimmed = line.trim();
272
- if (!trimmed)
273
- continue;
274
- const parts = trimmed.split(/\s+/);
275
- if (parts.length >= 3) {
276
- const rssKb = parseInt(parts[1], 10) || 0;
277
- const timeStr = parts[2];
278
- totalRssKb += rssKb;
279
- totalCpuMs += this.parseCpuTimeToMs(timeStr);
280
- count++;
281
- }
282
- }
283
- return {
284
- rssBytes: totalRssKb * 1024,
285
- pidsCount: count || targetPids.length,
286
- cpuTimeMs: totalCpuMs,
287
- };
380
+ return this.sampler.sampleMetrics(targetPids);
288
381
  }
289
382
  catch {
290
383
  return { rssBytes: 0, pidsCount: 0, cpuTimeMs: 0 };
291
384
  }
292
385
  }
293
- getProcessTree(leaderPid) {
294
- const allProcs = this.getAllProcesses();
295
- const procsByPid = new Map();
296
- const childrenMap = new Map();
297
- for (const p of allProcs) {
298
- procsByPid.set(p.pid, p);
299
- const list = childrenMap.get(p.ppid);
300
- if (list) {
301
- list.push(p);
302
- }
303
- else {
304
- childrenMap.set(p.ppid, [p]);
305
- }
306
- }
307
- const allDescendants = [];
308
- const inGroupDescendants = [];
309
- const escapedDescendants = [];
310
- const cumSet = this.cumulativeDescendantsMap.get(leaderPid);
311
- const historicalDescendants = cumSet ? Array.from(cumSet) : [];
312
- // 初始队列包括 leaderPid 以及所有已知历史后代(防止中间父进程提前退出导致孙进程 PPID 变为 1 发生断链漏报)
313
- const queue = [leaderPid];
314
- const visited = new Set([leaderPid]);
315
- for (const hPid of historicalDescendants) {
316
- if (!visited.has(hPid)) {
317
- visited.add(hPid);
318
- queue.push(hPid);
319
- const pInfo = procsByPid.get(hPid);
320
- if (pInfo) {
321
- allDescendants.push(hPid);
322
- if (pInfo.pgid === leaderPid) {
323
- inGroupDescendants.push(hPid);
324
- }
325
- else {
326
- escapedDescendants.push(hPid);
327
- }
328
- }
329
- }
330
- }
331
- while (queue.length > 0) {
332
- const currentPid = queue.shift();
333
- const children = childrenMap.get(currentPid) || [];
334
- for (const child of children) {
335
- if (!visited.has(child.pid)) {
336
- visited.add(child.pid);
337
- allDescendants.push(child.pid);
338
- if (child.pgid === leaderPid) {
339
- inGroupDescendants.push(child.pid);
340
- }
341
- else {
342
- escapedDescendants.push(child.pid);
343
- }
344
- queue.push(child.pid);
345
- }
346
- }
347
- }
348
- // 扫描可能已由于中间父进程退出导致 PPID 变为 1、但仍同属于 leader 进程组的残留进程
349
- for (const p of allProcs) {
350
- if (p.pgid === leaderPid && !visited.has(p.pid)) {
351
- visited.add(p.pid);
352
- allDescendants.push(p.pid);
353
- inGroupDescendants.push(p.pid);
354
- }
355
- }
356
- if (cumSet) {
357
- for (const d of allDescendants) {
358
- cumSet.add(d);
359
- }
360
- }
361
- return { allDescendants, inGroupDescendants, escapedDescendants };
362
- }
363
- getAllProcesses() {
364
- try {
365
- const output = execFileSync('ps', ['-A', '-o', 'pid=,ppid=,pgid=,command='], {
366
- encoding: 'utf8',
367
- stdio: ['ignore', 'pipe', 'ignore'],
368
- });
369
- const lines = output.split('\n');
370
- const procs = [];
371
- for (const line of lines) {
372
- const trimmed = line.trim();
373
- if (!trimmed)
374
- continue;
375
- const match = trimmed.match(/^(\d+)\s+(\d+)\s+(\d+)\s+(.*)$/);
376
- if (match) {
377
- procs.push({
378
- pid: parseInt(match[1], 10),
379
- ppid: parseInt(match[2], 10),
380
- pgid: parseInt(match[3], 10),
381
- command: match[4],
382
- });
383
- }
384
- }
385
- return procs;
386
- }
387
- catch {
388
- return [];
389
- }
390
- }
391
386
  isPidAlive(pid) {
392
387
  try {
393
388
  process.kill(pid, 0);
@@ -397,68 +392,24 @@ export class NodePlatformDriver {
397
392
  return err.code === 'EPERM';
398
393
  }
399
394
  }
400
- /**
401
- * 崩溃恢复的判定依据:进程组里是否还有**真正能工作**的进程。
402
- *
403
- * SIGKILL 掉的 leader 会以僵尸形态留在组里,`kill(-pgid, 0)` 因此永远为真。
404
- * 只按 PID 存在与否判断会让恢复永远无法确认"组已清空",孤儿进程也就永远得不到
405
- * 处置。这里按 ps 的进程状态把僵尸排除掉。
406
- */
407
- isGroupAlive(pgid) {
408
- try {
409
- process.kill(-pgid, 0);
410
- }
411
- catch (err) {
412
- return err.code === 'EPERM';
413
- }
414
- // The group exists; zombies do not count as work in progress.
415
- for (const entry of this.processTable().values()) {
416
- if (entry.state === 'Z')
417
- continue;
418
- if (entry.pgid === pgid)
419
- return true;
420
- }
421
- return false;
422
- }
423
- /** pid → { pid, pgid, state } snapshot from `ps -A`; state is one letter (R/S/T/Z/…). */
424
- processTable() {
425
- const table = new Map();
426
- try {
427
- const output = execFileSync('ps', ['-A', '-o', 'pid=,pgid=,state='], {
428
- encoding: 'utf8',
429
- stdio: ['ignore', 'pipe', 'ignore'],
430
- });
431
- for (const line of output.split('\n')) {
432
- const match = line.trim().match(/^(\d+)\s+(\d+)\s+(\S+)/);
433
- if (!match)
434
- continue;
435
- const pid = parseInt(match[1], 10);
436
- table.set(pid, { pid, pgid: parseInt(match[2], 10), state: match[3][0] });
437
- }
438
- }
439
- catch {
440
- // no snapshot available; callers treat the process as alive
441
- }
442
- return table;
443
- }
444
- isZombie(pid) {
445
- return this.processTable().get(pid)?.state === 'Z';
446
- }
447
395
  sendSignalToGroup(pid, signal) {
448
396
  try {
449
397
  process.kill(-pid, signal);
450
398
  }
451
- catch {
452
- try {
453
- process.kill(pid, signal);
399
+ catch (err) {
400
+ if (err && err.code === 'ESRCH') {
401
+ // ESRCH 表示该进程组已不存在或已完全清空,如实返回
402
+ return;
454
403
  }
455
- catch { }
404
+ // 其他错误(如 EPERM)如实上抛,禁止退回向单 PID 发信号(N5 / 契约 #42)
405
+ throw err;
456
406
  }
457
407
  }
458
408
  async waitForGroupAndPids(pid, inGroupPids, timeoutMs) {
459
409
  const start = Date.now();
460
410
  while (Date.now() - start < timeoutMs) {
461
- const grpAlive = this.isGroupAlive(pid);
411
+ this.sampler.clearCache();
412
+ const grpAlive = await this.sampler.isGroupAlive(pid);
462
413
  const procAlive = this.isPidAlive(pid);
463
414
  const inGrpAlive = inGroupPids.some((p) => this.isPidAlive(p));
464
415
  if (!grpAlive && !procAlive && !inGrpAlive) {
@@ -466,41 +417,11 @@ export class NodePlatformDriver {
466
417
  }
467
418
  await new Promise((r) => setTimeout(r, 50));
468
419
  }
469
- const stillGrp = this.isGroupAlive(pid);
420
+ this.sampler.clearCache();
421
+ const stillGrp = await this.sampler.isGroupAlive(pid);
470
422
  const stillProc = this.isPidAlive(pid);
471
423
  const stillInGrp = inGroupPids.some((p) => this.isPidAlive(p));
472
424
  return !stillGrp && !stillProc && !stillInGrp;
473
425
  }
474
- parseCpuTimeToMs(timeStr) {
475
- try {
476
- let days = 0;
477
- let rest = timeStr;
478
- if (rest.includes('-')) {
479
- const daySplit = rest.split('-');
480
- days = parseInt(daySplit[0], 10) || 0;
481
- rest = daySplit[1];
482
- }
483
- const parts = rest.split(':');
484
- let hours = 0;
485
- let minutes = 0;
486
- let seconds = 0;
487
- if (parts.length === 3) {
488
- hours = parseInt(parts[0], 10) || 0;
489
- minutes = parseInt(parts[1], 10) || 0;
490
- seconds = parseFloat(parts[2]) || 0;
491
- }
492
- else if (parts.length === 2) {
493
- minutes = parseInt(parts[0], 10) || 0;
494
- seconds = parseFloat(parts[1]) || 0;
495
- }
496
- else if (parts.length === 1) {
497
- seconds = parseFloat(parts[0]) || 0;
498
- }
499
- return Math.round(days * 86400000 + hours * 3600000 + minutes * 60000 + seconds * 1000);
500
- }
501
- catch {
502
- return 0;
503
- }
504
- }
505
426
  }
506
427
  //# sourceMappingURL=node-driver.js.map