@zhushanwen/pi-base-tool-enhance 0.5.2 → 0.5.3

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,161 +0,0 @@
1
- // src/__tests__/kill-tree.test.ts —— 进程树 kill 真实进程验证(POSIX 进程组语义)
2
- // + pid 身份判据(getProcessStartTimeSec / pidStartMatchesRegistered——原 reaper.test.ts
3
- // 覆盖平移:收殓下沉 runtime 后 helper 移入本文件,kill 侧自防御仍消费)
4
- import { spawn, spawnSync } from "node:child_process";
5
-
6
- import { describe, expect, it, vi } from "vitest";
7
-
8
- vi.setConfig({ testTimeout: 20000 });
9
-
10
- import {
11
- getProcessStartTimeSec,
12
- isPidAlive,
13
- killProcessTree,
14
- pidStartMatchesRegistered,
15
- } from "../kill-tree.ts";
16
-
17
- function sleep(ms: number): Promise<void> {
18
- return new Promise((resolve) => setTimeout(resolve, ms));
19
- }
20
-
21
- /**
22
- * 轮询直到期望状态出现或超时:真实进程事件(退出 → libuv reap → pid 释放)在满载
23
- * 下延迟不可预估,固定 sleep 后单次断言是满载 flake 源——等状态而非猜时刻。
24
- */
25
- async function pollUntil(check: () => boolean, deadlineMs: number, what: string): Promise<void> {
26
- const deadline = Date.now() + deadlineMs;
27
- while (!check()) {
28
- if (Date.now() > deadline) {
29
- throw new Error(`timed out after ${deadlineMs}ms waiting for ${what}`);
30
- }
31
- await sleep(25);
32
- }
33
- }
34
-
35
- /** pgrep -P 组内枚举:列组长直接子 pid(仅本进程组内,非全机扫描)。 */
36
- function listChildPids(pid: number): number[] {
37
- const result = spawnSync("/usr/bin/pgrep", ["-P", String(pid)], { encoding: "utf8" });
38
- // exit 1 = 无匹配(空表正常态);解析失败按空表处理,由轮询重试兜住 spawn 竞态
39
- return (result.stdout ?? "")
40
- .split("\n")
41
- .map((line) => Number.parseInt(line.trim(), 10))
42
- .filter((n) => Number.isInteger(n) && n > 0);
43
- }
44
-
45
- /** 真实 detached spawn(与本包后台任务同款形态:自成进程组)。 */
46
- function spawnDetached(command: string) {
47
- const child = spawn("/bin/sh", ["-c", command], {
48
- detached: true,
49
- stdio: "ignore",
50
- });
51
- child.on("error", () => {});
52
- child.unref();
53
- return child;
54
- }
55
-
56
- describe("isPidAlive", () => {
57
- it("current process is alive", () => {
58
- expect(isPidAlive(process.pid)).toBe(true);
59
- });
60
-
61
- it("exited process is dead (pid freed after reap)", async () => {
62
- const child = spawnDetached("true");
63
- const pid = child.pid;
64
- if (pid === undefined) throw new Error("no pid");
65
- // 轮询等退出 + libuv reap(SIGCHLD → waitpid 后 pid 释放)
66
- await pollUntil(() => !isPidAlive(pid), 5000, "pid to be freed after reap");
67
- expect(isPidAlive(pid)).toBe(false);
68
- });
69
-
70
- it("invalid pid (0/negative) is treated as dead", () => {
71
- expect(isPidAlive(0)).toBe(false);
72
- expect(isPidAlive(-1)).toBe(false);
73
- });
74
- });
75
-
76
- describe("killProcessTree (process group semantics)", () => {
77
- it("kills the whole detached process group including grandchildren", async () => {
78
- // 组长 sh + 两个子 sleep:进程组 kill 必须全部覆盖
79
- const child = spawnDetached("sleep 30 & sleep 30 & wait");
80
- const pid = child.pid;
81
- if (pid === undefined) throw new Error("no pid");
82
- // 轮询等两个孙子 sleep 真正 fork 出来,并在 kill 之前用组内枚举(pgrep -P)
83
- // 记录孙子 pid 快照——kill 后只对这些记录过的 pid 断言,禁止全机 pgrep -f
84
- // 扫描(满载并行时其他包测试会真实 spawn 同名命令,跨包互踩)
85
- let grandchildPids: number[] = [];
86
- await pollUntil(
87
- () => {
88
- grandchildPids = listChildPids(pid);
89
- return isPidAlive(pid) && grandchildPids.length >= 2;
90
- },
91
- 5000,
92
- "grandchildren to spawn inside the group",
93
- );
94
- expect(isPidAlive(pid)).toBe(true);
95
-
96
- killProcessTree(pid);
97
- // 轮询组长死(等 libuv reap,deadline 5s)
98
- await pollUntil(() => !isPidAlive(pid), 5000, "group leader to die");
99
-
100
- // 记录过的孙子 pid 也不得残留。覆盖面说明:killProcessTree 是进程组 kill
101
- // (POSIX kill -- -<pgid>),孙子与组长同组、组长死后 reparent 不换组,
102
- // 故「组 kill + 组内 pid 记录」已覆盖「reparent 后孙子也能被杀」的原始意图;
103
- // pid 复用概率在 5s 轮询窗口内可忽略
104
- await pollUntil(
105
- () => grandchildPids.every((gpid) => !isPidAlive(gpid)),
106
- 5000,
107
- "recorded grandchildren to die",
108
- );
109
- });
110
-
111
- it("killing an already-dead pid is a silent no-op", async () => {
112
- const child = spawnDetached("true");
113
- const pid = child.pid;
114
- if (pid === undefined) throw new Error("no pid");
115
- // 轮询等死透再 kill:断言语义是「对已死 pid 发令为无害 no-op」
116
- await pollUntil(() => !isPidAlive(pid), 5000, "pid to die");
117
- expect(() => killProcessTree(pid)).not.toThrow();
118
- });
119
- });
120
-
121
- describe("getProcessStartTimeSec (platform probe sanity)", () => {
122
- it("returns a sane epoch-seconds value for a live process", () => {
123
- const sec = getProcessStartTimeSec(process.pid);
124
- expect(sec).toBeDefined();
125
- const nowSec = Math.floor(Date.now() / 1000);
126
- // 本进程必然启动于 [now - 10min, now + 5s](时钟容差)
127
- expect(sec!).toBeGreaterThan(nowSec - 600);
128
- expect(sec!).toBeLessThan(nowSec + 5);
129
- });
130
-
131
- it("returns undefined for a dead pid", async () => {
132
- const child = spawnDetached("sleep 0.1");
133
- const pid = child.pid;
134
- if (pid === undefined) throw new Error("no pid");
135
- // 轮询等退出 + libuv reap(满载下延迟不可预估)
136
- await pollUntil(() => getProcessStartTimeSec(pid) === undefined, 5000, "ps record to vanish");
137
- expect(getProcessStartTimeSec(pid)).toBeUndefined();
138
- });
139
- });
140
-
141
- describe("pidStartMatchesRegistered (pid identity predicate, §3.6)", () => {
142
- it("exact match on registered pidStartTime authorizes (precise path)", () => {
143
- expect(pidStartMatchesRegistered(1000, 1000, 1_700_000_000_000)).toBe(true);
144
- });
145
-
146
- it("registered mismatch (pid reuse suspicion) rejects", () => {
147
- expect(pidStartMatchesRegistered(2000, 1000, 1_700_000_000_000)).toBe(false);
148
- });
149
-
150
- it("missing registration degrades to startedAt-seconds bound: original process authorizes", () => {
151
- // 登记发生在 spawn 之后 → 原进程 start time 必然 ≤ floor(startedAt/1000)
152
- const startedAtMs = 1_700_000_123_456; // floor = 1_700_000_123
153
- expect(pidStartMatchesRegistered(1_700_000_123, undefined, startedAtMs)).toBe(true);
154
- expect(pidStartMatchesRegistered(1_700_000_122, undefined, startedAtMs)).toBe(true);
155
- });
156
-
157
- it("missing registration degrades: newer-than-registration start time rejects (reuse)", () => {
158
- const startedAtMs = 1_700_000_123_456;
159
- expect(pidStartMatchesRegistered(1_700_000_124, undefined, startedAtMs)).toBe(false);
160
- });
161
- });
package/src/kill-tree.ts DELETED
@@ -1,151 +0,0 @@
1
- /**
2
- * 自实现进程树 kill 与 pid 判活/身份判据(设计文档 §3.5 bash_kill)。
3
- *
4
- * 为什么不用 pi 的 killProcessTree:它未从主入口导出(pi package.json exports 仅
5
- * `.`/`./rpc-entry`/`./client`,定义在 dist/utils/shell.d.ts 但子路径不暴露),不可
6
- * import。分支语义与 pi 实装对齐:Windows `taskkill /F /T`、POSIX 杀进程组
7
- * `kill -- -<pgid>`(本包后台任务 detached spawn 自成进程组,pgid = pid);进程组
8
- * 杀不到时回退单 pid + `pgrep -P` 递归杀残留子进程。
9
- *
10
- * pid 身份判据(getProcessStartTimeSec / pidStartMatchesRegistered):原定义于
11
- * reaper.ts,孤儿收殓下沉 runtime 后随 reaper.ts 删除平移至此(u-bte-remove)
12
- * ——消费方是 bash_kill 前校验与后台 timeout 到点校验(进程内自防御,与收殓无关)。
13
- */
14
-
15
- import { spawnSync, type SpawnSyncReturns } from "node:child_process";
16
-
17
- import { getLogger } from "@zhushanwen/pi-extension-logger";
18
-
19
- const logger = getLogger("base-tool-enhance");
20
-
21
- /** ps 调用超时:卡死的 ps 不拖垮调用方(超时按取不到处理 → 保守跳过)。 */
22
- const PS_TIMEOUT_MS = 5000;
23
- /** 毫秒 → 秒(epoch 秒换算,spawn-background.ts 同名常量先例)。 */
24
- const MS_PER_SECOND = 1000;
25
-
26
- /**
27
- * pid 判活:kill(pid, 0) 不发信号只做权限校验。
28
- * ESRCH = 已死(含 libuv 自动 reap 后);EPERM = 进程存在但属其他用户,仍视为活。
29
- */
30
- export function isPidAlive(pid: number): boolean {
31
- if (!Number.isInteger(pid) || pid <= 0) return false;
32
- try {
33
- process.kill(pid, 0);
34
- return true;
35
- } catch (err) {
36
- return (err as NodeJS.ErrnoException).code === "EPERM";
37
- }
38
- }
39
-
40
- /**
41
- * 杀整棵进程树(同步;收殓路径在 process.on("exit") 里跑,必须同步)。
42
- * 幂等:目标已死时静默成功。
43
- */
44
- export function killProcessTree(pid: number): void {
45
- if (!Number.isInteger(pid) || pid <= 0) return;
46
- if (process.platform === "win32") {
47
- killProcessTreeWindows(pid);
48
- return;
49
- }
50
- // POSIX:detached spawn 自成进程组(pgid = pid),杀进程组一次性覆盖全部子孙
51
- try {
52
- process.kill(-pid, "SIGKILL");
53
- return;
54
- } catch (err) {
55
- logger.debug("process group kill missed, falling back to single pid + descendants", {
56
- detail: { pid, err: err instanceof Error ? err.message : String(err) },
57
- });
58
- }
59
- // 回退:组长已死(进程组不复存在)时单杀 pid + pgrep -P 递归清理残留子进程
60
- try {
61
- process.kill(pid, "SIGKILL");
62
- } catch (err) {
63
- // 目标已死:kill 幂等语义,仅留诊断
64
- logger.debug("single pid kill missed (already dead?)", {
65
- detail: { pid, err: err instanceof Error ? err.message : String(err) },
66
- });
67
- }
68
- killDescendantsRecursive(pid);
69
- }
70
-
71
- function killProcessTreeWindows(pid: number): void {
72
- try {
73
- const result = spawnSync("taskkill", ["/F", "/T", "/PID", String(pid)], {
74
- stdio: "ignore",
75
- windowsHide: true,
76
- });
77
- if (result.error) throw result.error;
78
- } catch (err) {
79
- logger.debug("taskkill failed", {
80
- detail: { pid, err: err instanceof Error ? err.message : String(err) },
81
- });
82
- }
83
- }
84
-
85
- /** pgrep -P 递归:先杀孙辈再杀子辈(防孙辈在父死后被 reparent 逃逸枚举)。 */
86
- function killDescendantsRecursive(pid: number): void {
87
- let stdout: string;
88
- try {
89
- const result = spawnSync("pgrep", ["-P", String(pid)], { encoding: "utf8" });
90
- if (result.error || result.status !== 0 || !result.stdout) return;
91
- stdout = result.stdout;
92
- } catch {
93
- return;
94
- }
95
- for (const line of stdout.split("\n")) {
96
- const childPid = Number.parseInt(line.trim(), 10);
97
- if (Number.isInteger(childPid) && childPid > 0) {
98
- killDescendantsRecursive(childPid);
99
- try {
100
- process.kill(childPid, "SIGKILL");
101
- } catch (err) {
102
- // 已死:kill 幂等语义,仅留诊断
103
- logger.debug("descendant kill missed (already dead?)", {
104
- detail: { pid: childPid, err: err instanceof Error ? err.message : String(err) },
105
- });
106
- }
107
- }
108
- }
109
- }
110
-
111
- /**
112
- * 取进程 start time(epoch 秒)。ps -o lstart= 跨 macOS/Linux(Linux /proc/
113
- * <pid>/stat 精度更高但 macOS 无 /proc,统一 ps 保跨平台一致);= 号去表头。
114
- * 返回 undefined:进程不存在 / ps 不可用 / 输出不可解析——调用方一律按
115
- * 「无法校验 → 保守跳过」处理。
116
- */
117
- export function getProcessStartTimeSec(pid: number): number | undefined {
118
- let result: SpawnSyncReturns<string>;
119
- try {
120
- result = spawnSync("ps", ["-o", "lstart=", "-p", String(pid)], {
121
- encoding: "utf8",
122
- timeout: PS_TIMEOUT_MS,
123
- });
124
- } catch {
125
- return undefined;
126
- }
127
- if (result.error || result.status !== 0 || !result.stdout) return undefined;
128
- // lstart 形如 "Mon Aug 25 14:23:45 2026"(本地时区),Date.parse 按本地时区解释
129
- const ms = Date.parse(result.stdout.trim());
130
- return Number.isNaN(ms) ? undefined : Math.floor(ms / MS_PER_SECOND);
131
- }
132
-
133
- /**
134
- * pid 身份判据(§3.6「宁不杀勿误杀」的唯一定义点;原 reaper.ts 单点定义,收殓
135
- * 下沉后由进程内自防御消费——bash_kill kill 前校验与 background timeout 到点
136
- * kill 校验共用)。true = 当前占用该 pid 的进程 start time 与登记值匹配,可安全
137
- * kill。
138
- * - 有登记 start time(spawn 时 ps 读取成功)→ 精确比较(同单位 epoch 秒)
139
- * - 缺登记 start time(M3 前登记的存量条目 / ps 不可用平台)→ startedAtMs 秒级
140
- * 降级:登记发生在 spawn 之后(进程先启动、条目后登记),原进程 start time
141
- * 必然 ≤ floor(startedAtMs/1000)
142
- */
143
- export function pidStartMatchesRegistered(
144
- actualStartSec: number,
145
- registeredStartSec: number | undefined,
146
- startedAtMs: number,
147
- ): boolean {
148
- return registeredStartSec !== undefined
149
- ? actualStartSec === registeredStartSec
150
- : actualStartSec <= Math.floor(startedAtMs / MS_PER_SECOND);
151
- }