mslxdff 0.1.183 → 0.1.184

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.183",
3
+ "version": "0.1.184",
4
4
  "description": "测试项目,请勿使用。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -4,6 +4,7 @@ import { startServerLifecycle } from "./server-lifecycle.js";
4
4
  import { appendModelTrace } from "../model-trace.js";
5
5
  import { startGroupSync } from "./group-sync.js";
6
6
  import { startBroadband } from "./broadband.js";
7
+ import { installLogStamp } from "./log-stamp.js";
7
8
 
8
9
  const logs = { appendCall, appendError, appendEvent, appendTimeline, appendModelTrace };
9
10
 
@@ -12,6 +13,8 @@ const logs = { appendCall, appendError, appendEvent, appendTimeline, appendModel
12
13
  * 重活下沉 providers-setup / server-lifecycle / group-sync / broadband(-stream)。
13
14
  */
14
15
  export async function startDaemonMain(VERSION) {
16
+ // 每行带 [时间 pid]:一份 daemon.log 被多进程/多版本写过时,这是唯一能回答「谁写的、哪一轮」的东西
17
+ if (process.env.MSLXDFF_DAEMON === "1") installLogStamp();
15
18
  const { installLifecycleLog } = await import("./lifecycle-log.js");
16
19
  installLifecycleLog({ version: VERSION, logs });
17
20
  // 接管:内存里若还有旧的 mslxdff daemon,先请它退出。它不在 daemon.pid 里(-restart 结构性碰不到它),
@@ -20,7 +23,10 @@ export async function startDaemonMain(VERSION) {
20
23
  if (process.env.MSLXDFF_DAEMON === "1" && process.env.MSLXDFF_DEBUG !== "1") {
21
24
  try {
22
25
  const { takeoverOlderDaemons } = await import("../daemon.js");
23
- await takeoverOlderDaemons({ log: (m) => console.log(m) });
26
+ const r = await takeoverOlderDaemons({ log: (m) => console.log(m) });
27
+ if (!r.killed.length && !r.skipped.length && !r.remaining.length) {
28
+ console.log("[daemon] 内存里没有别的 mslxdff daemon(扫过一遍 /proc)—— 本进程独占出口池端口");
29
+ }
24
30
  } catch (e) {
25
31
  console.log(`[daemon] 接管旧 daemon 失败(不阻断启动):${String(e?.message || e)}`);
26
32
  }
@@ -0,0 +1,73 @@
1
+ // daemon.log 行归因前缀(时间 + pid):一个日志文件被多个进程写时,没有它就无法回答
2
+ // 「这行是谁写的、是这一轮还是上一轮」。2026-10-17 组长 VPS 事故就卡在这里——同一份 daemon.log 里
3
+ // 混着两个 daemon、两个版本的行,而旧版行与新版本行只差措辞(`已复活` vs `已复活(第 N 次尝试成功)`),
4
+ // 肉眼分不出来,于是「日志什么都没看出来」。时间解决「哪一轮」,pid 解决「谁写的」。
5
+ // 只在 daemon 模式装:前台/CLI 的输出是给人看的,不额外加噪。
6
+ const MARK = Symbol.for("mslxdff.logStamp");
7
+
8
+ /** 行前缀:`[HH:MM:SS pid] `(本地时间,秒级足够定位一轮启动)。 */
9
+ export function stampPrefix({ now = Date.now, pid = process.pid } = {}) {
10
+ const d = new Date(now());
11
+ const p2 = (n) => String(n).padStart(2, "0");
12
+ return `[${p2(d.getHours())}:${p2(d.getMinutes())}:${p2(d.getSeconds())} ${pid}] `;
13
+ }
14
+
15
+ /**
16
+ * 给整段文本的每一行加前缀(纯函数,行首状态由调用方跨次传递)。
17
+ * 只在行首加:半行写入(`write("半句")` 之后 `write("收尾\n")`)不会被重复打标。
18
+ * @returns {{ text: string, atLineStart: boolean }}
19
+ */
20
+ export function stampText(text, { prefix = stampPrefix(), atLineStart = true } = {}) {
21
+ const src = String(text ?? "");
22
+ if (!src) return { text: "", atLineStart };
23
+ let out = "";
24
+ let atStart = atLineStart;
25
+ let idx = 0;
26
+ while (idx < src.length) {
27
+ const nl = src.indexOf("\n", idx);
28
+ if (nl === -1) {
29
+ out += (atStart ? prefix : "") + src.slice(idx);
30
+ atStart = false;
31
+ break;
32
+ }
33
+ out += (atStart ? prefix : "") + src.slice(idx, nl + 1);
34
+ atStart = true;
35
+ idx = nl + 1;
36
+ }
37
+ return { text: out, atLineStart: atStart };
38
+ }
39
+
40
+ /**
41
+ * 给流装行前缀(幂等:重复调用不会套两层)。绝不抛——日志格式化不许挡住启动。
42
+ * @param {{ streams?: object[], now?: Function, pid?: number }} [opts]
43
+ * @returns {object[]} 实际装上的流
44
+ */
45
+ export function installLogStamp({ streams = [process.stdout, process.stderr], now = Date.now, pid = process.pid } = {}) {
46
+ const done = [];
47
+ for (const s of streams) {
48
+ if (!s || typeof s.write !== "function" || s[MARK]) continue;
49
+ const orig = s.write.bind(s);
50
+ let atLineStart = true;
51
+ s.write = (chunk, enc, cb) => {
52
+ let encoding = enc;
53
+ let callback = cb;
54
+ if (typeof enc === "function") {
55
+ callback = enc;
56
+ encoding = undefined;
57
+ }
58
+ const text = typeof chunk === "string"
59
+ ? chunk
60
+ : (chunk && typeof chunk.toString === "function" ? chunk.toString(encoding || "utf8") : String(chunk ?? ""));
61
+ const r = stampText(text, { prefix: stampPrefix({ now, pid }), atLineStart });
62
+ atLineStart = r.atLineStart;
63
+ return orig(r.text, encoding, callback);
64
+ };
65
+ try {
66
+ Object.defineProperty(s, MARK, { value: true, enumerable: false });
67
+ } catch {
68
+ s[MARK] = true;
69
+ }
70
+ done.push(s);
71
+ }
72
+ return done;
73
+ }
@@ -1,6 +1,7 @@
1
1
  // warp-pool · 装配入口。
2
2
  //
3
3
  // 定位(MODULES.md §0/§10):WARP 池是**增量**出口,不是替代成员自身出口;缺省**开**,MSLXDFF_WARP_POOL=0 关。
4
+ import { statSync } from "node:fs";
4
5
  import { resolveWarpConfig } from "./config.js";
5
6
  import { createRotatingDispatcher } from "./rotate.js";
6
7
  import { createHealthMonitor } from "./health.js";
@@ -9,7 +10,7 @@ import { registerWarpDevice } from "./register.js";
9
10
  import { ensureBinary, probeTunnel, resolveAsset, startTunnel, writeTunnelConf } from "./wireproxy.js";
10
11
  import { createReviver, labelExits, tailReason } from "./revive.js";
11
12
  import { portInUse, portOwner, reclaimPort, waitPortFree } from "./ports.js";
12
- import { fmtConf, fmtExit, fmtPool, fmtPoolEmpty, fmtProbe, fmtProbeGiveUp, fmtPortBlocked, fmtSiblings, fmtSpawn, startupReport } from "./report.js";
13
+ import { fmtConf, fmtExit, fmtIdentityDetail, fmtPool, fmtPoolEmpty, fmtProbe, fmtProbeGiveUp, fmtPortBlocked, fmtSiblings, fmtSpawn, startupReport } from "./report.js";
13
14
  import { wireRotation } from "./identity.js";
14
15
  import { findDaemons } from "../runtime/proc-info.js";
15
16
 
@@ -177,7 +178,11 @@ export function createWarpPool({
177
178
 
178
179
  // 1. 复用磁盘上已注册的节点
179
180
  let nodes = loadNodes(cfg.dir, cfg.ports);
180
- await startupReport({ cfg, nodes, binPath, ownerOf: (port) => portOwner({ dir: cfg.dir, port }), log });
181
+ let binSize = 0;
182
+ try { binSize = statSync(binPath).size; } catch {}
183
+ await startupReport({ cfg, nodes, binPath, binSize, ownerOf: (port) => portOwner({ dir: cfg.dir, port }), log });
184
+ // 身份指纹:4 条隧道只拿到 2 个出口时,只有这组读数能区分「设备写重复」与「CF 共享 NAT 折叠」
185
+ for (const line of fmtIdentityDetail(nodes)) log(line);
181
186
  // 同机其它 daemon:两个 daemon 会互相把对方的活隧道当「残留」杀掉(真机事故根因),必须一开池就报出来
182
187
  const siblings = findDaemons({ self: process.pid });
183
188
  if (siblings.length) log(fmtSiblings({ list: siblings }));
@@ -214,7 +219,7 @@ export function createWarpPool({
214
219
  if (live.length) {
215
220
  await dispatcher.setNodes(live.map((n) => ({ port: n.port, uri: `http://127.0.0.1:${n.port}` })));
216
221
  // 出口取证:一次拿到每个端口的真实出口,既喂给轮换器做「换没换动」的比对,也直接写进池结论那一行
217
- const exits = await labelExits({ ports: live.map((n) => n.port), log });
222
+ const exits = await labelExits({ ports: live.map((n) => n.port), dir: cfg.dir, log });
218
223
  rotator.noteExits(exits);
219
224
  log(fmtPool({ pooled: live.length, total: nodes.length, exits }));
220
225
  } else {
@@ -114,6 +114,49 @@ export function fmtPool({ pooled, total, exits }) {
114
114
  return `${head}|出口:${table}|不同地址 ${new Set(ipOf.values()).size} 个`;
115
115
  }
116
116
 
117
+ /**
118
+ * 身份指纹:每个端口的短设备 id / WARP 内网地址 / 对端端点 / 注册时间。
119
+ * 为什么必须有:4 条隧道只拿到 2 个出口地址时(真机 2026-10-17),只有这组读数能区分
120
+ * 「设备本身被写重复了」和「CF 的共享 NAT 把不同设备折叠成一个出口」——没有它就只能猜。
121
+ */
122
+ export function fmtIdentityDetail(nodes = []) {
123
+ const out = [];
124
+ const byId = new Map();
125
+ const byV4 = new Map();
126
+ for (const n of nodes || []) {
127
+ if (n?.id) byId.set(n.id, [...(byId.get(n.id) || []), n.port]);
128
+ if (n?.v4) byV4.set(n.v4, [...(byV4.get(n.v4) || []), n.port]);
129
+ out.push(`[warp-pool] :${n?.port} 设备=${n?.id ? String(n.id).slice(0, 8) : "无id"} 内网=${n?.v4 || "无"} 端点=${n?.endpoint || "?"} 注册=${String(n?.registeredAt || "?").slice(0, 19)}`);
130
+ }
131
+ // cut 只给设备 id 用(UUID 太长):内网地址必须原样打,截短了就成了「172.16.0」
132
+ const dups = (m, cut = 0) => [...m.entries()].filter(([, p]) => p.length > 1).map(([k, p]) => `${cut ? String(k).slice(0, cut) : String(k)} ← :${p.join(",:")}`).join(";");
133
+ const dupId = dups(byId, 8);
134
+ const dupV4 = dups(byV4);
135
+ if (dupId) out.push(`[warp-pool] ${MARK.warn} 设备 id 重复:${dupId} —— 同一个 WARP 设备被写到多个端口,出口必然相同;删掉重复端口的 node-<port>.json 再重启,它会重新注册`);
136
+ if (dupV4) out.push(`[warp-pool] ${MARK.warn} WARP 内网地址重复:${dupV4} —— 同一内网地址必是同一出口`);
137
+ return out;
138
+ }
139
+
140
+ /**
141
+ * 出口分散结论:几个出口地址 / 几条隧道在线、撞车的是谁、以及「想多要一份该怎么办」。
142
+ * 措辞必须自洽:免费 WARP 的 IPv4 是共享 NAT,**隧道数 ≠ 上游名额数**——旧措辞
143
+ * 「同时在线就有 N 份名额」与紧邻的「出口不分散」分支自相矛盾(真机 4 条隧道只拿到 2 个出口)。
144
+ */
145
+ export function fmtExits({ groups = [], dir = "" } = {}) {
146
+ if (!groups.length) return "";
147
+ const online = groups.reduce((a, g) => a + g.nodes.length, 0);
148
+ if (groups.length === 1) {
149
+ return `[warp-pool] ${MARK.warn} 出口不分散:${online} 条隧道同时在线只有 1 个出口地址(${groups[0].ip}):免费版 WARP 的 IPv4 是共享 NAT,多开隧道换不到多份名额;多份名额靠撞 429 时的身份轮换`;
150
+ }
151
+ const collide = groups.filter((g) => g.nodes.length > 1);
152
+ const drop = collide.flatMap((g) => g.nodes.slice(1));
153
+ const base = dir || "$HOME/.config/mslxdff/warp-pool";
154
+ const next = drop.length
155
+ ? `|${MARK.warn} 撞车:${collide.map((g) => `${g.ip} ← :${g.nodes.join(",:")}`).join(";")}(它们共用同一份上游名额)⇒ 想多要一份:rm -f ${drop.map((p) => `${base}/node-${p}.json`).join(" ")} && mslxdff -restart(重新注册换出口;共享 NAT 下常换不动,要试几次)`
156
+ : "";
157
+ return `[warp-pool] ${MARK.step} 出口分散:${groups.length} 个出口地址 / ${online} 条隧道在线(上游按出口 IP 计名额 ⇒ 最多 ${groups.length} 份;隧道数 ≠ 名额数:免费版 WARP 的 IPv4 是共享 NAT)${next}`;
158
+ }
159
+
117
160
  /** 池空告警:最容易被误读成「功能在跑」,必须把结论与逐条自检命令一起说。 */
118
161
  export function fmtPoolEmpty({ total, ports }) {
119
162
  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`;
@@ -3,13 +3,15 @@
3
3
  // 三件事,都来自组长 VPS 上暴露的缺陷:
4
4
  //
5
5
  // 1. 隧道退出后必须自愈:原先只有「全池不通」才补拉,一条死隧道会让池静默从 4 降到 3,直到 daemon 重启。
6
- // 2. 入池后必须实测各节点真实出口:WARP 的 IPv4 出口是共享 NAT,「N 设备」很可能折叠成一个出口。
6
+ // 2. 入池后必须实测各节点真实出口:WARP 的 IPv4 出口是共享 NAT,「N 设备」很可能折叠成一个出口
7
+ // (2026-10-17 组长 VPS 实测:4 条隧道只拿到 2 个出口地址 ⇒ 隧道数 ≠ 上游名额数)。
7
8
  // 3. 复活必须**真的活下来**(2026-10-16 组长 VPS 事故):`tunnels.has(port)` 只证明「spawn 被调用了」,
8
9
  // wireproxy 起一条死一条(端口被上一代孤儿进程占着 ⇒ bind 失败 ⇒ 退出码 1),旧逻辑次次判「已复活」
9
10
  // ⇒ 下一次退出立刻再复活 ⇒ 无限重启刷屏,该端口永远起不来。
10
11
  //
11
12
  // 复活只走**磁盘上已有的 conf**:进程挂了 ≠ 设备失效,重注册只会白烧 CF 配额。
12
13
  import { probeTunnel } from "./wireproxy.js";
14
+ import { fmtExits } from "./report.js";
13
15
 
14
16
  /** 出口取证目标:Cloudflare 自己的 trace 端点,回 `ip=` 一行(不烧上游 token)。 */
15
17
  export const EXIT_PROBE_URL = "https://cloudflare.com/cdn-cgi/trace";
@@ -136,9 +138,10 @@ export function createReviver({ revive, isAlive, minGapMs = 30_000, maxGapMs = 1
136
138
  /**
137
139
  * 入池后实测各节点真实出口地址并打日志(含撞车警告)。
138
140
  * 只读探测:打 Cloudflare 的 trace 端点,不碰上游配额。
141
+ * @param {{ ports?: number[], dir?: string, fetchImpl?: Function, timeoutMs?: number, log?: Function }} [opts]
139
142
  * @returns {Promise<{ ip: string, nodes: number[] }[]>} 按出口地址分组的节点清单
140
143
  */
141
- export async function labelExits({ ports = [], fetchImpl, timeoutMs = 12_000, log = () => {} } = {}) {
144
+ export async function labelExits({ ports = [], dir = "", fetchImpl, timeoutMs = 12_000, log = () => {} } = {}) {
142
145
  const byIp = new Map();
143
146
  let failed = 0;
144
147
  for (const port of ports) {
@@ -158,11 +161,8 @@ export async function labelExits({ ports = [], fetchImpl, timeoutMs = 12_000, lo
158
161
  }
159
162
 
160
163
  const groups = [...byIp.entries()].map(([ip, nodes]) => ({ ip, nodes }));
161
- if (groups.length === 1) {
162
- log(`[warp-pool] ⚠ 出口不分散:${groups[0].nodes.length} 条隧道同时在线只有 1 个出口地址(${groups[0].ip}):免费版 WARP 的 IPv4 是共享 NAT,多开隧道换不到多份名额;多份名额靠撞 429 时的身份轮换`);
163
- } else if (groups.length > 1) {
164
- log(`[warp-pool] 出口分散:${groups.length} 个出口地址 / ${groups.reduce((a, g) => a + g.nodes.length, 0)} 条隧道同时在线(同时在线就有 ${groups.length} 份上游名额)`);
165
- }
164
+ const line = fmtExits({ groups, dir });
165
+ if (line) log(line);
166
166
  if (failed) log(`[warp-pool] ${failed} 个节点出口取证失败(不影响进池,只影响轮换时「出口到底变没变」的比对)`);
167
167
  return groups;
168
168
  }