mslxdff 0.1.183 → 0.1.185
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/runtime/bootstrap.js +7 -1
- package/src/runtime/log-stamp.js +73 -0
- package/src/warp-pool/index.js +36 -5
- package/src/warp-pool/report.js +43 -0
- package/src/warp-pool/revive.js +7 -7
- package/src/warp-pool/rotate.js +55 -4
- package/src/warp-pool/verdict.js +69 -0
package/package.json
CHANGED
package/src/runtime/bootstrap.js
CHANGED
|
@@ -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
|
+
}
|
package/src/warp-pool/index.js
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
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";
|
|
7
|
+
import { classifyWarpSignal, SIGNAL_STATUSES } from "./verdict.js";
|
|
6
8
|
import { createHealthMonitor } from "./health.js";
|
|
7
9
|
import { ensureDir, loadNodes, removeNode, saveNode } from "./nodes.js";
|
|
8
10
|
import { registerWarpDevice } from "./register.js";
|
|
9
11
|
import { ensureBinary, probeTunnel, resolveAsset, startTunnel, writeTunnelConf } from "./wireproxy.js";
|
|
10
12
|
import { createReviver, labelExits, tailReason } from "./revive.js";
|
|
11
13
|
import { portInUse, portOwner, reclaimPort, waitPortFree } from "./ports.js";
|
|
12
|
-
import { fmtConf, fmtExit, fmtPool, fmtPoolEmpty, fmtProbe, fmtProbeGiveUp, fmtPortBlocked, fmtSiblings, fmtSpawn, startupReport } from "./report.js";
|
|
14
|
+
import { fmtConf, fmtExit, fmtIdentityDetail, fmtPool, fmtPoolEmpty, fmtProbe, fmtProbeGiveUp, fmtPortBlocked, fmtSiblings, fmtSpawn, startupReport } from "./report.js";
|
|
13
15
|
import { wireRotation } from "./identity.js";
|
|
14
16
|
import { findDaemons } from "../runtime/proc-info.js";
|
|
15
17
|
|
|
@@ -50,10 +52,35 @@ export function createWarpPool({
|
|
|
50
52
|
const cfg = resolveWarpConfig(env);
|
|
51
53
|
if (!cfg.active) return null;
|
|
52
54
|
|
|
53
|
-
/**
|
|
55
|
+
/** 身份轮换器:上游按出口 IP 计的限额(403 FreeTierError / 429 FreeUsage·GoUsage / 裸 429)时换一台 WARP 设备换出口。 */
|
|
54
56
|
let rotator = null;
|
|
55
57
|
|
|
56
|
-
const dispatcher = createRotatingDispatcher({
|
|
58
|
+
const dispatcher = createRotatingDispatcher({
|
|
59
|
+
nodes: [],
|
|
60
|
+
strategy: cfg.strategy,
|
|
61
|
+
cooldownMs: cfg.cooldownMs,
|
|
62
|
+
log,
|
|
63
|
+
// 判决照抄参照实现 zen-router 的决策表(见 verdict.js 头注):403 FreeTierError 与
|
|
64
|
+
// 429 FreeUsage/GoUsage 是同一条件、必须**强制**换出口(跳过冷却);BlackUsageLimitError
|
|
65
|
+
// 是账号日配额、绝不换;其余状态码不参与判决、也不许刷日志。
|
|
66
|
+
onStatus: (signal, port) => {
|
|
67
|
+
const status = Number(signal?.status);
|
|
68
|
+
if (!SIGNAL_STATUSES.includes(status)) return;
|
|
69
|
+
const v = classifyWarpSignal(signal);
|
|
70
|
+
if (!cfg.rotate) {
|
|
71
|
+
log(`[warp-pool] :${port} 上游 ${status}(${v.kind})—— 轮换开关关着(MSLXDFF_WARP_ROTATE=0),不换出口`);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (!v.rotate || !rotator) {
|
|
75
|
+
log(`[warp-pool] :${port} 上游 ${status} 判为「${v.kind}」⇒ 不换出口:${v.why}`);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
log(`[warp-pool] :${port} 上游 ${status} 判为「${v.kind}」⇒ 换身份换出口${v.forced ? "(强制,跳过冷却)" : ""}:${v.why}`);
|
|
79
|
+
void rotator.rotate(port, { force: v.forced })
|
|
80
|
+
.then((r) => { if (r && r.skipped) log(`[warp-pool] :${port} 换出口被冷却掐掉(距上次不足最小间隔)—— 这一发仍走原出口`); })
|
|
81
|
+
.catch(() => {});
|
|
82
|
+
},
|
|
83
|
+
});
|
|
57
84
|
/** @type {Map<number, {stop: () => void}>} */
|
|
58
85
|
const tunnels = new Map();
|
|
59
86
|
|
|
@@ -177,7 +204,11 @@ export function createWarpPool({
|
|
|
177
204
|
|
|
178
205
|
// 1. 复用磁盘上已注册的节点
|
|
179
206
|
let nodes = loadNodes(cfg.dir, cfg.ports);
|
|
180
|
-
|
|
207
|
+
let binSize = 0;
|
|
208
|
+
try { binSize = statSync(binPath).size; } catch {}
|
|
209
|
+
await startupReport({ cfg, nodes, binPath, binSize, ownerOf: (port) => portOwner({ dir: cfg.dir, port }), log });
|
|
210
|
+
// 身份指纹:4 条隧道只拿到 2 个出口时,只有这组读数能区分「设备写重复」与「CF 共享 NAT 折叠」
|
|
211
|
+
for (const line of fmtIdentityDetail(nodes)) log(line);
|
|
181
212
|
// 同机其它 daemon:两个 daemon 会互相把对方的活隧道当「残留」杀掉(真机事故根因),必须一开池就报出来
|
|
182
213
|
const siblings = findDaemons({ self: process.pid });
|
|
183
214
|
if (siblings.length) log(fmtSiblings({ list: siblings }));
|
|
@@ -214,7 +245,7 @@ export function createWarpPool({
|
|
|
214
245
|
if (live.length) {
|
|
215
246
|
await dispatcher.setNodes(live.map((n) => ({ port: n.port, uri: `http://127.0.0.1:${n.port}` })));
|
|
216
247
|
// 出口取证:一次拿到每个端口的真实出口,既喂给轮换器做「换没换动」的比对,也直接写进池结论那一行
|
|
217
|
-
const exits = await labelExits({ ports: live.map((n) => n.port), log });
|
|
248
|
+
const exits = await labelExits({ ports: live.map((n) => n.port), dir: cfg.dir, log });
|
|
218
249
|
rotator.noteExits(exits);
|
|
219
250
|
log(fmtPool({ pooled: live.length, total: nodes.length, exits }));
|
|
220
251
|
} else {
|
package/src/warp-pool/report.js
CHANGED
|
@@ -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`;
|
package/src/warp-pool/revive.js
CHANGED
|
@@ -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
|
-
|
|
162
|
-
|
|
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
|
}
|
package/src/warp-pool/rotate.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// bootstrap 完成后 setNodes() 换上真实出口。这样 daemon 启动不会因注册/下载而被阻塞。
|
|
8
8
|
import { getUndici } from "../compat.js";
|
|
9
9
|
import { createSelector } from "./strategy.js";
|
|
10
|
+
import { looksLikeQuotaSignal, SIGNAL_MAX_BYTES, SIGNAL_STATUSES } from "./verdict.js";
|
|
10
11
|
|
|
11
12
|
const SESSION_HEADER = "x-opencode-session";
|
|
12
13
|
|
|
@@ -20,14 +21,64 @@ export function headerValue(headers, name) {
|
|
|
20
21
|
return "";
|
|
21
22
|
}
|
|
22
23
|
|
|
23
|
-
/**
|
|
24
|
+
/**
|
|
25
|
+
* 让 dispatcher 看见上游的失败信号:复制一层 handler,其余方法原样转发(全部调用都落在副本上,this 一致)。
|
|
26
|
+
*
|
|
27
|
+
* 403/429 要**连正文一起**交出去才能判决(`403 FreeTierError` 与 `429 FreeUsageLimitError` 是同一条件,
|
|
28
|
+
* 而 `429 BlackUsageLimitError` 是账号配额、绝不能换出口)——参照实现 zen-router 就是按正文里的具名判决
|
|
29
|
+
* 分类的(internal/zen/errors.go)。故这两类状态码延迟到「正文到手」再回调:凑够具名判决即提前回调,
|
|
30
|
+
* 否则等 onComplete/onError/触顶。其余状态码照旧在 onHeaders 立即回调(正文为空)。
|
|
31
|
+
* 正文只**旁听**不消费:onData 原样转发给下游 handler,捕获 ≤ SIGNAL_MAX_BYTES 字节。
|
|
32
|
+
*/
|
|
24
33
|
export function tapHandler(handler, tap) {
|
|
25
34
|
if (!tap || !handler || typeof handler !== "object") return handler;
|
|
26
35
|
const out = Object.assign(Object.create(Object.getPrototypeOf(handler)), handler);
|
|
27
|
-
const
|
|
36
|
+
const origHeaders = handler.onHeaders;
|
|
37
|
+
const origData = handler.onData;
|
|
38
|
+
const origComplete = handler.onComplete;
|
|
39
|
+
const origError = handler.onError;
|
|
40
|
+
/** @type {null | { status: number, buf: Buffer[], size: number, done: boolean }} */
|
|
41
|
+
let capture = null;
|
|
42
|
+
const fire = () => {
|
|
43
|
+
if (!capture || capture.done) return;
|
|
44
|
+
capture.done = true;
|
|
45
|
+
try { tap({ status: capture.status, text: Buffer.concat(capture.buf).toString("utf8") }); } catch {}
|
|
46
|
+
};
|
|
47
|
+
|
|
28
48
|
out.onHeaders = function (statusCode, headers, resume, statusText) {
|
|
29
|
-
|
|
30
|
-
|
|
49
|
+
const code = Number(statusCode);
|
|
50
|
+
// 只有 403/429 值得等正文;且必须下游真能收数据,否则退回「只按状态码判决」。
|
|
51
|
+
if (SIGNAL_STATUSES.includes(code) && typeof origData === "function") {
|
|
52
|
+
capture = { status: code, buf: [], size: 0, done: false };
|
|
53
|
+
} else {
|
|
54
|
+
try { tap({ status: code, text: "" }); } catch {}
|
|
55
|
+
}
|
|
56
|
+
return typeof origHeaders === "function" ? origHeaders.call(this, statusCode, headers, resume, statusText) : undefined;
|
|
57
|
+
};
|
|
58
|
+
out.onData = function (chunk) {
|
|
59
|
+
if (capture && !capture.done) {
|
|
60
|
+
try {
|
|
61
|
+
const b = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
|
|
62
|
+
const room = SIGNAL_MAX_BYTES - capture.size;
|
|
63
|
+
if (room > 0) {
|
|
64
|
+
const slice = b.length > room ? b.subarray(0, room) : b;
|
|
65
|
+
capture.buf.push(slice);
|
|
66
|
+
capture.size += slice.length;
|
|
67
|
+
}
|
|
68
|
+
} catch {}
|
|
69
|
+
// 具名判决一到手就判决,不必等整条 body;触顶也判(大 body 不许把判决拖成慢扫描)
|
|
70
|
+
const text = capture.buf.length ? Buffer.concat(capture.buf).toString("utf8") : "";
|
|
71
|
+
if (capture.size >= SIGNAL_MAX_BYTES || looksLikeQuotaSignal(text)) fire();
|
|
72
|
+
}
|
|
73
|
+
return typeof origData === "function" ? origData.call(this, chunk) : undefined;
|
|
74
|
+
};
|
|
75
|
+
out.onComplete = function (trailers) {
|
|
76
|
+
fire();
|
|
77
|
+
return typeof origComplete === "function" ? origComplete.call(this, trailers) : undefined;
|
|
78
|
+
};
|
|
79
|
+
out.onError = function (err) {
|
|
80
|
+
fire();
|
|
81
|
+
return typeof origError === "function" ? origError.call(this, err) : undefined;
|
|
31
82
|
};
|
|
32
83
|
return out;
|
|
33
84
|
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// warp-pool · 上游失败信号的「换不换出口」判决(纯函数,零 IO)。
|
|
2
|
+
//
|
|
3
|
+
// 判据逐字对齐参照实现 kyomufs/zen-router:
|
|
4
|
+
// - internal/zen/errors.go 的 Kind 分类表(哪些错误算「按出口 IP 计的限额」);
|
|
5
|
+
// - internal/router/stage.go 的 NextAttempt 决策表(每种 Kind 该换什么、要不要绕过冷却)。
|
|
6
|
+
//
|
|
7
|
+
// 立此模块的原因(2026-10-17 十次修订):我原先只在 rotate.js 的 onStatus 里认 `status === 429`,
|
|
8
|
+
// 漏掉了参照实现表里**最重要的一格**——`403 FreeTierError` 与 `429 FreeUsageLimitError` 是
|
|
9
|
+
// **同一个条件的两种状态码**(上游按自己的窗口状态二选一,文案完全相同)。参照实现实测:
|
|
10
|
+
// 同一身份连发 3 次 429,**身份一换同一个请求立刻 200**(errors.go:32-42);
|
|
11
|
+
// 而它最初把 403 当通用 403 不重试,代价是整轮请求直接死掉(errors.go:50-61 记录得很清楚)。
|
|
12
|
+
// 我这边更糟:`terminal-verdict.js` 把 FreeTierError 判成**终局**(跳过接力),
|
|
13
|
+
// warp-pool 又只认 429 ⇒ **403 FreeTierError 从来没有任何换出口动作**。
|
|
14
|
+
//
|
|
15
|
+
// 反向护栏同样照抄:`BlackUsageLimitError` 是真正的**账号日配额**,换出口抬不动,
|
|
16
|
+
// 参照实现明确「绝不旋转身份」(stage.go:125-128、errors.go:28-31);换 key 才是它的正解。
|
|
17
|
+
// 裸 403(正文没有 FreeTier 判决)也不旋转:参照实现的 kindByStatus 把 403 归为
|
|
18
|
+
// KindClient(不可重试),换出口对形状/区域/凭据类 403 毫无用处。
|
|
19
|
+
//
|
|
20
|
+
// 读不到正文时的退路也照抄 kindByStatus:429 → 当出口 IP 限流(换);403 → 不换。
|
|
21
|
+
|
|
22
|
+
/** 上游失败正文里判定免费层资格的具名判决(与 terminal-verdict.js 的 FREE_TIER_PATTERNS 同源口径)。 */
|
|
23
|
+
const FREE_TIER_RE = /FreeTierError|free tier can only be used/i;
|
|
24
|
+
/** 匿名免费层 429(参照实现实测:按出口 IP 计,换身份即换桶)。 */
|
|
25
|
+
const FREE_QUOTA_RE = /FreeUsageLimitError|GoUsageLimitError/i;
|
|
26
|
+
/** 黑名单层:真账号日配额,换出口抬不动。 */
|
|
27
|
+
const BLACK_QUOTA_RE = /BlackUsageLimitError/i;
|
|
28
|
+
|
|
29
|
+
/** 只这两类状态码参与判决;其余(含 200)一律不参与、也不许刷日志。 */
|
|
30
|
+
export const SIGNAL_STATUSES = [403, 429];
|
|
31
|
+
|
|
32
|
+
/** 正文捕获上限(判据只需要文案里的类名,2KB 足够,且防大 body 拖成慢扫描)。 */
|
|
33
|
+
export const SIGNAL_MAX_BYTES = 2048;
|
|
34
|
+
|
|
35
|
+
/** 正文里出现了具名判决(供 tapHandler 一凑够就提前判决,不必等整条 body 读完)。 */
|
|
36
|
+
export function looksLikeQuotaSignal(text) {
|
|
37
|
+
const t = typeof text === "string" ? text : "";
|
|
38
|
+
return FREE_TIER_RE.test(t) || FREE_QUOTA_RE.test(t) || BLACK_QUOTA_RE.test(t);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 一次上游失败该不该换出口。
|
|
43
|
+
* @param {{ status?: number, text?: string }} signal 上游状态码 + 正文(≤ SIGNAL_MAX_BYTES;读不到给空串)
|
|
44
|
+
* @returns {{ kind: "free-tier"|"free-quota"|"black-quota"|"ip-limit"|"none", rotate: boolean,
|
|
45
|
+
* forced: boolean, why: string }}
|
|
46
|
+
* forced=true 表示必须**跳过**冷却(参照实现的 RotateForced:冷却曾把第一次真实反应吞掉,
|
|
47
|
+
* 于是重试复用了同一个已限额的出口 —— stage.go:315-324)。
|
|
48
|
+
*/
|
|
49
|
+
export function classifyWarpSignal({ status, text = "" } = {}) {
|
|
50
|
+
const code = Number(status);
|
|
51
|
+
const body = typeof text === "string" ? text : "";
|
|
52
|
+
if (!SIGNAL_STATUSES.includes(code)) {
|
|
53
|
+
return { kind: "none", rotate: false, forced: false, why: `上游 ${code || "?"} 不是 403/429,与出口限额无关` };
|
|
54
|
+
}
|
|
55
|
+
// 顺序即优先级,照抄 errors.go 的 classify():先认正文里的具名判决,再退回状态码。
|
|
56
|
+
if (BLACK_QUOTA_RE.test(body)) {
|
|
57
|
+
return { kind: "black-quota", rotate: false, forced: false, why: "BlackUsageLimitError 是真正的账号日配额,换出口抬不动(参照实现同样绝不旋转)——该换 key,不是换 IP" };
|
|
58
|
+
}
|
|
59
|
+
if (FREE_TIER_RE.test(body)) {
|
|
60
|
+
return { kind: "free-tier", rotate: true, forced: true, why: "403 FreeTierError 与 429 FreeUsageLimitError 是同一条件的两种状态码;参照实现实测换身份后同一个请求立刻 200 ⇒ 强制换出口,跳过冷却" };
|
|
61
|
+
}
|
|
62
|
+
if (FREE_QUOTA_RE.test(body)) {
|
|
63
|
+
return { kind: "free-quota", rotate: true, forced: true, why: "匿名免费层 429 实测是**按出口 IP** 计的限额(同一身份连发 3 次全 429,换身份立刻 200)⇒ 强制换出口,跳过冷却" };
|
|
64
|
+
}
|
|
65
|
+
if (code === 429) {
|
|
66
|
+
return { kind: "ip-limit", rotate: true, forced: false, why: "裸 429(正文无配额判决)= 出口 IP 限流信号 ⇒ 换出口;保留冷却,防 429 风暴把 CF 注册刷爆" };
|
|
67
|
+
}
|
|
68
|
+
return { kind: "none", rotate: false, forced: false, why: "403 但正文没有 FreeTier 判决 ⇒ 不是配额信号(形状/区域/凭据类),换出口无用" };
|
|
69
|
+
}
|