mslxdff 0.1.181 → 0.1.183

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mslxdff",
3
- "version": "0.1.181",
3
+ "version": "0.1.183",
4
4
  "description": "测试项目,请勿使用。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,14 +1,27 @@
1
1
  import { existsSync, writeFileSync } from "node:fs";
2
- import { startDaemon, stopDaemon, readPid, readPidVersion, isPidAlive, pidFile, logFile } from "../../daemon.js";
2
+ import { startDaemon, stopDaemon, readPid, readPidVersion, isPidAlive, pidFile, logFile, waitPidGone, reapGhostDaemons } from "../../daemon.js";
3
3
  import { setPort } from "../../state.js";
4
4
  import { logDir, eventsFile, callsFile, errorsFile } from "../../logs.js";
5
5
  import { effectivePort, waitForHealth, stopDaemonIfOutdated, compareSemver, argValue } from "../policy.js";
6
6
  import { printStatus } from "../status.js";
7
7
  import { compatFetch, timeoutSignal } from "../../compat.js";
8
+ import { findDaemons, fmtAlive } from "../../runtime/proc-info.js";
9
+
10
+ /** 停 daemon 并**等它真死**:只发 SIGTERM 就起新的会留下幽灵(隧道子进程把事件循环钉住,见 daemon.js waitPidGone)。 */
11
+ async function stopDaemonHard(reason) {
12
+ const pid = readPid();
13
+ const r = stopDaemon({ reason });
14
+ if (pid && r.stopped) {
15
+ const g = await waitPidGone(pid);
16
+ if (!g.died) console.log(`⚠ 旧 daemon (pid ${pid}) 等了 ${Math.round(g.waitedMs / 1000)}s 仍没退(已补 SIGKILL)—— 若仍在世,它会和新的抢端口`);
17
+ else if (g.forced) console.log(`旧 daemon (pid ${pid}) 未响应 SIGTERM,已 SIGKILL(等了 ${Math.round(g.waitedMs / 1000)}s)`);
18
+ }
19
+ return r;
20
+ }
8
21
 
9
22
  export async function handleStop(args) {
10
23
  if (!(args.includes("-stop") || args.includes("--stop"))) return false;
11
- const { stopped, pid, reason } = stopDaemon();
24
+ const { stopped, pid, reason } = await stopDaemonHard("stop");
12
25
  if (stopped) {
13
26
  console.log(`mslxdff daemon stopped (pid ${pid})`);
14
27
  } else {
@@ -23,14 +36,17 @@ export async function handleRestart(args, VERSION) {
23
36
  const alive = pid ? isPidAlive(pid) : false;
24
37
  if (alive) {
25
38
  console.log(`restarting daemon (pid ${pid})...`);
26
- stopDaemon({ reason: "restart" });
27
- await new Promise((r) => setTimeout(r, 300));
39
+ await stopDaemonHard("restart");
28
40
  } else if (pid) {
29
41
  console.log(`daemon pid ${pid} is stale (not running) — starting fresh...`);
30
42
  try { stopDaemon({ reason: "restart" }); } catch {}
31
43
  } else {
32
- console.log(`daemon not running — starting...`);
44
+ console.log("daemon not running — starting...");
33
45
  }
46
+ // 结构性盲区收口:不在 daemon.pid 里的 daemon 永远杀不到(真机:Oct16 起的旧 daemon 活了一整天,
47
+ // 与新版互杀 warp-pool 端口;两边日志格式交错才暴露)。起新的之前先清干净。
48
+ const ghosts = await reapGhostDaemons({ log: (m) => console.log(m) });
49
+ if (ghosts.length) console.log(`已清掉 ${ghosts.length} 个不在册的 daemon(pid ${ghosts.map((g) => g.pid).join(", ")})`);
34
50
  const port = effectivePort(args);
35
51
  const spawnedPid = startDaemon([]);
36
52
  await waitForHealth(port, 4000);
@@ -39,7 +55,12 @@ export async function handleRestart(args, VERSION) {
39
55
  const r = await compatFetch(`http://127.0.0.1:${port}/health`, { signal: timeoutSignal(1200) });
40
56
  ok = r.ok;
41
57
  } catch {}
42
- if (ok) console.log(`mslxdff v${VERSION} restarted as a background daemon (pid ${spawnedPid})`);
58
+ // 诚实报错:/health 可能由**残留的旧 daemon** 应答(它也在监听同一个端口),所以不能只看 health
59
+ const leftover = findDaemons({ self: spawnedPid });
60
+ const leftTxt = leftover.map((g) => `pid ${g.pid}(已运行 ${fmtAlive(g.aliveSec)})`).join(";");
61
+ if (leftover.length) {
62
+ console.log(`⚠ 仍有 ${leftover.length} 个旧 daemon 在跑:${leftTxt} —— 它们会和新的抢出口池端口,请 kill 掉`);
63
+ } else if (ok) console.log(`mslxdff v${VERSION} restarted as a background daemon (pid ${spawnedPid})`);
43
64
  else console.log(`mslxdff v${VERSION} restarted (pid ${spawnedPid}) — health check pending (http://127.0.0.1:${port}/health)`);
44
65
  console.log(`endpoint: http://localhost:${port}/v1`);
45
66
  console.log(`log: ${logFile()}`);
package/src/daemon.js CHANGED
@@ -4,6 +4,7 @@ import { join, dirname } from "node:path";
4
4
  import os from "node:os";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { writeExitMarker } from "./runtime/lifecycle-forensics.js";
7
+ import { aliveSec, findDaemons, fmtAlive } from "./runtime/proc-info.js";
7
8
 
8
9
  export function daemonDir() {
9
10
  return process.env.MSLXDFF_DAEMON_DIR || join(os.homedir(), ".config", "mslxdff");
@@ -110,3 +111,85 @@ export function stopDaemon({ reason = "stop" } = {}) {
110
111
  if (killFailed) return { stopped: false, pid, reason: "EPERM — process kill denied (try running terminal as Administrator, or use restart-daemon.bat)" };
111
112
  return { stopped: true, pid, stale: !victimAlive };
112
113
  }
114
+
115
+ /**
116
+ * 等目标进程真的死掉,超时补 SIGKILL。返回 { died, forced, waitedMs }。
117
+ * 为什么必须有:只发 SIGTERM 就 spawn 新 daemon,会留下「收到信号但退不出去」的幽灵
118
+ * (隧道子进程把事件循环钉住)——它不在 daemon.pid 里,从此 `-restart` 结构性碰不到它。
119
+ */
120
+ export async function waitPidGone(pid, { timeoutMs = 5000, killWaitMs = 2000, stepMs = 100, kill = process.kill, isAlive = isPidAlive, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = {}) {
121
+ const t0 = Date.now();
122
+ while (isAlive(pid) && Date.now() - t0 < timeoutMs) await sleep(stepMs);
123
+ if (!isAlive(pid)) return { died: true, forced: false, waitedMs: Date.now() - t0 };
124
+ try { kill(pid, "SIGKILL"); } catch {}
125
+ const t1 = Date.now();
126
+ while (isAlive(pid) && Date.now() - t1 < killWaitMs) await sleep(stepMs);
127
+ return { died: !isAlive(pid), forced: true, waitedMs: Date.now() - t0 };
128
+ }
129
+
130
+ /**
131
+ * 清「不在 daemon.pid 里的」幽灵 daemon —— `-restart` 的结构性盲区
132
+ *(真机事故:旧 daemon 活了一整天,与新版互杀 warp-pool 端口,两边日志格式交错才暴露)。
133
+ * 先 SIGTERM 全清 → 等 5s → 仍活着的 SIGKILL。返回**确实清掉**的清单(供调用方诚实汇报)。
134
+ */
135
+ export async function reapGhostDaemons({ self = process.pid, list = () => findDaemons({ self }), kill = process.kill, log = () => {}, isAlive = isPidAlive, settleMs = 5000, killWaitMs = 2000, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = {}) {
136
+ const ghosts = list();
137
+ if (!ghosts.length) return [];
138
+ for (const g of ghosts) log(`[daemon] 发现不在册的 daemon pid=${g.pid}(父 ${g.ppid},已运行 ${fmtAlive(g.aliveSec)})—— -restart 杀不到它,清掉`);
139
+ for (const g of ghosts) { try { kill(g.pid, "SIGTERM"); } catch {} }
140
+ const t0 = Date.now();
141
+ while (Date.now() - t0 < settleMs && ghosts.some((g) => isAlive(g.pid))) await sleep(100);
142
+ const stuck = ghosts.filter((g) => isAlive(g.pid));
143
+ for (const g of stuck) { try { kill(g.pid, "SIGKILL"); } catch {} }
144
+ const t1 = Date.now();
145
+ while (Date.now() - t1 < killWaitMs && stuck.some((g) => isAlive(g.pid))) await sleep(100);
146
+ return ghosts.filter((g) => !isAlive(g.pid));
147
+ }
148
+
149
+ /**
150
+ * 接管:内存里若还有**别的** mslxdff daemon,先把比我们旧的那批请出去。
151
+ * 为什么必须有(用户点名要求 + 真机 2026-10-17 事故):`-restart` 只认 `daemon.pid`,
152
+ * 不在册的 daemon(SIGTERM 后没真退出、pid 文件又被删掉的那个)会活一整天,与新版互抢
153
+ * warp-pool 的 4 个端口——日志里只能看到「起一条死一条」,看不出是两个 daemon 在互杀。
154
+ * 判据:比自己早启动 ≥ `minAgeMs` 的一律清(SIGTERM → 等 → SIGKILL);与本次启动几乎同时的不动
155
+ * (谁该活由 8989 的 bind 决定,`server-lifecycle` 的 EADDRINUSE 自愈会收口),避免两边互杀。
156
+ * 全程不抛(接管失败绝不能挡住启动)。
157
+ * @returns {Promise<{killed:number[], skipped:number[], remaining:number[]}>}
158
+ */
159
+ export async function takeoverOlderDaemons({
160
+ self = process.pid,
161
+ list = () => findDaemons({ self }),
162
+ kill = process.kill,
163
+ isAlive = isPidAlive,
164
+ log = () => {},
165
+ minAgeMs = 3000,
166
+ settleMs = 5000,
167
+ killWaitMs = 2000,
168
+ myAliveSec = undefined,
169
+ sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
170
+ } = {}) {
171
+ const sibs = list();
172
+ if (!sibs.length) return { killed: [], skipped: [], remaining: [] };
173
+ const mine = myAliveSec === undefined ? aliveSec(self) : myAliveSec;
174
+ // 取不到运行时长(非 Linux / 读不到 /proc)时保守处理:一律当成「旧的」,因为只允许一个 daemon 活着
175
+ const isOlder = (d) => mine == null || d.aliveSec == null || (d.aliveSec - mine) * 1000 >= minAgeMs;
176
+ const older = sibs.filter(isOlder);
177
+ const skipped = sibs.filter((d) => !isOlder(d));
178
+
179
+ for (const d of older) log(`[daemon] 内存里还有别的 mslxdff daemon:pid ${d.pid}(父 ${d.ppid},已运行 ${fmtAlive(d.aliveSec)},比本进程旧)—— 先请它退出,否则两个 daemon 会抢同一批端口(warp-pool 起一条死一条的真因)`);
180
+ for (const d of older) { try { kill(d.pid, "SIGTERM"); } catch {} }
181
+ const t0 = Date.now();
182
+ while (Date.now() - t0 < settleMs && older.some((d) => isAlive(d.pid))) await sleep(100);
183
+ const stuck = older.filter((d) => isAlive(d.pid));
184
+ for (const d of stuck) log(`[daemon] pid ${d.pid} 收到 SIGTERM 后 ${Math.round((Date.now() - t0) / 1000)}s 仍没退,补 SIGKILL`);
185
+ for (const d of stuck) { try { kill(d.pid, "SIGKILL"); } catch {} }
186
+ const t1 = Date.now();
187
+ while (Date.now() - t1 < killWaitMs && stuck.some((d) => isAlive(d.pid))) await sleep(100);
188
+
189
+ const remaining = older.filter((d) => isAlive(d.pid));
190
+ const killed = older.filter((d) => !isAlive(d.pid)).map((d) => d.pid);
191
+ for (const d of skipped) log(`[daemon] ⚠ 同机还有 pid ${d.pid}(与本次启动几乎同时,不是旧的)—— 不动它,由 8989 的 bind 决定谁活`);
192
+ if (remaining.length) log(`[daemon] ⚠ 请不走:pid ${remaining.map((d) => d.pid).join(",")}(SIGKILL 都没用,可能是僵尸或权限不足)—— 它仍会抢端口,请手动 kill -9`);
193
+ else if (killed.length) log(`[daemon] ✓ 已请走 ${killed.length} 个旧 daemon(pid ${killed.join(",")}),本进程独占`);
194
+ return { killed, skipped: skipped.map((d) => d.pid), remaining: remaining.map((d) => d.pid) };
195
+ }
@@ -14,6 +14,17 @@ const logs = { appendCall, appendError, appendEvent, appendTimeline, appendModel
14
14
  export async function startDaemonMain(VERSION) {
15
15
  const { installLifecycleLog } = await import("./lifecycle-log.js");
16
16
  installLifecycleLog({ version: VERSION, logs });
17
+ // 接管:内存里若还有旧的 mslxdff daemon,先请它退出。它不在 daemon.pid 里(-restart 结构性碰不到它),
18
+ // 两个 daemon 会互抢 warp-pool 的端口(真机 2026-10-17:旧 daemon 活了一整天,日志只显示「起一条死一条」)。
19
+ // 必须早于 setupProviders —— 那一步才建池抢端口。debug 前台有自己的收口逻辑,不在这里抢。
20
+ if (process.env.MSLXDFF_DAEMON === "1" && process.env.MSLXDFF_DEBUG !== "1") {
21
+ try {
22
+ const { takeoverOlderDaemons } = await import("../daemon.js");
23
+ await takeoverOlderDaemons({ log: (m) => console.log(m) });
24
+ } catch (e) {
25
+ console.log(`[daemon] 接管旧 daemon 失败(不阻断启动):${String(e?.message || e)}`);
26
+ }
27
+ }
17
28
  const ctx = await setupProviders();
18
29
  const { srv, bus } = await startServerLifecycle({ VERSION, ...ctx, logs });
19
30
  void srv;
@@ -0,0 +1,137 @@
1
+ // 进程取证层(Linux /proc 只读)——2026-10-17 组长 VPS「幽灵 daemon 互杀」事故后新增。
2
+ //
3
+ // 事故:两个 daemon 同时活着(一个从昨天起活了一整天),都管同一份 warp-pool 目录,
4
+ // 把对方的 wireproxy 当「上一代残留」杀掉 ⇒ 无限互杀、池永远不稳。三处都缺「读 /proc 说事实」的能力:
5
+ // ① 日志不知道「同机还有几个 daemon」(只能靠人肉 ps 才发现);
6
+ // ② 清理不知道「这个 wireproxy 的父进程还活着(= 另一个 daemon 的活隧道)」;
7
+ // ③ 「SIGKILL 都清不掉」其实是**僵尸**(state=Z,kill 对它无效,但 kill(pid,0) 仍返回成功)。
8
+ // 放 runtime 而不是 warp-pool:daemon 与 warp-pool 都要用,放 warp-pool 会让 daemon 反向依赖它。
9
+ // 非 Linux 一律返回空 —— 读不到 /proc 就别猜(不在别的平台做危险推断)。
10
+ import { readFileSync, readdirSync } from "node:fs";
11
+
12
+ /** /proc/<pid>/cmdline 是 NUL 分隔的字节串;切成参数数组(丢掉结尾空串)。 */
13
+ export function parseCmdline(buf) {
14
+ return String(buf == null ? "" : buf).split("\0").filter((s) => s !== "");
15
+ }
16
+
17
+ /**
18
+ * /proc/<pid>/stat → 我们关心的三格。
19
+ * 格式 `pid (comm) state ppid ... starttime`:comm 里可能有空格和括号 ⇒ 从**最后一个** `)` 之后切,
20
+ * 否则 comm 带括号的进程会让后面所有字段错位。
21
+ * @returns {{state:string, ppid:number, startTicks:number} | null}
22
+ */
23
+ export function parseStat(raw) {
24
+ const s = String(raw == null ? "" : raw);
25
+ const cut = s.lastIndexOf(")");
26
+ if (cut < 0) return null;
27
+ const f = s.slice(cut + 1).trim().split(/\s+/);
28
+ if (f.length < 20) return null;
29
+ return { state: String(f[0] || ""), ppid: Number(f[1]) || 0, startTicks: Number(f[19]) || 0 };
30
+ }
31
+
32
+ /** 僵尸:已死、等父进程收尸。kill 对它无效,但 kill(pid, 0) 仍返回成功 ⇒ 必须单独判。 */
33
+ export function isZombie(state) {
34
+ return String(state || "").toUpperCase() === "Z";
35
+ }
36
+
37
+ /** 进程是否还在(信号 0 = 只做存在性检查,不真发信号)。 */
38
+ export function pidAlive(pid, kill = process.kill) {
39
+ try {
40
+ kill(pid, 0);
41
+ return true;
42
+ } catch {
43
+ return false;
44
+ }
45
+ }
46
+
47
+ export function defaultPids() {
48
+ try {
49
+ return readdirSync("/proc").filter((f) => /^\d+$/.test(f)).map(Number);
50
+ } catch {
51
+ return [];
52
+ }
53
+ }
54
+
55
+ export function defaultCmdline(pid) {
56
+ try {
57
+ return parseCmdline(readFileSync(`/proc/${pid}/cmdline`));
58
+ } catch {
59
+ return [];
60
+ }
61
+ }
62
+
63
+ export function defaultStat(pid) {
64
+ try {
65
+ return parseStat(readFileSync(`/proc/${pid}/stat`, "utf8"));
66
+ } catch {
67
+ return null;
68
+ }
69
+ }
70
+
71
+ /** 本机已开机秒数(/proc/uptime 第一列);读不到返回 0。 */
72
+ export function uptimeSec() {
73
+ try {
74
+ const n = Number(String(readFileSync("/proc/uptime", "utf8")).trim().split(/\s+/)[0]);
75
+ return Number.isFinite(n) ? n : 0;
76
+ } catch {
77
+ return 0;
78
+ }
79
+ }
80
+
81
+ /** 进程已运行秒数 = 开机秒数 − starttime/USER_HZ(Linux USER_HZ 恒为 100)。取不到返回 null。 */
82
+ export function aliveSec(pid, { uptime = uptimeSec, stat = defaultStat } = {}) {
83
+ const st = stat(pid);
84
+ const up = uptime();
85
+ if (!st || !(up > 0) || !(st.startTicks > 0)) return null;
86
+ return Math.max(0, Math.round(up - st.startTicks / 100));
87
+ }
88
+
89
+ /** argv 看起来是我们的 daemon:带 `--daemon`,且有参数以 `mslxdff` / `mslxdff.js` 结尾。 */
90
+ export function isDaemonArgv(argv) {
91
+ const list = Array.isArray(argv) ? argv : [];
92
+ return list.includes("--daemon") && list.some((a) => /(^|[\\/])mslxdff(\.js)?$/i.test(String(a)));
93
+ }
94
+
95
+ /** 「已运行 6h12m」这种人话时长(取证日志用)。 */
96
+ export function fmtAlive(sec) {
97
+ if (!Number.isFinite(sec)) return "运行时长未知";
98
+ const s = Math.max(0, Math.round(sec));
99
+ if (s < 60) return `${s}s`;
100
+ if (s < 3600) return `${Math.floor(s / 60)}m`;
101
+ return `${Math.floor(s / 3600)}h${Math.floor((s % 3600) / 60)}m`;
102
+ }
103
+
104
+ /**
105
+ * 同机其它我们的 daemon(排除 self)。
106
+ * 「不在 daemon.pid 里的 daemon」是 `-restart` 的结构性盲区 —— 必须能列出来,才谈得上清理与告警。
107
+ * @returns {Array<{pid:number, ppid:number, state:string, aliveSec:number|null}>}
108
+ */
109
+ export function findDaemons({ self = 0, pids = defaultPids, cmdline = defaultCmdline, stat = defaultStat, uptime = uptimeSec } = {}) {
110
+ const out = [];
111
+ for (const pid of pids()) {
112
+ if (pid === self) continue;
113
+ if (!isDaemonArgv(cmdline(pid))) continue;
114
+ const st = stat(pid);
115
+ out.push({
116
+ pid,
117
+ ppid: st ? st.ppid : 0,
118
+ state: st ? st.state : "",
119
+ aliveSec: aliveSec(pid, { uptime, stat: () => st }),
120
+ });
121
+ }
122
+ return out;
123
+ }
124
+
125
+ /**
126
+ * 该进程的父进程是不是**另一个活着的 mslxdff daemon**?
127
+ * 是 ⇒ 这不是「上一代残留」,是**别人的活隧道**(清了就是互杀)。
128
+ * 自家子进程(ppid=self)、孤儿(ppid<=1 或父进程已死)、读不到 stat 都返回 null(照旧清)。
129
+ * @returns {{pid:number, aliveSec:number|null} | null}
130
+ */
131
+ export function otherDaemonParent(pid, { self = process.pid, stat = defaultStat, cmdline = defaultCmdline, kill = process.kill, uptime = uptimeSec } = {}) {
132
+ const st = stat(pid);
133
+ if (!st || st.ppid <= 1 || st.ppid === self) return null;
134
+ if (!pidAlive(st.ppid, kill)) return null;
135
+ if (!isDaemonArgv(cmdline(st.ppid))) return null;
136
+ return { pid: st.ppid, aliveSec: aliveSec(st.ppid, { uptime, stat }) };
137
+ }
package/src/server.js CHANGED
@@ -38,9 +38,18 @@ export function startServer({ router, signals = true, host, onBeforeClose }, por
38
38
  // 关停留痕(Linux 有效:可捕获 SIGTERM/SIGINT 并记录)。Windows 注意:process.kill 的
39
39
  // SIGTERM 实为 TerminateProcess 强杀,被杀的 JS 进程收不到该事件,此处不会触发——
40
40
  // Windows 的"谁杀了我"靠 src/daemon.js stopDaemon() 的杀者留痕 + 最后一条 heartbeat。
41
+ let stopping = false;
41
42
  const onSignal = (sig) => {
43
+ if (stopping) return;
44
+ stopping = true;
42
45
  try { console.log(`[lifecycle] ${sig} received — shutting down (pid ${process.pid}, uptime ${Math.round(process.uptime())}s)`); } catch {}
43
- close();
46
+ // 关停必须**真的退出**:warp-pool 的 wireproxy 子进程 stdio 是 pipe,活着的子进程会把事件循环钉住;
47
+ // 只 close() server(放开 8989)会留下「不监听端口却还活着」的幽灵 daemon
48
+ //(真机 2026-10-17:Oct16 起的幽灵与新版互杀 warp-pool 端口一整天,且它不在 daemon.pid 里,-restart 永远碰不到)。
49
+ // process.exit 会跑 exit 钩子(warp-pool 杀隧道、lifecycle 写遗书),故清理不会被跳过。
50
+ const hard = setTimeout(() => { try { console.log(`[lifecycle] ${sig} 优雅关停 8s 未完成 — 强制退出`); } catch {} process.exit(0); }, 8000);
51
+ hard.unref?.();
52
+ close().then(() => process.exit(0), (e) => { try { console.log(`[lifecycle] 关停抛错:${String(e?.message || e)}`); } catch {} process.exit(1); });
44
53
  };
45
54
  process.on("SIGINT", () => onSignal("SIGINT"));
46
55
  process.on("SIGTERM", () => onSignal("SIGTERM"));
@@ -9,8 +9,9 @@ import { registerWarpDevice } from "./register.js";
9
9
  import { ensureBinary, probeTunnel, resolveAsset, startTunnel, writeTunnelConf } from "./wireproxy.js";
10
10
  import { createReviver, labelExits, tailReason } from "./revive.js";
11
11
  import { portInUse, portOwner, reclaimPort, waitPortFree } from "./ports.js";
12
- import { fmtConf, fmtExit, fmtPool, fmtPoolEmpty, fmtProbe, fmtProbeGiveUp, fmtPortBlocked, fmtSpawn, startupReport } from "./report.js";
12
+ import { fmtConf, fmtExit, fmtPool, fmtPoolEmpty, fmtProbe, fmtProbeGiveUp, fmtPortBlocked, fmtSiblings, fmtSpawn, startupReport } from "./report.js";
13
13
  import { wireRotation } from "./identity.js";
14
+ import { findDaemons } from "../runtime/proc-info.js";
14
15
 
15
16
  /** 重拨节流:连续失败指数退避。网络不通的机器绝不能让重注册把 CF 刷到 429。 */
16
17
  const REDIAL_MIN_GAP_MS = 5 * 60_000;
@@ -177,6 +178,9 @@ export function createWarpPool({
177
178
  // 1. 复用磁盘上已注册的节点
178
179
  let nodes = loadNodes(cfg.dir, cfg.ports);
179
180
  await startupReport({ cfg, nodes, binPath, ownerOf: (port) => portOwner({ dir: cfg.dir, port }), log });
181
+ // 同机其它 daemon:两个 daemon 会互相把对方的活隧道当「残留」杀掉(真机事故根因),必须一开池就报出来
182
+ const siblings = findDaemons({ self: process.pid });
183
+ if (siblings.length) log(fmtSiblings({ list: siblings }));
180
184
 
181
185
  // 2. 补足:逐个注册(拉长间隔,避免 CF 注册限流与同网段撞车)
182
186
  const used = new Set(nodes.map((n) => n.port));
@@ -1,18 +1,18 @@
1
- // warp-pool · 端口占用与「上一代进程残留」清理(2026-10-16 组长 VPS 真机事故后新增)。
1
+ // warp-pool · 端口占用与「上一代进程残留」清理(2026-10-16 真机事故后新增;2026-10-17 七次修订)。
2
2
  //
3
- // 事故:daemon 被强杀(WMI TerminateProcess / taskkill /F)时 `process.once("exit")` 钩子不执行,
4
- // wireproxy 子进程变成孤儿,继续占着 127.0.0.1:10800+ 这些端口。新 daemon 再起隧道就 bind 失败
5
- // (wireproxy 立刻退出 code=1),而旧代码把「spawn 成功」当成「复活成功」⇒ 无限重启、日志刷屏、
6
- // 且该端口的隧道永远起不来。治本是「起隧道前先把自家残留清掉」,并「端口被别人占着就别硬起」。
7
- import { readFileSync, readdirSync } from "node:fs";
3
+ // 事故一(0.1.179):daemon 被强杀时 `process.once("exit")` 钩子不执行,wireproxy 子进程变孤儿继续占着端口。
4
+ // 事故二(七次修订,真机铁证):**两个 daemon 同时活着**(一个从昨天起活了一整天),两者都用同一份
5
+ // `node-<port>.conf` 判「本池残留」⇒ 互相把对方的**活隧道**当残留杀掉 ⇒ 无限互杀、池永远不稳。
6
+ // 两条教训写进判据:
7
+ // ① 父进程是**另一个活着的 daemon** ⇒ 那不是残留,是「别人的活隧道」,清了就是互杀 ⇒ 不许清,只报出来;
8
+ // ② 「SIGKILL 都清不掉」多半是**僵尸**(state=Z:kill 无效,但 kill(pid,0) 仍返回成功)⇒ 僵尸算已死。
8
9
  import { connect } from "node:net";
10
+ import { defaultCmdline, defaultPids, defaultStat, isZombie, otherDaemonParent, parseCmdline, pidAlive } from "../runtime/proc-info.js";
9
11
  import { nodePaths } from "./nodes.js";
10
12
  import { fmtReclaim } from "./report.js";
11
13
 
12
- /** /proc/<pid>/cmdline 是 NUL 分隔的字节串;切成参数数组(丢掉结尾空串)。 */
13
- export function parseCmdline(buf) {
14
- return String(buf == null ? "" : buf).split("\0").filter((s) => s !== "");
15
- }
14
+ // /proc 读取层已上移到 runtime/proc-info.js(daemon 侧也要用同一套判据);这里原样再导出,既有调用方路径不变。
15
+ export { parseCmdline, pidAlive };
16
16
 
17
17
  /**
18
18
  * 该进程是不是「正在跑我们这条 conf」的 wireproxy。
@@ -25,37 +25,13 @@ export function isOurWireproxy(argv, conf) {
25
25
  return isBin && list.includes(conf);
26
26
  }
27
27
 
28
- function defaultPids() {
29
- try {
30
- return readdirSync("/proc").filter((f) => /^\d+$/.test(f)).map(Number);
31
- } catch {
32
- return [];
33
- }
34
- }
35
-
36
- function defaultCmdline(pid) {
37
- try {
38
- return parseCmdline(readFileSync(`/proc/${pid}/cmdline`));
39
- } catch {
40
- return [];
41
- }
42
- }
43
-
44
- /** 进程是否还在(信号 0 = 只做存在性检查,不真发信号)。 */
45
- export function pidAlive(pid, kill = process.kill) {
46
- try {
47
- kill(pid, 0);
48
- return true;
49
- } catch {
50
- return false;
51
- }
52
- }
53
-
54
28
  /**
55
- * 清掉「占着该端口那条 conf」的上一代 wireproxy。返回被清理的 pid 列表。
56
- * 非 Linux 直接返回 [](读不到 /proc,别拿别的平台做危险猜测)。
57
- * @param {{ dir: string, port: number, log?: Function, platform?: string,
58
- * pids?: Function, cmdline?: Function, kill?: Function, sleep?: Function, waitRounds?: number }} opts
29
+ * 清掉占着该端口那条 conf 的**真残留**:自家丢引用的子进程、父进程已死的孤儿。
30
+ * 不动的三类:僵尸(kill 无效,也不是真占用者)、**父进程是另一个活 daemon 的活隧道**(清了就是互杀)、
31
+ * argv 不匹配的别人的进程。
32
+ * 非 Linux 直接返回空(读不到 /proc,别拿别的平台做危险猜测)。
33
+ * @returns {Promise<{scanned:number, killed:number[], zombies:number[],
34
+ * foreign:Array<{pid:number,ppid:number,aliveSec:number|null}>, waitedMs:number, gone:boolean}>}
59
35
  */
60
36
  export async function reclaimPort({
61
37
  dir,
@@ -64,33 +40,58 @@ export async function reclaimPort({
64
40
  platform = process.platform,
65
41
  pids = defaultPids,
66
42
  cmdline = defaultCmdline,
43
+ stat = defaultStat,
67
44
  kill = process.kill,
68
45
  sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
69
46
  waitRounds = 15,
70
47
  } = {}) {
71
- if (platform !== "linux") return [];
48
+ const startedAt = Date.now();
49
+ const shape = (scanned, killed, zombies, foreign) => ({
50
+ scanned,
51
+ killed,
52
+ zombies,
53
+ foreign,
54
+ waitedMs: Date.now() - startedAt,
55
+ gone: !killed.some((p) => pidAlive(p, kill)),
56
+ });
57
+ if (platform !== "linux") return shape(0, [], [], []);
58
+
72
59
  const conf = nodePaths(dir, port).conf;
73
60
  const all = pids();
74
- const targets = all.filter((pid) => isOurWireproxy(cmdline(pid), conf));
75
- const startedAt = Date.now();
76
- if (!targets.length) {
77
- log(fmtReclaim({ port, scanned: all.length, ours: [], others: [], waitedMs: 0, killed: false }));
78
- return [];
79
- }
80
- for (const pid of targets) {
81
- try {
82
- kill(pid, "SIGTERM");
83
- } catch {}
61
+ const zombies = [];
62
+ const foreign = [];
63
+ const targets = [];
64
+ for (const pid of all) {
65
+ if (!isOurWireproxy(cmdline(pid), conf)) continue;
66
+ const st = stat(pid);
67
+ if (st && isZombie(st.state)) {
68
+ zombies.push(pid);
69
+ continue;
70
+ }
71
+ const other = otherDaemonParent(pid, { stat, cmdline, kill });
72
+ if (other) {
73
+ foreign.push({ pid, ppid: other.pid, aliveSec: other.aliveSec });
74
+ continue;
75
+ }
76
+ targets.push(pid);
84
77
  }
85
- for (let i = 0; i < waitRounds && targets.some((p) => pidAlive(p, kill)); i++) await sleep(100);
86
- for (const pid of targets.filter((p) => pidAlive(p, kill))) {
87
- try {
88
- kill(pid, "SIGKILL");
89
- } catch {}
78
+
79
+ if (targets.length) {
80
+ for (const pid of targets) {
81
+ try {
82
+ kill(pid, "SIGTERM");
83
+ } catch {}
84
+ }
85
+ for (let i = 0; i < waitRounds && targets.some((p) => pidAlive(p, kill)); i++) await sleep(100);
86
+ for (const pid of targets.filter((p) => pidAlive(p, kill))) {
87
+ try {
88
+ kill(pid, "SIGKILL");
89
+ } catch {}
90
+ }
90
91
  }
91
- const gone = !targets.some((p) => pidAlive(p, kill));
92
- log(fmtReclaim({ port, scanned: all.length, ours: targets, waitedMs: Date.now() - startedAt, killed: gone }));
93
- return targets;
92
+ const r = shape(all.length, targets, zombies, foreign);
93
+ log(fmtReclaim({ port, ...r }));
94
+ return r;
94
95
  }
95
96
 
96
97
  /** 该端口是否已有人在监听(TCP 连得上 = 占用;连不上/被拒 = 空闲)。 */
@@ -125,19 +126,30 @@ export async function waitPortFree(port, { inUse = portInUse, sleep = (ms) => ne
125
126
  }
126
127
 
127
128
  /**
128
- * 端口占用者取证:谁在监听——本池 wireproxy 残留?别人的进程?还是空闲。
129
- * 「孤儿占端口」这类事故唯一能一眼看出结论的读数:只报「占用 / 拒绝再起」是查不出问题的(第 2 轮返工的教训)。
130
- * @returns {Promise<{busy:boolean, ours:number[], others:Array<{pid:number,name:string}>}>}
129
+ * 端口占用者取证:谁在监听——本池残留?另一个 daemon 的活隧道?僵尸?别人的进程?还是空闲。
130
+ * 「孤儿占端口」「两个 daemon 互杀」这两类事故唯一能一眼看出结论的读数:只报「占用 / 拒绝再起」是查不出问题的。
131
+ * @returns {Promise<{busy:boolean, ours:number[], zombies:number[],
132
+ * foreign:Array<{pid:number,ppid:number,aliveSec:number|null}>, others:Array<{pid:number,name:string}>}>}
131
133
  */
132
- export async function portOwner({ dir, port, platform = process.platform, pids = defaultPids, cmdline = defaultCmdline, inUse = portInUse } = {}) {
134
+ export async function portOwner({ dir, port, platform = process.platform, pids = defaultPids, cmdline = defaultCmdline, stat = defaultStat, kill = process.kill, inUse = portInUse } = {}) {
133
135
  const busy = await inUse(port);
134
- const out = { busy, ours: [], others: [] };
136
+ const out = { busy, ours: [], zombies: [], foreign: [], others: [] };
135
137
  if (!busy || platform !== "linux") return out;
136
138
  const conf = nodePaths(dir, port).conf;
137
139
  for (const pid of pids()) {
138
140
  const argv = cmdline(pid);
139
141
  if (!argv.length) continue;
140
142
  if (isOurWireproxy(argv, conf)) {
143
+ const st = stat(pid);
144
+ if (st && isZombie(st.state)) {
145
+ out.zombies.push(pid);
146
+ continue;
147
+ }
148
+ const other = otherDaemonParent(pid, { stat, cmdline, kill });
149
+ if (other) {
150
+ out.foreign.push({ pid, ppid: other.pid, aliveSec: other.aliveSec });
151
+ continue;
152
+ }
141
153
  out.ours.push(pid);
142
154
  continue;
143
155
  }
@@ -1,9 +1,11 @@
1
- // warp-pool · 日志与自检报告(2026-10-16 四轮返工后新增)。
1
+ // warp-pool · 日志与自检报告(2026-10-16 四轮返工后新增;2026-10-17 七次修订补「同机其它 daemon」)。
2
2
  //
3
- // 为什么单独一个模块(四轮返工的教训):日志若只说「失败了」、不说「谁干的、为什么」,就只能靠猜——
4
- // 第 1 轮猜孤儿占端口、第 2 轮分不清旧 daemon 子进程与刚被杀还没释放的套接字、第 3 轮分不清握手未完成与上游拒绝。
5
- // 故把可判定信息一次摆全:配置快照、端口占用者(pid+命令行+是否本池残留)、身份文件、每条隧道完整生命周期
6
- // (清理→写配置→spawn pid→探活耗时/状态/错误→退出码/信号/存活时长/末行)、池子结论与「下一步敲什么命令」。
3
+ // 为什么单独一个模块(五轮返工的教训):日志若只说「失败了」、不说「谁干的、为什么」,就只能靠猜——
4
+ // 第 1 轮猜孤儿占端口、第 2 轮分不清旧 daemon 子进程与刚被杀还没释放的套接字、第 3 轮分不清握手未完成与上游拒绝、
5
+ // 第 4~5 轮对着「退出 / 已复活」循环完全看不出真凶是**同机另一个 daemon**(两边都把对方的活隧道当残留杀)。
6
+ // 故把可判定信息一次摆全:配置快照、**同机其它 daemon**、端口占用者(pid+ppid+命令行+本池残留/别人的活隧道/僵尸)、
7
+ // 身份文件、每条隧道完整生命周期(清理→写配置→spawn pid→探活耗时/状态/错误→退出码/信号/存活时长/末行)、池子结论与「下一步敲什么命令」。
8
+ import { fmtAlive } from "../runtime/proc-info.js";
7
9
  export const MARK = { step: "▸", ok: "✓", fail: "✗", warn: "⚠" };
8
10
 
9
11
  const KB = (n) => (n >= 1024 * 1024 ? `${(n / 1024 / 1024).toFixed(1)}MB` : `${Math.round(n / 1024)}KB`);
@@ -35,21 +37,30 @@ export function fmtIdentities(nodes, ports) {
35
37
  return `[warp-pool] ${MARK.step} 身份文件 ${nodes.length}/${ports.length}:${parts.join(" ")}(✓=有 id+token+私钥,✗缺=现场注册)`;
36
38
  }
37
39
 
38
- /** 单端口占用者:区分「本池残留」「别人的进程」「空闲」——第 1 轮返工缺的就是这一行。 */
39
- export function fmtPortState({ port, busy, ours = [], others = [] }) {
40
+ /** 「另一个 daemon(pid 1558055,已运行 6h12m) 的活隧道 pid 33」——两个 daemon 互杀这类事故唯一能一眼看出结论的读数。 */
41
+ export function fmtForeign(foreign = []) {
42
+ return (foreign || []).map((f) => `另一个 daemon(pid ${f.ppid}${f.aliveSec != null ? `,已运行 ${fmtAlive(f.aliveSec)}` : ""}) 的活隧道 pid ${f.pid}`).join(";");
43
+ }
44
+
45
+ /** 单端口占用者:区分「本池残留」「另一个 daemon 的活隧道」「僵尸」「别人的进程」「空闲」——两轮返工缺的就是这一行。 */
46
+ export function fmtPortState({ port, busy, ours = [], zombies = [], foreign = [], others = [] }) {
40
47
  if (!busy) return `[warp-pool] :${port} 空闲`;
41
- if (ours.length) return `[warp-pool] :${port} 被 pid ${ours.join(",")} 占:本池 wireproxy 残留(上一代 daemon 被强杀留下的孤儿)→ 起隧道前清理`;
48
+ if (foreign.length) return `[warp-pool] :${port} 被 ${fmtForeign(foreign)} 占着:那是**别人的活隧道**,本进程不碰它(清了就是和另一个 daemon 互杀)`;
49
+ if (ours.length) return `[warp-pool] :${port} 被 pid ${ours.join(",")} 占:本池 wireproxy 残留(上一代 daemon 留下的孤儿)→ 起隧道前清理`;
50
+ if (zombies.length) return `[warp-pool] :${port} 有本池僵尸 pid ${zombies.join(",")}(已死等收尸:kill 无效,也不再占端口)`;
42
51
  const who = others.length ? others.map((o) => `pid ${o.pid} ${o.name}`).join("、") : "未知进程";
43
52
  return `[warp-pool] :${port} 被 ${who} 占:非本池进程 → 不碰它,这个端口本轮跳过(查明:ss -ltnp | grep :${port})`;
44
53
  }
45
54
 
46
- /** 清理残留的结果:扫了多少进程、命中谁、等了多久、清掉没——第 2 轮返工缺的就是「谁在占」。 */
47
- export function fmtReclaim({ port, scanned, ours = [], others = [], waitedMs, killed }) {
48
- if (!ours.length) {
49
- const extra = others.length ? "(端口另有别的进程占着,不是本池的,不动它)" : "";
50
- return `[warp-pool] :${port} ${MARK.step} 清理残留:扫 ${scanned} 个进程,无本池残留${extra}`;
51
- }
52
- return `[warp-pool] :${port} ${MARK.step} 清理残留:扫 ${scanned} 个进程,命中本池残留 pid ${ours.join(",")} → ${killed ? "已清掉" : "清不掉"}(等了 ${dur(waitedMs)})`;
55
+ /** 清理残留的结果:扫了多少进程、命中谁、等了多久、清掉没、以及「另一个 daemon 占着」这种不该动的情形。 */
56
+ export function fmtReclaim({ port, scanned, killed = [], zombies = [], foreign = [], waitedMs = 0, gone = true }) {
57
+ const parts = [`扫 ${scanned} 个进程`];
58
+ if (killed.length) parts.push(`命中本池残留 pid ${killed.join(",")} → ${gone ? "已清掉" : "清不掉(SIGTERM/SIGKILL 都试过)"}`);
59
+ else parts.push("无本池残留");
60
+ if (zombies.length) parts.push(`僵尸 pid ${zombies.join(",")}(已死等收尸,kill 无效)`);
61
+ if (foreign.length) parts.push(`${MARK.warn} ${fmtForeign(foreign)}——不碰它(清了就是和另一个 daemon 互杀)`);
62
+ const wait = killed.length ? `(等了 ${dur(waitedMs)})` : "";
63
+ return `[warp-pool] :${port} ${MARK.step} 清理残留:${parts.join(";")}${wait}`;
53
64
  }
54
65
 
55
66
  /** 写出的 WireGuard 配置(体积能看出是不是缺字段:缺私钥那次就是 338B vs 373B)。 */
@@ -83,11 +94,13 @@ export function fmtExit({ port, code, signal, pid, aliveMs, tail }) {
83
94
  return `[warp-pool] :${port} ${MARK.fail} 隧道退出:${how}|存活 ${aliveMs == null ? "?" : dur(aliveMs)}|pid ${pid ?? "?"}|${killed}|末行="${tail || "没有输出"}"`;
84
95
  }
85
96
 
86
- /** 端口最终被非本池进程占着、拒绝硬起时,把占用者与「套接字还没释放」两种情形分开说。 */
87
- export function fmtPortBlocked({ port, ours = [], others = [] }) {
88
- const oursTxt = ours.length ? `本池残留 pid ${ours.join(",")}(SIGTERM/SIGKILL 都没清掉:可能僵尸或权限不足)` : "";
97
+ /** 端口最终被占着、拒绝硬起时,把「另一个 daemon 的活隧道 / 本池残留 / 僵尸 / 别人的进程 / 套接字还没释放」分开说。 */
98
+ export function fmtPortBlocked({ port, ours = [], zombies = [], foreign = [], others = [] }) {
99
+ const foreignTxt = foreign.length ? `${fmtForeign(foreign)}(清了就是互杀,故本进程不碰)——先停掉那个 daemon:kill ${[...new Set(foreign.map((f) => f.ppid))].join(" ")}` : "";
100
+ const oursTxt = ours.length ? `本池残留 pid ${ours.join(",")}(SIGTERM/SIGKILL 都没清掉)` : "";
101
+ const zombieTxt = zombies.length ? `本池僵尸 pid ${zombies.join(",")}(已死等收尸,不占端口)` : "";
89
102
  const othTxt = others.length ? others.map((o) => `pid ${o.pid} ${o.name}`).join("、") : "";
90
- const who = [oursTxt, othTxt].filter(Boolean).join(";") || "未知进程(已等套接字释放仍连得上)";
103
+ const who = [foreignTxt, oursTxt, zombieTxt, othTxt].filter(Boolean).join(";") || "未知进程(已等套接字释放仍连得上)";
91
104
  return `[warp-pool] :${port} ${MARK.fail} 端口被 ${who} 占着,拒绝再起(硬起只会 bind 失败、无限重启刷屏)。查明:ss -ltnp | grep :${port}`;
92
105
  }
93
106
 
@@ -121,11 +134,18 @@ export function fmtHeartbeat({ alive, total, pooled, exits, cooling, lastAllDown
121
134
  return `[warp-pool] ${MARK.step} 心跳:隧道 ${alive}/${total} 活|入池 ${pooled}|出口 ${ips.length ? `${ips.length} 种(${ips.join(",")})` : "未知"}|冷却中 ${cooling}|全池不通 ${lastAllDown || "未发生"}`;
122
135
  }
123
136
 
137
+ /** 同机其它 daemon:两个 daemon 抢同一批端口会互杀(真机事故根因),必须一次说清并给出精确 kill 命令。 */
138
+ export function fmtSiblings({ list = [] } = {}) {
139
+ if (!list.length) return "";
140
+ const who = list.map((d) => `pid ${d.pid}(父 ${d.ppid}${d.aliveSec != null ? `,已运行 ${fmtAlive(d.aliveSec)}` : ""})`).join(";");
141
+ return `[warp-pool] ${MARK.warn} 同机还有 ${list.length} 个别的 mslxdff daemon 在跑:${who} —— 它也在管同一个出口池目录,两边会把对方的隧道当「残留」杀掉(本池每个 conf 只允许一个拥有者)。先停掉它:kill ${list.map((d) => d.pid).join(" ")}`;
142
+ }
143
+
124
144
  /**
125
145
  * 启动自检块:一次性把「这次按什么参数跑、二进制在哪多大、磁盘上有几个身份、端口分别被谁占着」打全。
126
146
  * 第 1 轮返工只看到「退出 code=1 / 已复活」循环,就是缺这一块——真因(孤儿占端口)当时只能靠猜。
127
147
  * @param {{ cfg: object, nodes: Array<object>, binPath?: string, binSize?: number,
128
- * ownerOf: (port:number)=>Promise<{busy:boolean, ours:number[], others:Array<{pid:number,name:string}>}>,
148
+ * ownerOf: (port:number)=>Promise<{busy:boolean, ours:number[], zombies:number[], foreign:Array<object>, others:Array<{pid:number,name:string}>}>,
129
149
  * log?: Function }} opts
130
150
  */
131
151
  export async function startupReport({ cfg, nodes, binPath = "", binSize = 0, ownerOf, log = () => {} }) {
@@ -137,13 +157,21 @@ export async function startupReport({ cfg, nodes, binPath = "", binSize = 0, own
137
157
  try {
138
158
  states.push({ port, ...(await ownerOf(port)) });
139
159
  } catch (e) {
140
- states.push({ port, busy: false, ours: [], others: [], error: String(e?.message || e) });
160
+ states.push({ port, busy: false, ours: [], zombies: [], foreign: [], others: [], error: String(e?.message || e) });
141
161
  }
142
162
  }
143
163
  const busy = states.filter((s) => s.busy);
144
164
  log(`[warp-pool] ${MARK.step} 端口体检:${busy.length} 个被占 / ${states.length} 个端口`);
145
165
  for (const s of states) log(fmtPortState(s));
166
+ // 另一个 daemon 占着端口 = 两个 daemon 互杀(真机事故根因):必须单独大声报,并给出精确 kill 命令
167
+ const foreign = busy.flatMap((s) => s.foreign || []);
168
+ if (foreign.length) {
169
+ const pids = [...new Set(foreign.map((f) => f.ppid))];
170
+ log(`[warp-pool] ${MARK.warn} ${foreign.length} 个端口被 ${fmtForeign(foreign)} 占着 —— 那不是残留,本进程不碰它(清了就是互杀)。若那是上一代的 daemon:kill ${pids.join(" ")}`);
171
+ }
146
172
  const orphans = busy.filter((s) => (s.ours || []).length).length;
147
173
  if (orphans) log(`[warp-pool] ${MARK.warn} 有 ${orphans} 个端口被本池残留占着(上一代 daemon 被强杀留下的孤儿)——起隧道前逐个清理,日志会写清 pid`);
174
+ const zombies = busy.filter((s) => (s.zombies || []).length).length;
175
+ if (zombies) log(`[warp-pool] ${MARK.step} 有 ${zombies} 个端口上还有本池僵尸进程(已死等收尸:kill 无效,也不再占端口,无需处理)`);
148
176
  return states;
149
177
  }