mslxdff 0.1.180 → 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 +1 -1
- package/src/cli/commands/daemon.js +27 -6
- package/src/daemon.js +83 -0
- package/src/runtime/bootstrap.js +11 -0
- package/src/runtime/proc-info.js +137 -0
- package/src/server.js +10 -1
- package/src/warp-pool/health.js +50 -9
- package/src/warp-pool/index.js +26 -15
- package/src/warp-pool/ports.js +96 -58
- package/src/warp-pool/report.js +177 -0
- package/src/warp-pool/wireproxy.js +11 -6
package/package.json
CHANGED
|
@@ -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 } =
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
+
}
|
package/src/runtime/bootstrap.js
CHANGED
|
@@ -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
|
-
|
|
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"));
|
package/src/warp-pool/health.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// warp-pool · 节点健康:主动探活(经隧道打上游 models,不烧 token)+ 失败冷却 + 连续失败触发重拨换 IP。
|
|
2
2
|
import { probeTunnel } from "./wireproxy.js";
|
|
3
|
+
import { fmtHealth, fmtHeartbeat, dur, MARK } from "./report.js";
|
|
3
4
|
|
|
4
5
|
export function createHealthMonitor({
|
|
5
6
|
dispatcher,
|
|
@@ -7,41 +8,81 @@ export function createHealthMonitor({
|
|
|
7
8
|
probePath = "/zen/v1/models",
|
|
8
9
|
unhealthyAfter = 3,
|
|
9
10
|
intervalMs = 60_000,
|
|
11
|
+
heartbeatEvery = 10,
|
|
12
|
+
infoOf = null,
|
|
13
|
+
cooldownMs = 600_000,
|
|
10
14
|
onExhausted = null,
|
|
11
15
|
probe = probeTunnel,
|
|
12
16
|
headers = {},
|
|
17
|
+
now = Date.now,
|
|
13
18
|
log = () => {},
|
|
14
19
|
} = {}) {
|
|
15
20
|
let timer = null;
|
|
16
21
|
let rounds = 0;
|
|
22
|
+
let ticks = 0;
|
|
23
|
+
let lastAllDownAt = "";
|
|
24
|
+
/** @type {Map<number, boolean>} 每端口上一轮通不通(用来只在「状态变化」时打日志) */
|
|
25
|
+
const lastOk = new Map();
|
|
26
|
+
const stamp = () => new Date(now()).toISOString().slice(11, 19);
|
|
17
27
|
|
|
18
|
-
/**
|
|
28
|
+
/**
|
|
29
|
+
* 跑一轮全池探活。日志纪律:**只在「状态有变化」「全池不通」「心跳点」时打**——每分钟一行会把日志淹掉。
|
|
30
|
+
* 每个不通的节点都要报全:HTTP 状态、错误原文、耗时、连续第几次、冷却多久(这样才不用靠猜)。
|
|
31
|
+
*/
|
|
19
32
|
async function tick() {
|
|
20
33
|
if (!dispatcher) return { checked: 0, healthy: 0 };
|
|
21
34
|
const list = dispatcher.entries();
|
|
22
35
|
let healthy = 0;
|
|
36
|
+
let slowestMs = 0;
|
|
37
|
+
const problems = [];
|
|
38
|
+
const changes = [];
|
|
23
39
|
for (const e of list) {
|
|
24
40
|
const r = await probe({ port: e.port, baseUrl, path: probePath, headers }).catch((err) => ({
|
|
25
41
|
ok: false,
|
|
26
42
|
error: String(err?.message || err),
|
|
27
43
|
}));
|
|
44
|
+
if (Number(r.ms) > slowestMs) slowestMs = Number(r.ms);
|
|
28
45
|
if (r.ok) {
|
|
29
46
|
dispatcher.markOk(e.index);
|
|
30
47
|
healthy += 1;
|
|
31
|
-
|
|
32
|
-
|
|
48
|
+
if (lastOk.get(e.port) === false) changes.push(`:${e.port} 恢复(HTTP ${r.status},${dur(r.ms)})`);
|
|
49
|
+
lastOk.set(e.port, true);
|
|
50
|
+
continue;
|
|
33
51
|
}
|
|
52
|
+
dispatcher.markFailure(e.index);
|
|
53
|
+
if (lastOk.get(e.port) !== false) {
|
|
54
|
+
changes.push(`:${e.port} 掉线(HTTP ${r.status || 0}${r.error ? ` ${String(r.error).slice(0, 80)}` : ""})`);
|
|
55
|
+
}
|
|
56
|
+
lastOk.set(e.port, false);
|
|
57
|
+
problems.push({ port: e.port, status: r.status, error: r.error, ms: r.ms, fails: Number(e.fails || 0) + 1, cooldownMs });
|
|
34
58
|
}
|
|
35
|
-
|
|
59
|
+
const allDown = list.length > 0 && healthy === 0;
|
|
60
|
+
ticks += 1;
|
|
61
|
+
if (allDown) {
|
|
36
62
|
rounds += 1;
|
|
37
|
-
|
|
38
|
-
if (rounds >= 2 && typeof onExhausted === "function") {
|
|
39
|
-
rounds = 0;
|
|
40
|
-
await onExhausted().catch((e) => log(`[warp-pool] 重拨失败:${String(e?.message || e)}`));
|
|
41
|
-
}
|
|
63
|
+
lastAllDownAt = stamp();
|
|
42
64
|
} else {
|
|
43
65
|
rounds = 0;
|
|
44
66
|
}
|
|
67
|
+
if (changes.length || allDown || ticks % heartbeatEvery === 0) {
|
|
68
|
+
log(fmtHealth({ healthy, total: list.length, slowestMs, problems }));
|
|
69
|
+
}
|
|
70
|
+
for (const c of changes) log(`[warp-pool] ${MARK.warn} 状态变化:${c}`);
|
|
71
|
+
if (ticks % heartbeatEvery === 0) {
|
|
72
|
+
const extra = typeof infoOf === "function" ? infoOf() || {} : {};
|
|
73
|
+
log(fmtHeartbeat({
|
|
74
|
+
alive: extra.alive ?? list.length,
|
|
75
|
+
total: list.length,
|
|
76
|
+
pooled: extra.pooled ?? list.length,
|
|
77
|
+
exits: extra.exits || [],
|
|
78
|
+
cooling: list.filter((e) => Number(e.cooldownUntil) > now()).length,
|
|
79
|
+
lastAllDown: lastAllDownAt,
|
|
80
|
+
}));
|
|
81
|
+
}
|
|
82
|
+
if (allDown && rounds >= 2 && typeof onExhausted === "function") {
|
|
83
|
+
rounds = 0;
|
|
84
|
+
await onExhausted().catch((e) => log(`[warp-pool] 重拨失败:${String(e?.message || e)}`));
|
|
85
|
+
}
|
|
45
86
|
return { checked: list.length, healthy };
|
|
46
87
|
}
|
|
47
88
|
|
package/src/warp-pool/index.js
CHANGED
|
@@ -8,8 +8,10 @@ import { ensureDir, loadNodes, removeNode, saveNode } from "./nodes.js";
|
|
|
8
8
|
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
|
-
import { portInUse, reclaimPort, waitPortFree } from "./ports.js";
|
|
11
|
+
import { portInUse, portOwner, reclaimPort, waitPortFree } from "./ports.js";
|
|
12
|
+
import { fmtConf, fmtExit, fmtPool, fmtPoolEmpty, fmtProbe, fmtProbeGiveUp, fmtPortBlocked, fmtSiblings, fmtSpawn, startupReport } from "./report.js";
|
|
12
13
|
import { wireRotation } from "./identity.js";
|
|
14
|
+
import { findDaemons } from "../runtime/proc-info.js";
|
|
13
15
|
|
|
14
16
|
/** 重拨节流:连续失败指数退避。网络不通的机器绝不能让重注册把 CF 刷到 429。 */
|
|
15
17
|
const REDIAL_MIN_GAP_MS = 5 * 60_000;
|
|
@@ -95,25 +97,26 @@ export function createWarpPool({
|
|
|
95
97
|
async function bringUp(node) {
|
|
96
98
|
if (tunnels.has(node.port)) return; // 已归我们管:绝不再清残留(reclaim 会把自己这条隧道当残留杀掉)
|
|
97
99
|
await reclaim({ dir: cfg.dir, port: node.port, log }); // 清上一代孤儿(daemon 被强杀时子进程不会自己退)
|
|
98
|
-
writeTunnelConf(cfg.dir, node);
|
|
100
|
+
const { conf, bytes } = writeTunnelConf(cfg.dir, node);
|
|
101
|
+
log(fmtConf({ port: node.port, conf, bytes }));
|
|
99
102
|
// SIGTERM 之后套接字要几百毫秒才释放,这期间连得上 ≠ 别人的进程占着——先等它真空出来再判
|
|
100
103
|
if (!(await waitFree(node.port, { inUse }))) {
|
|
101
|
-
|
|
104
|
+
const owner = await portOwner({ dir: cfg.dir, port: node.port }).catch(() => ({ ours: [], others: [] }));
|
|
105
|
+
throw new Error(fmtPortBlocked({ port: node.port, ...owner }));
|
|
102
106
|
}
|
|
103
107
|
const t = start({
|
|
104
108
|
binPath,
|
|
105
109
|
dir: cfg.dir,
|
|
106
110
|
port: node.port,
|
|
107
111
|
log,
|
|
108
|
-
onExit: ({ port, code, signal, tail }) => {
|
|
112
|
+
onExit: ({ port, code, signal, tail, pid, aliveMs }) => {
|
|
109
113
|
tunnels.delete(port);
|
|
110
|
-
|
|
111
|
-
log(`[warp-pool] :${port} 隧道意外退出(退出码 ${code}${signal ? `,信号 ${signal}` : ""}):${why}`);
|
|
114
|
+
log(fmtExit({ port, code, signal, pid, aliveMs, tail: tailReason(tail) }));
|
|
112
115
|
if (!closed) void reviver.onExit(port); // 收摊中就别复活了,否则退出过程里又 spawn 出一批孤儿
|
|
113
116
|
},
|
|
114
117
|
});
|
|
115
118
|
tunnels.set(node.port, t);
|
|
116
|
-
log(
|
|
119
|
+
log(fmtSpawn({ port: node.port, pid: t.pid, binPath, conf }));
|
|
117
120
|
}
|
|
118
121
|
|
|
119
122
|
// 429 = 上游按出口 IP 计的名额耗尽 ⇒ 换一台设备的身份换出口(换身份=换桶)。
|
|
@@ -140,17 +143,20 @@ export function createWarpPool({
|
|
|
140
143
|
await wait(PROBE_SETTLE_MS);
|
|
141
144
|
for (const n of list) {
|
|
142
145
|
if (!tunnels.has(n.port)) continue;
|
|
146
|
+
const url = `${String(baseUrl).replace(/\/+$/, "")}${cfg.probePath}`;
|
|
143
147
|
let r = { ok: false };
|
|
144
148
|
for (let i = 1; i <= PROBE_ATTEMPTS; i++) {
|
|
149
|
+
const t0 = now();
|
|
145
150
|
r = await probe({ port: n.port, baseUrl, path: cfg.probePath });
|
|
151
|
+
const ms = Number.isFinite(r.ms) ? r.ms : now() - t0;
|
|
152
|
+
log(fmtProbe({ port: n.port, attempt: i, attempts: PROBE_ATTEMPTS, url, ok: r.ok, status: r.status, error: r.error, ms }));
|
|
146
153
|
if (r.ok) break;
|
|
147
154
|
if (i < PROBE_ATTEMPTS) await wait(PROBE_SETTLE_MS);
|
|
148
155
|
}
|
|
149
156
|
if (r.ok) {
|
|
150
157
|
live.push(n);
|
|
151
|
-
log(`[warp-pool] :${n.port} 探通(HTTP ${r.status}),进池`);
|
|
152
158
|
} else {
|
|
153
|
-
log(
|
|
159
|
+
log(fmtProbeGiveUp({ port: n.port, attempts: PROBE_ATTEMPTS, url }));
|
|
154
160
|
}
|
|
155
161
|
}
|
|
156
162
|
return live;
|
|
@@ -171,7 +177,10 @@ export function createWarpPool({
|
|
|
171
177
|
|
|
172
178
|
// 1. 复用磁盘上已注册的节点
|
|
173
179
|
let nodes = loadNodes(cfg.dir, cfg.ports);
|
|
174
|
-
|
|
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 }));
|
|
175
184
|
|
|
176
185
|
// 2. 补足:逐个注册(拉长间隔,避免 CF 注册限流与同网段撞车)
|
|
177
186
|
const used = new Set(nodes.map((n) => n.port));
|
|
@@ -204,13 +213,13 @@ export function createWarpPool({
|
|
|
204
213
|
const live = await probeGate(nodes);
|
|
205
214
|
if (live.length) {
|
|
206
215
|
await dispatcher.setNodes(live.map((n) => ({ port: n.port, uri: `http://127.0.0.1:${n.port}` })));
|
|
207
|
-
|
|
216
|
+
// 出口取证:一次拿到每个端口的真实出口,既喂给轮换器做「换没换动」的比对,也直接写进池结论那一行
|
|
217
|
+
const exits = await labelExits({ ports: live.map((n) => n.port), log });
|
|
218
|
+
rotator.noteExits(exits);
|
|
219
|
+
log(fmtPool({ pooled: live.length, total: nodes.length, exits }));
|
|
208
220
|
} else {
|
|
209
|
-
log(
|
|
221
|
+
log(fmtPoolEmpty({ total: nodes.length, ports: cfg.ports }));
|
|
210
222
|
}
|
|
211
|
-
|
|
212
|
-
// 5. 出口取证:把每台设备的真实出口地址打进日志(免费版 WARP 的 IPv4 是共享 NAT,N 台设备很可能折叠成同一个出口)
|
|
213
|
-
if (live.length) rotator.noteExits(await labelExits({ ports: live.map((n) => n.port), log }));
|
|
214
223
|
}
|
|
215
224
|
|
|
216
225
|
const monitor = createHealthMonitor({
|
|
@@ -218,6 +227,8 @@ export function createWarpPool({
|
|
|
218
227
|
baseUrl,
|
|
219
228
|
probePath: cfg.probePath,
|
|
220
229
|
log,
|
|
230
|
+
infoOf: () => ({ alive: tunnels.size, pooled: dispatcher.size, exits: rotator ? rotator.stats() : [] }),
|
|
231
|
+
cooldownMs: cfg.cooldownMs,
|
|
221
232
|
onExhausted: async () => {
|
|
222
233
|
// 重拨 = 重新注册换出口。只重拨「进程已死」的隧道:全活却全挂 = 网络路径问题,重注册只会把 CF 刷到 429
|
|
223
234
|
const plan = redialPlan({ ports: cfg.ports, alivePorts: [...tunnels.keys()], lastRedialAt, redialGapMs, now });
|
package/src/warp-pool/ports.js
CHANGED
|
@@ -1,17 +1,18 @@
|
|
|
1
|
-
// warp-pool · 端口占用与「上一代进程残留」清理(2026-10-16
|
|
1
|
+
// warp-pool · 端口占用与「上一代进程残留」清理(2026-10-16 真机事故后新增;2026-10-17 七次修订)。
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
|
|
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";
|
|
12
|
+
import { fmtReclaim } from "./report.js";
|
|
10
13
|
|
|
11
|
-
|
|
12
|
-
export
|
|
13
|
-
return String(buf == null ? "" : buf).split("\0").filter((s) => s !== "");
|
|
14
|
-
}
|
|
14
|
+
// /proc 读取层已上移到 runtime/proc-info.js(daemon 侧也要用同一套判据);这里原样再导出,既有调用方路径不变。
|
|
15
|
+
export { parseCmdline, pidAlive };
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* 该进程是不是「正在跑我们这条 conf」的 wireproxy。
|
|
@@ -24,37 +25,13 @@ export function isOurWireproxy(argv, conf) {
|
|
|
24
25
|
return isBin && list.includes(conf);
|
|
25
26
|
}
|
|
26
27
|
|
|
27
|
-
function defaultPids() {
|
|
28
|
-
try {
|
|
29
|
-
return readdirSync("/proc").filter((f) => /^\d+$/.test(f)).map(Number);
|
|
30
|
-
} catch {
|
|
31
|
-
return [];
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function defaultCmdline(pid) {
|
|
36
|
-
try {
|
|
37
|
-
return parseCmdline(readFileSync(`/proc/${pid}/cmdline`));
|
|
38
|
-
} catch {
|
|
39
|
-
return [];
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/** 进程是否还在(信号 0 = 只做存在性检查,不真发信号)。 */
|
|
44
|
-
export function pidAlive(pid, kill = process.kill) {
|
|
45
|
-
try {
|
|
46
|
-
kill(pid, 0);
|
|
47
|
-
return true;
|
|
48
|
-
} catch {
|
|
49
|
-
return false;
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
28
|
/**
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
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}>}
|
|
58
35
|
*/
|
|
59
36
|
export async function reclaimPort({
|
|
60
37
|
dir,
|
|
@@ -63,31 +40,58 @@ export async function reclaimPort({
|
|
|
63
40
|
platform = process.platform,
|
|
64
41
|
pids = defaultPids,
|
|
65
42
|
cmdline = defaultCmdline,
|
|
43
|
+
stat = defaultStat,
|
|
66
44
|
kill = process.kill,
|
|
67
45
|
sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
68
46
|
waitRounds = 15,
|
|
69
47
|
} = {}) {
|
|
70
|
-
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
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, [], [], []);
|
|
74
58
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
59
|
+
const conf = nodePaths(dir, port).conf;
|
|
60
|
+
const all = pids();
|
|
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);
|
|
83
77
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
+
}
|
|
89
91
|
}
|
|
90
|
-
|
|
92
|
+
const r = shape(all.length, targets, zombies, foreign);
|
|
93
|
+
log(fmtReclaim({ port, ...r }));
|
|
94
|
+
return r;
|
|
91
95
|
}
|
|
92
96
|
|
|
93
97
|
/** 该端口是否已有人在监听(TCP 连得上 = 占用;连不上/被拒 = 空闲)。 */
|
|
@@ -120,3 +124,37 @@ export async function waitPortFree(port, { inUse = portInUse, sleep = (ms) => ne
|
|
|
120
124
|
}
|
|
121
125
|
return !(await inUse(port));
|
|
122
126
|
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
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}>}>}
|
|
133
|
+
*/
|
|
134
|
+
export async function portOwner({ dir, port, platform = process.platform, pids = defaultPids, cmdline = defaultCmdline, stat = defaultStat, kill = process.kill, inUse = portInUse } = {}) {
|
|
135
|
+
const busy = await inUse(port);
|
|
136
|
+
const out = { busy, ours: [], zombies: [], foreign: [], others: [] };
|
|
137
|
+
if (!busy || platform !== "linux") return out;
|
|
138
|
+
const conf = nodePaths(dir, port).conf;
|
|
139
|
+
for (const pid of pids()) {
|
|
140
|
+
const argv = cmdline(pid);
|
|
141
|
+
if (!argv.length) continue;
|
|
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
|
+
}
|
|
153
|
+
out.ours.push(pid);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
// 非 root 看不到别人的 listen 表,退一步:命令行里带着这个端口号的进程也值得报出来
|
|
157
|
+
if (argv.some((a) => String(a).includes(`:${port}`))) out.others.push({ pid, name: String(argv[0]).split("/").pop() });
|
|
158
|
+
}
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// warp-pool · 日志与自检报告(2026-10-16 四轮返工后新增;2026-10-17 七次修订补「同机其它 daemon」)。
|
|
2
|
+
//
|
|
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";
|
|
9
|
+
export const MARK = { step: "▸", ok: "✓", fail: "✗", warn: "⚠" };
|
|
10
|
+
|
|
11
|
+
const KB = (n) => (n >= 1024 * 1024 ? `${(n / 1024 / 1024).toFixed(1)}MB` : `${Math.round(n / 1024)}KB`);
|
|
12
|
+
|
|
13
|
+
/** 人类可读时长:412ms / 1.8s / 2m3s。 */
|
|
14
|
+
export function dur(ms) {
|
|
15
|
+
const n = Number(ms);
|
|
16
|
+
if (!Number.isFinite(n) || n < 0) return "?";
|
|
17
|
+
if (n < 1000) return `${Math.round(n)}ms`;
|
|
18
|
+
if (n < 60_000) return `${(n / 1000).toFixed(1)}s`;
|
|
19
|
+
return `${Math.floor(n / 60_000)}m${Math.round((n % 60_000) / 1000)}s`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** 配置快照:一行说清这次到底按什么参数在跑。 */
|
|
23
|
+
export function fmtConfig(cfg) {
|
|
24
|
+
return `[warp-pool] ${MARK.step} 启动自检:开关=${cfg.enabled ? "开" : "关"} 模式=${cfg.mode} 端口=${cfg.ports.join(",")} 策略=${cfg.strategy} 轮换=${cfg.rotate ? "开" : "关"} 冷却=${Math.round(cfg.cooldownMs / 1000)}s`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** 目录与二进制(带体积,顺手能看出二进制是不是被下坏了)。 */
|
|
28
|
+
export function fmtEnv({ dir, binPath, binSize }) {
|
|
29
|
+
const bin = binPath ? `${binPath}(${binSize ? KB(binSize) : "体积未知"})` : "未就绪";
|
|
30
|
+
return `[warp-pool] ${MARK.step} 目录=${dir} 二进制=${bin}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 身份文件盘点:哪个端口有 id+token+私钥,哪个缺。 */
|
|
34
|
+
export function fmtIdentities(nodes, ports) {
|
|
35
|
+
const have = new Set(nodes.map((n) => n.port));
|
|
36
|
+
const parts = ports.map((p) => `:${p}${have.has(p) ? " ✓" : " ✗缺"}`);
|
|
37
|
+
return `[warp-pool] ${MARK.step} 身份文件 ${nodes.length}/${ports.length}:${parts.join(" ")}(✓=有 id+token+私钥,✗缺=现场注册)`;
|
|
38
|
+
}
|
|
39
|
+
|
|
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 = [] }) {
|
|
47
|
+
if (!busy) return `[warp-pool] :${port} 空闲`;
|
|
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 无效,也不再占端口)`;
|
|
51
|
+
const who = others.length ? others.map((o) => `pid ${o.pid} ${o.name}`).join("、") : "未知进程";
|
|
52
|
+
return `[warp-pool] :${port} 被 ${who} 占:非本池进程 → 不碰它,这个端口本轮跳过(查明:ss -ltnp | grep :${port})`;
|
|
53
|
+
}
|
|
54
|
+
|
|
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}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 写出的 WireGuard 配置(体积能看出是不是缺字段:缺私钥那次就是 338B vs 373B)。 */
|
|
67
|
+
export function fmtConf({ port, conf, bytes }) {
|
|
68
|
+
return `[warp-pool] :${port} ${MARK.step} 写配置 ${conf}(${bytes}B,0600)`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 起隧道:pid + 完整命令行,事后可 `ps -p <pid>` 对账。 */
|
|
72
|
+
export function fmtSpawn({ port, pid, binPath, conf }) {
|
|
73
|
+
return `[warp-pool] :${port} ${MARK.step} 起隧道 pid=${pid ?? "?"}:${binPath} -c ${conf}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** 探活单次:URL、状态、耗时、错误原文——第 3 轮返工缺的就是「到底哪一步不通」。 */
|
|
77
|
+
export function fmtProbe({ port, attempt, attempts, url, ok, status, error, ms }) {
|
|
78
|
+
const head = `[warp-pool] :${port} ${ok ? MARK.ok : MARK.fail} 探活 ${attempt}/${attempts}:GET ${url} 经 127.0.0.1:${port}`;
|
|
79
|
+
const tail = ok
|
|
80
|
+
? `HTTP ${status}(${dur(ms)})→ 进池`
|
|
81
|
+
: `HTTP ${status || 0}(${dur(ms)})${error ? ` 错误=${String(error).slice(0, 160)}` : ""} → 稍后重试`;
|
|
82
|
+
return `${head} → ${tail}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** 探活彻底失败:说清「不进池」的后果与自检命令,而不是只丢一句「没探通」。 */
|
|
86
|
+
export function fmtProbeGiveUp({ port, attempts, url }) {
|
|
87
|
+
return `[warp-pool] :${port} ${MARK.fail} 探活 ${attempts} 次都不通 ⇒ 不进池(进程可能在跑但隧道转不动流量,进池会把请求挂到超时,比直连更糟)。自检:curl --proxy http://127.0.0.1:${port} ${url}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** 隧道退出:退出码/信号/存活时长/pid/是否外部终止/stderr 末行(含「谁杀的」这一层推断)。 */
|
|
91
|
+
export function fmtExit({ port, code, signal, pid, aliveMs, tail }) {
|
|
92
|
+
const how = signal ? `信号 ${signal}` : `退出码 ${code}`;
|
|
93
|
+
const killed = signal ? "本进程未调用 stop ⇒ 外部终止(别人杀了它 / 系统回收)" : "进程自行退出(多为配置被拒或端口被占)";
|
|
94
|
+
return `[warp-pool] :${port} ${MARK.fail} 隧道退出:${how}|存活 ${aliveMs == null ? "?" : dur(aliveMs)}|pid ${pid ?? "?"}|${killed}|末行="${tail || "没有输出"}"`;
|
|
95
|
+
}
|
|
96
|
+
|
|
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(",")}(已死等收尸,不占端口)` : "";
|
|
102
|
+
const othTxt = others.length ? others.map((o) => `pid ${o.pid} ${o.name}`).join("、") : "";
|
|
103
|
+
const who = [foreignTxt, oursTxt, zombieTxt, othTxt].filter(Boolean).join(";") || "未知进程(已等套接字释放仍连得上)";
|
|
104
|
+
return `[warp-pool] :${port} ${MARK.fail} 端口被 ${who} 占着,拒绝再起(硬起只会 bind 失败、无限重启刷屏)。查明:ss -ltnp | grep :${port}`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** 池子结论:入池几个、各端口出口是什么、几种不同地址——一句话判断「WARP 到底有没有在用」。 */
|
|
108
|
+
export function fmtPool({ pooled, total, exits }) {
|
|
109
|
+
const head = `[warp-pool] ${MARK.step} 出口池就绪:${pooled}/${total} 条入池`;
|
|
110
|
+
const ipOf = new Map();
|
|
111
|
+
for (const g of exits || []) for (const p of g.nodes || []) if (g.ip) ipOf.set(p, g.ip);
|
|
112
|
+
if (!ipOf.size) return `${head}|出口地址未知(CF trace 取证失败,不影响使用)`;
|
|
113
|
+
const table = [...ipOf.entries()].map(([p, ip]) => `:${p}=${ip}`).join(" ");
|
|
114
|
+
return `${head}|出口:${table}|不同地址 ${new Set(ipOf.values()).size} 个`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** 池空告警:最容易被误读成「功能在跑」,必须把结论与逐条自检命令一起说。 */
|
|
118
|
+
export function fmtPoolEmpty({ total, ports }) {
|
|
119
|
+
return `[warp-pool] ${MARK.warn} 没有一条隧道探通 ⇒ 池为空,全部请求照旧直连(不影响可用性,但 WARP 出口等于没启用)。逐条自检:for p in ${ports.join(" ")}; do echo -n ":$p "; curl -s --max-time 8 --proxy http://127.0.0.1:$p https://cloudflare.com/cdn-cgi/trace | grep '^ip='; done`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** 健康检查一轮:只在状态变化/心跳时打(每分钟一行会把日志淹掉)。 */
|
|
123
|
+
export function fmtHealth({ healthy, total, slowestMs, problems = [] }) {
|
|
124
|
+
if (!problems.length) return `[warp-pool] ${MARK.ok} 健康检查:${healthy}/${total} 通(最慢 ${dur(slowestMs)})`;
|
|
125
|
+
const detail = problems
|
|
126
|
+
.map((p) => `:${p.port} 第 ${p.fails} 次不通(HTTP ${p.status || 0}${p.error ? ` ${String(p.error).slice(0, 80)}` : ""},${dur(p.ms)})冷却 ${Math.round(p.cooldownMs / 1000)}s`)
|
|
127
|
+
.join(";");
|
|
128
|
+
return `[warp-pool] ${MARK.warn} 健康检查:${healthy}/${total} 通|不通:${detail}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** 心跳:一行给全部关键状态,省得「日志翻半天不知道现在啥情况」。 */
|
|
132
|
+
export function fmtHeartbeat({ alive, total, pooled, exits, cooling, lastAllDown }) {
|
|
133
|
+
const ips = [...new Set((exits || []).map((e) => e.ip).filter(Boolean))];
|
|
134
|
+
return `[warp-pool] ${MARK.step} 心跳:隧道 ${alive}/${total} 活|入池 ${pooled}|出口 ${ips.length ? `${ips.length} 种(${ips.join(",")})` : "未知"}|冷却中 ${cooling}|全池不通 ${lastAllDown || "未发生"}`;
|
|
135
|
+
}
|
|
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
|
+
|
|
144
|
+
/**
|
|
145
|
+
* 启动自检块:一次性把「这次按什么参数跑、二进制在哪多大、磁盘上有几个身份、端口分别被谁占着」打全。
|
|
146
|
+
* 第 1 轮返工只看到「退出 code=1 / 已复活」循环,就是缺这一块——真因(孤儿占端口)当时只能靠猜。
|
|
147
|
+
* @param {{ cfg: object, nodes: Array<object>, binPath?: string, binSize?: number,
|
|
148
|
+
* ownerOf: (port:number)=>Promise<{busy:boolean, ours:number[], zombies:number[], foreign:Array<object>, others:Array<{pid:number,name:string}>}>,
|
|
149
|
+
* log?: Function }} opts
|
|
150
|
+
*/
|
|
151
|
+
export async function startupReport({ cfg, nodes, binPath = "", binSize = 0, ownerOf, log = () => {} }) {
|
|
152
|
+
log(fmtConfig(cfg));
|
|
153
|
+
log(fmtEnv({ dir: cfg.dir, binPath, binSize }));
|
|
154
|
+
log(fmtIdentities(nodes, cfg.ports));
|
|
155
|
+
const states = [];
|
|
156
|
+
for (const port of cfg.ports) {
|
|
157
|
+
try {
|
|
158
|
+
states.push({ port, ...(await ownerOf(port)) });
|
|
159
|
+
} catch (e) {
|
|
160
|
+
states.push({ port, busy: false, ours: [], zombies: [], foreign: [], others: [], error: String(e?.message || e) });
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
const busy = states.filter((s) => s.busy);
|
|
164
|
+
log(`[warp-pool] ${MARK.step} 端口体检:${busy.length} 个被占 / ${states.length} 个端口`);
|
|
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
|
+
}
|
|
172
|
+
const orphans = busy.filter((s) => (s.ours || []).length).length;
|
|
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 无效,也不再占端口,无需处理)`);
|
|
176
|
+
return states;
|
|
177
|
+
}
|
|
@@ -93,16 +93,19 @@ function runCommand(cmd, args, { log = () => {} } = {}) {
|
|
|
93
93
|
export function writeTunnelConf(dir, node) {
|
|
94
94
|
if (!node?.privateKey) throw new Error(`节点 :${node?.port} 缺 WireGuard 私钥,拒绝写出一份跑不起来的配置`);
|
|
95
95
|
const { conf } = nodePaths(dir, node.port);
|
|
96
|
-
|
|
96
|
+
const text = renderWireproxyConf({ ...node, port: node.port });
|
|
97
|
+
writeSecret(conf, text);
|
|
98
|
+
return { conf, bytes: Buffer.byteLength(text) };
|
|
97
99
|
}
|
|
98
100
|
|
|
99
101
|
/**
|
|
100
102
|
* 启动一条隧道。返回 { port, conf, stop() }。
|
|
101
103
|
* 进程退出即视为隧道失效(不自动重启:由 health 层决策重拨换 IP)。
|
|
102
104
|
*/
|
|
103
|
-
export function startTunnel({ binPath: exe, dir, port, log = () => {}, onExit = () => {} } = {}) {
|
|
105
|
+
export function startTunnel({ binPath: exe, dir, port, log = () => {}, onExit = () => {}, now = Date.now } = {}) {
|
|
104
106
|
const { conf } = nodePaths(dir, port);
|
|
105
107
|
const child = spawn(exe, ["-c", conf], { stdio: ["ignore", "pipe", "pipe"] });
|
|
108
|
+
const startedAt = now();
|
|
106
109
|
let tail = "";
|
|
107
110
|
const keepTail = (d) => {
|
|
108
111
|
tail = (tail + String(d)).slice(-800);
|
|
@@ -112,7 +115,7 @@ export function startTunnel({ binPath: exe, dir, port, log = () => {}, onExit =
|
|
|
112
115
|
|
|
113
116
|
let stopped = false;
|
|
114
117
|
child.on("exit", (code, signal) => {
|
|
115
|
-
if (!stopped) onExit({ port, code, signal, tail });
|
|
118
|
+
if (!stopped) onExit({ port, code, signal, tail, pid: child.pid, aliveMs: now() - startedAt });
|
|
116
119
|
});
|
|
117
120
|
|
|
118
121
|
return {
|
|
@@ -139,18 +142,20 @@ export function startTunnel({ binPath: exe, dir, port, log = () => {}, onExit =
|
|
|
139
142
|
export async function probeTunnel({ port, baseUrl = "https://opencode.ai", path = "/zen/v1/models", fetchImpl = compatFetch, timeoutMs = 8000, headers = {}, wantText = false } = {}) {
|
|
140
143
|
const { ProxyAgent } = getUndici();
|
|
141
144
|
const agent = new ProxyAgent({ uri: `http://127.0.0.1:${port}`, connections: 2, keepAliveTimeout: 10_000 });
|
|
145
|
+
const url = `${String(baseUrl).replace(/\/+$/, "")}${path}`;
|
|
146
|
+
const t0 = Date.now();
|
|
142
147
|
try {
|
|
143
|
-
const res = await fetchImpl(
|
|
148
|
+
const res = await fetchImpl(url, {
|
|
144
149
|
headers,
|
|
145
150
|
signal: AbortSignal.timeout(timeoutMs),
|
|
146
151
|
dispatcher: agent,
|
|
147
152
|
});
|
|
148
|
-
const base = { ok: res.ok, status: res.status };
|
|
153
|
+
const base = { ok: res.ok, status: res.status, ms: Date.now() - t0, url };
|
|
149
154
|
if (!wantText) return base;
|
|
150
155
|
const text = await res.text().catch(() => "");
|
|
151
156
|
return { ...base, text: String(text).slice(0, 2000) };
|
|
152
157
|
} catch (e) {
|
|
153
|
-
return { ok: false, status: 0, error: String(e?.message || e) };
|
|
158
|
+
return { ok: false, status: 0, error: String(e?.message || e), ms: Date.now() - t0, url };
|
|
154
159
|
} finally {
|
|
155
160
|
try { await agent.close(); } catch {}
|
|
156
161
|
}
|