@lark-codem/codem-cli 0.1.270 → 0.1.271-early-build-dev.100103738101.0.g5243898

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.
@@ -0,0 +1,217 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+
4
+ import { applyHostedWindowsChildOptions } from "./platform-child-process.js";
5
+
6
+ const execFileAsync = promisify(execFile);
7
+
8
+ /**
9
+ * 进程树终止。
10
+ *
11
+ * 为什么不能只 `child.kill()`:我们 spawn 的是 linco 一个进程,但 linco 干活时
12
+ * 会自己再起子进程(内联的 `npm run dev`、`cargo build`、git…)。只杀直接子进程
13
+ * 会把整棵子树留成孤儿——它们继续占端口、占锁,而且**继承了我们的 stdio 管道**,
14
+ * 于是 `child` 的 `close` 永远不触发(见 linco-runner.js 的
15
+ * LINCO_EXIT_DRAIN_GRACE_MS)。
16
+ *
17
+ * 为什么必须"先快照后代、再杀":`ps` 反查 ppid(unix)和 `taskkill /T`
18
+ * (Windows)**都依赖父进程还活着**。父进程一死,后代立刻被 reparent 到 PID 1
19
+ * (Windows 上 ppid 指向一个已消失的 pid),血缘信息就永久丢了,再也认不出哪些
20
+ * 进程曾属于这次 run。而"父死子活"恰恰就是我们要处理的那个场景。所以血缘必须
21
+ * 在**树还完整的时刻**取样,强杀阶段只按快照执行。
22
+ */
23
+
24
+ // `ps -A -o pid=,ppid=` 是 POSIX 通用形态,macOS / Linux 输出一致(两列数字,
25
+ // 前导空格对齐)。用 `=` 后缀去掉表头,省一次解析分支。
26
+ const UNIX_PS_ARGS = ["-A", "-o", "pid=,ppid="];
27
+
28
+ // Win32 没有 POSIX ps。PowerShell 的 CIM 查询比已废弃的 wmic 稳(wmic 在
29
+ // Windows 11 / Server 2025 上默认不再安装)。输出两列数字,与 unix 分支同构。
30
+ const WINDOWS_PS_ARGS = [
31
+ "-NoProfile",
32
+ "-NonInteractive",
33
+ "-Command",
34
+ "Get-CimInstance Win32_Process | ForEach-Object { \"$($_.ProcessId) $($_.ParentProcessId)\" }"
35
+ ];
36
+
37
+ function parsePidPairs(stdout) {
38
+ const pairs = [];
39
+ for (const line of String(stdout ?? "").split("\n")) {
40
+ const match = line.trim().match(/^(\d+)\s+(\d+)$/);
41
+ if (!match) {
42
+ continue;
43
+ }
44
+ pairs.push([Number.parseInt(match[1], 10), Number.parseInt(match[2], 10)]);
45
+ }
46
+ return pairs;
47
+ }
48
+
49
+ /**
50
+ * 广度优先收集 rootPid 的全部后代(不含 rootPid 自己),返回**由深到浅**的顺序。
51
+ *
52
+ * 由深到浅是为了终止时先杀叶子:先杀中间节点会让它的孩子提前变孤儿,剩下的
53
+ * kill 就打空了。
54
+ *
55
+ * 任何失败都返回空数组——快照是尽力而为的增强,绝不能让它把调用方的终止路径
56
+ * 拖崩。拿不到血缘时调用方仍会杀直接子进程,等于退回改动前的行为。
57
+ */
58
+ export async function snapshotDescendantPids(
59
+ rootPid,
60
+ { platform = process.platform, execFileImpl = execFileAsync, timeoutMs = null, onError = null } = {}
61
+ ) {
62
+ if (!Number.isInteger(rootPid) || rootPid <= 0) {
63
+ return [];
64
+ }
65
+
66
+ const startedAt = performance.now();
67
+ let pairs = [];
68
+ try {
69
+ const { stdout } =
70
+ platform === "win32"
71
+ ? await execFileImpl(
72
+ "powershell.exe",
73
+ WINDOWS_PS_ARGS,
74
+ applyHostedWindowsChildOptions(
75
+ Number.isFinite(timeoutMs) && timeoutMs > 0 ? { timeout: timeoutMs } : {},
76
+ { platform }
77
+ )
78
+ )
79
+ : await execFileImpl(
80
+ "ps",
81
+ UNIX_PS_ARGS,
82
+ applyHostedWindowsChildOptions(
83
+ Number.isFinite(timeoutMs) && timeoutMs > 0 ? { timeout: timeoutMs } : {},
84
+ { platform }
85
+ )
86
+ );
87
+ pairs = parsePidPairs(stdout);
88
+ } catch (error) {
89
+ onError?.(error, {
90
+ durationMs: Math.round(performance.now() - startedAt),
91
+ timeoutMs,
92
+ code: error?.code ?? null,
93
+ killed: error?.killed === true,
94
+ signal: error?.signal ?? null
95
+ });
96
+ return [];
97
+ }
98
+
99
+ const childrenByParent = new Map();
100
+ for (const [pid, ppid] of pairs) {
101
+ if (pid === ppid) {
102
+ // 自指(部分系统上 pid 1 会这么报)。放任会让下面的 BFS 死循环。
103
+ continue;
104
+ }
105
+ const bucket = childrenByParent.get(ppid);
106
+ if (bucket) {
107
+ bucket.push(pid);
108
+ } else {
109
+ childrenByParent.set(ppid, [pid]);
110
+ }
111
+ }
112
+
113
+ const levels = [];
114
+ const seen = new Set([rootPid]);
115
+ let frontier = [rootPid];
116
+ // 深度上限:纯防御。真实进程树不会有 64 层,但快照来自外部命令的文本输出,
117
+ // 一旦解析出环就会在这里无限打转。
118
+ for (let depth = 0; depth < 64 && frontier.length > 0; depth += 1) {
119
+ const next = [];
120
+ for (const pid of frontier) {
121
+ for (const childPid of childrenByParent.get(pid) ?? []) {
122
+ if (seen.has(childPid)) {
123
+ continue;
124
+ }
125
+ seen.add(childPid);
126
+ next.push(childPid);
127
+ }
128
+ }
129
+ if (next.length > 0) {
130
+ levels.push(next);
131
+ }
132
+ frontier = next;
133
+ }
134
+
135
+ return levels.reverse().flat();
136
+ }
137
+
138
+ /**
139
+ * 杀掉 rootPid 及其后代。
140
+ *
141
+ * `descendants` 由调用方在树还完整时用 snapshotDescendantPids 取样后传入;
142
+ * 不传就现取(只在父进程仍活着时才有意义)。
143
+ *
144
+ * 返回实际发出信号的 pid 数——调用方用它做日志,不做控制流:kill 失败绝大多数
145
+ * 是"进程已经没了",那正是我们要的结果。
146
+ */
147
+ export async function terminateProcessTree({
148
+ pid,
149
+ descendants = null,
150
+ signal = "SIGKILL",
151
+ platform = process.platform,
152
+ execFileImpl = execFileAsync,
153
+ killImpl = (targetPid, targetSignal) => process.kill(targetPid, targetSignal),
154
+ timeoutMs = null,
155
+ onError = null
156
+ } = {}) {
157
+ if (!Number.isInteger(pid) || pid <= 0) {
158
+ return { signaled: 0 };
159
+ }
160
+
161
+ const targets =
162
+ descendants ??
163
+ (await snapshotDescendantPids(pid, { platform, execFileImpl, timeoutMs, onError }));
164
+ let signaled = 0;
165
+
166
+ if (platform === "win32") {
167
+ // /T 覆盖此刻仍挂在 pid 下的活树,一次调用比逐个 taskkill 更少竞态。
168
+ try {
169
+ await execFileImpl(
170
+ "taskkill.exe",
171
+ ["/F", "/T", "/PID", String(pid)],
172
+ applyHostedWindowsChildOptions(
173
+ Number.isFinite(timeoutMs) && timeoutMs > 0 ? { timeout: timeoutMs } : {},
174
+ { platform }
175
+ )
176
+ );
177
+ signaled += 1;
178
+ } catch (error) {
179
+ // pid 已消失时 taskkill 返回非 0,属预期。
180
+ onError?.(error);
181
+ }
182
+ // 快照里那些已经和 pid 断了血缘的(父先死的孤儿),/T 够不着,逐个补刀。
183
+ for (const targetPid of targets) {
184
+ try {
185
+ await execFileImpl(
186
+ "taskkill.exe",
187
+ ["/F", "/PID", String(targetPid)],
188
+ applyHostedWindowsChildOptions(
189
+ Number.isFinite(timeoutMs) && timeoutMs > 0 ? { timeout: timeoutMs } : {},
190
+ { platform }
191
+ )
192
+ );
193
+ signaled += 1;
194
+ } catch {
195
+ // 已退出。
196
+ }
197
+ }
198
+ return { signaled };
199
+ }
200
+
201
+ // unix:叶子优先,最后才是 root。
202
+ for (const targetPid of targets) {
203
+ try {
204
+ killImpl(targetPid, signal);
205
+ signaled += 1;
206
+ } catch {
207
+ // 已退出。
208
+ }
209
+ }
210
+ try {
211
+ killImpl(pid, signal);
212
+ signaled += 1;
213
+ } catch {
214
+ // 已退出。
215
+ }
216
+ return { signaled };
217
+ }
@@ -1,6 +1,7 @@
1
1
  // Startup-only routing. Keep this module independent of Bridge business imports:
2
2
  // it is also executed by container entrypoints before either Bridge is loaded.
3
- import { spawn } from 'node:child_process';
3
+ import { execFile, spawn } from 'node:child_process';
4
+ import { promisify } from 'node:util';
4
5
  import { createHash, randomUUID } from 'node:crypto';
5
6
  import fs from 'node:fs/promises';
6
7
  import { constants as fsConstants } from 'node:fs';
@@ -11,6 +12,9 @@ import { setTimeout as delay } from 'node:timers/promises';
11
12
  import { applyHostedWindowsChildOptions } from './platform-child-process.js';
12
13
  import { resolveEffectiveLincoHome, resolveLincoSessionsRoot, sameRecoveryPath } from './linco-paths.js';
13
14
  import { observeWorkerRuntime } from './worker-runtime-telemetry.js';
15
+ import { snapshotDescendantPids, terminateProcessTree } from './process-tree.js';
16
+
17
+ const execFileAsync = promisify(execFile);
14
18
 
15
19
  export const WORKER_HOST_HOME = process.env.CODEM_WORKER_HOST_HOME || path.join(os.homedir(), '.codem');
16
20
  export const WORKER_RUNTIME_ROOT = path.join(WORKER_HOST_HOME, 'worker-runtimes');
@@ -271,11 +275,58 @@ export function launchWorkerRuntime(runtime, { args = process.argv.slice(2), env
271
275
  CODEM_SKIP_UPDATE_CHECK: '1', CODEM_DISABLE_DAEMON_AUTO_UPDATE: '1' } }));
272
276
  const stopObservation=observeWorkerRuntime(runtime,{env,args});
273
277
  const signals = ['SIGTERM', 'SIGINT', 'SIGHUP'];
274
- const handlers = signals.map(signal => () => { if (child.exitCode === null && child.signalCode === null) { try { child.kill(signal); } catch {} } });
278
+ let shutdownTimer = null, descendants = null, shutdownCleanup = null;
279
+ const cleanup = () => {clearTimeout(shutdownTimer);stopObservation();signals.forEach((signal, index) => process.off(signal, handlers[index]));};
280
+ // Both the deadline and an early daemon exit own the same bounded tree cleanup.
281
+ // In particular, exit must not discard descendants orphaned by graceful shutdown.
282
+ const finishShutdown = () => {
283
+ if (shutdownCleanup === null) shutdownCleanup = (async () => {
284
+ clearTimeout(shutdownTimer);
285
+ let killTimer;
286
+ const controller = new AbortController();
287
+ const execFileImpl = (file, args, options) => execFileAsync(file, args, { ...options, signal: controller.signal });
288
+ try {
289
+ await Promise.race([
290
+ (async () => {
291
+ const current = child.exitCode === null && child.signalCode === null
292
+ ? await snapshotDescendantPids(child.pid, { timeoutMs: 1000, execFileImpl }) : [];
293
+ await terminateProcessTree({ pid: child.pid,
294
+ descendants: [...new Set([...current, ...await descendants])], timeoutMs: 1000, execFileImpl });
295
+ })(),
296
+ new Promise(resolve => { killTimer = setTimeout(resolve, 2000); })
297
+ ]);
298
+ } finally {
299
+ controller.abort();
300
+ clearTimeout(killTimer);
301
+ if (child.exitCode === null && child.signalCode === null) { try { child.kill('SIGKILL'); } catch {} }
302
+ cleanup();
303
+ }
304
+ })();
305
+ return shutdownCleanup;
306
+ };
307
+ const handlers = signals.map(signal => () => {
308
+ if (child.exitCode !== null || child.signalCode !== null || shutdownTimer !== null) return;
309
+ // Snapshot before forwarding: graceful shutdown may orphan descendants.
310
+ descendants = snapshotDescendantPids(child.pid, { timeoutMs: 1000 });
311
+ const exitCode = 128 + (os.constants.signals[signal] || 1);
312
+ // Sandbox shutdown owns up to 15s plus finalization. Leave that budget intact;
313
+ // repeated signals cannot renew this independent launcher backstop.
314
+ shutdownTimer = setTimeout(async () => {
315
+ await finishShutdown();
316
+ process.exit(exitCode);
317
+ }, 20_000);
318
+ void descendants.then(() => {
319
+ if (child.exitCode === null && child.signalCode === null) { try { child.kill(signal); } catch {} }
320
+ });
321
+ });
275
322
  signals.forEach((signal, index) => process.on(signal, handlers[index]));
276
- const cleanup = () => {stopObservation();signals.forEach((signal, index) => process.off(signal, handlers[index]));};
277
323
  child.once('error', error => { cleanup(); reject(error); });
278
- child.once('exit', (code, signal) => { cleanup(); resolve(code ?? (signal ? 128 + (os.constants.signals[signal] || 1) : 1)); });
324
+ child.once('exit', async (code, signal) => {
325
+ const exitCode = code ?? (signal ? 128 + (os.constants.signals[signal] || 1) : 1);
326
+ if (shutdownTimer !== null) await finishShutdown();
327
+ else cleanup();
328
+ resolve(exitCode);
329
+ });
279
330
  });
280
331
  }
281
332
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lark-codem/codem-cli",
3
- "version": "0.1.270",
3
+ "version": "0.1.271-early-build-dev.100103738101.0.g5243898",
4
4
  "codemServiceManagementProtocol": 3,
5
5
  "codemWorkerRuntimeProtocol": 1,
6
6
  "codemProfileProtocol": 1,
@@ -30,17 +30,17 @@
30
30
  "access": "public"
31
31
  },
32
32
  "dependencies": {
33
- "@lark-codem/codem-core": "0.8.87",
33
+ "@lark-codem/codem-core": "0.8.88-early-build-dev.100103738101.0.gc717eeb",
34
34
  "zod": "3.25.76",
35
35
  "@lark-codem/lark-cli": "0.1.1"
36
36
  },
37
37
  "optionalDependencies": {
38
- "@lark-codem/codem-cli-darwin-arm64": "0.1.270",
39
- "@lark-codem/codem-cli-darwin-x64": "0.1.270",
40
- "@lark-codem/codem-cli-linux-arm64": "0.1.270",
41
- "@lark-codem/codem-cli-linux-x64": "0.1.270",
42
- "@lark-codem/codem-cli-win32-arm64": "0.1.270",
43
- "@lark-codem/codem-cli-win32-x64": "0.1.270"
38
+ "@lark-codem/codem-cli-darwin-arm64": "0.1.271-early-build-dev.100103738101.0.g5243898",
39
+ "@lark-codem/codem-cli-darwin-x64": "0.1.271-early-build-dev.100103738101.0.g5243898",
40
+ "@lark-codem/codem-cli-linux-arm64": "0.1.271-early-build-dev.100103738101.0.g5243898",
41
+ "@lark-codem/codem-cli-linux-x64": "0.1.271-early-build-dev.100103738101.0.g5243898",
42
+ "@lark-codem/codem-cli-win32-arm64": "0.1.271-early-build-dev.100103738101.0.g5243898",
43
+ "@lark-codem/codem-cli-win32-x64": "0.1.271-early-build-dev.100103738101.0.g5243898"
44
44
  },
45
45
  "engines": {
46
46
  "node": ">=18"