tt-help-cli-ycl 1.4.131 → 1.4.134
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/scripts/test-daemon-failover.mjs +79 -45
- package/scripts/test-probe-live.mjs +83 -0
- package/scripts/test-probe.mjs +315 -0
- package/src/clash/api.js +39 -0
- package/src/clash/checker.js +1 -1
- package/src/clash/daemon.js +79 -40
- package/src/watch/db-stats.js +3 -3
- package/src/watch/server.js +54 -42
- package/src/watchdog/agent.js +5 -0
- package/src/watchdog/process-manager.js +8 -2
package/package.json
CHANGED
|
@@ -1,14 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* daemon _handleFailover
|
|
2
|
+
* daemon _handleFailover 单测(2026-08-25,时间锚点方案,无需 Clash 运行)
|
|
3
3
|
*
|
|
4
4
|
* 验证:
|
|
5
|
-
* -
|
|
6
|
-
* -
|
|
7
|
-
* -
|
|
8
|
-
* -
|
|
9
|
-
* - 窗口内到达 failoverEscapeAfter 次 → 按业务级封禁轮换一次
|
|
10
|
-
* - 实际切换后 → 窗口与计数重置
|
|
11
|
-
* - escapeAfter 可配置
|
|
5
|
+
* - 探针不健康 → 立即切换(无视冷却)
|
|
6
|
+
* - 探针健康(含壳页)→ 距上次切换 < 冷却期仅检查不切换
|
|
7
|
+
* - 探针健康 → 距上次切换 >= 冷却期 → 按业务级封禁轮换一次
|
|
8
|
+
* - 切换后(含手动/巡检/failover)更新 _lastSwitchAt 锚点,冷却期内不再切
|
|
12
9
|
* - 候选集为空 → 触发 delay 初测且不切换
|
|
13
10
|
*
|
|
14
11
|
* 运行: node --test scripts/test-daemon-failover.mjs
|
|
@@ -31,7 +28,9 @@ const state = {
|
|
|
31
28
|
nodes: ["🇭🇰 香港01", "🇲🇾 马来02"],
|
|
32
29
|
currentNode: "🇭🇰 香港01",
|
|
33
30
|
switches: [],
|
|
34
|
-
closes: 0,
|
|
31
|
+
closes: 0, // closeAllConnections(全断)调用次数
|
|
32
|
+
idleCloses: 0, // closeIdleConnections(优雅断连)调用次数
|
|
33
|
+
failIdleClose: false, // 模拟优雅断连失败
|
|
35
34
|
};
|
|
36
35
|
|
|
37
36
|
function resetState() {
|
|
@@ -48,6 +47,8 @@ function resetState() {
|
|
|
48
47
|
state.currentNode = "🇭🇰 香港01";
|
|
49
48
|
state.switches = [];
|
|
50
49
|
state.closes = 0;
|
|
50
|
+
state.idleCloses = 0;
|
|
51
|
+
state.failIdleClose = false;
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
// ── 注册模块 mock(必须在 import daemon.js 之前) ──
|
|
@@ -85,6 +86,11 @@ before(async () => {
|
|
|
85
86
|
closeAllConnections: async () => {
|
|
86
87
|
state.closes += 1;
|
|
87
88
|
},
|
|
89
|
+
closeIdleConnections: async () => {
|
|
90
|
+
if (state.failIdleClose) throw new Error("mock 优雅断连失败");
|
|
91
|
+
state.idleCloses += 1;
|
|
92
|
+
return { closed: 3, active: 2 };
|
|
93
|
+
},
|
|
88
94
|
},
|
|
89
95
|
});
|
|
90
96
|
mock.module("../src/clash/ipc.js", {
|
|
@@ -116,29 +122,32 @@ function makeCtx(opts = {}) {
|
|
|
116
122
|
return { d, c };
|
|
117
123
|
}
|
|
118
124
|
|
|
119
|
-
test("F1
|
|
125
|
+
test("F1 探针健康:冷却期外首次 failover 即轮换,并更新锚点", async () => {
|
|
120
126
|
resetState();
|
|
121
127
|
const { d, c } = makeCtx();
|
|
122
|
-
|
|
123
|
-
await d._handleFailover({ reason: "F1
|
|
124
|
-
assert.
|
|
125
|
-
assert.equal(state.
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
128
|
+
c._lastSwitchAt = Date.now() - 600_001; // 距上次切换 > 10min 冷却
|
|
129
|
+
await d._handleFailover({ reason: "F1" }, c);
|
|
130
|
+
assert.deepEqual(state.switches, ["🇲🇾 马来02"], "冷却期外应轮换切换");
|
|
131
|
+
assert.equal(state.idleCloses, 1, "切换后应优雅断连(空闲回收)");
|
|
132
|
+
assert.ok(
|
|
133
|
+
c._lastSwitchAt && c._lastSwitchAt > Date.now() - 5000,
|
|
134
|
+
"切换后应更新锚点",
|
|
135
|
+
);
|
|
129
136
|
});
|
|
130
137
|
|
|
131
|
-
test("F2
|
|
138
|
+
test("F2 探针不健康:立即切换(无视冷却)", async () => {
|
|
132
139
|
resetState();
|
|
133
140
|
const { d, c } = makeCtx();
|
|
141
|
+
c._lastSwitchAt = Date.now(); // 刚切过,但探针不健康时不看锚点
|
|
134
142
|
state.health = { ...state.health, healthy: false, error: "timeout" };
|
|
135
143
|
await d._handleFailover({ reason: "F2" }, c);
|
|
136
144
|
assert.deepEqual(state.switches, ["🇲🇾 马来02"], "不健康应立即切换");
|
|
137
145
|
});
|
|
138
146
|
|
|
139
|
-
test("F3
|
|
147
|
+
test("F3 壳页视为链路正常:冷却期内仅检查不切换", async () => {
|
|
140
148
|
resetState();
|
|
141
149
|
const { d, c } = makeCtx();
|
|
150
|
+
c._lastSwitchAt = Date.now(); // 冷却期内
|
|
142
151
|
// 壳页签名:200 + 无跳转 + 无 error + 无 title
|
|
143
152
|
state.health = {
|
|
144
153
|
healthy: false,
|
|
@@ -150,41 +159,45 @@ test("F3 壳页视为链路正常:仅检查不切换", async () => {
|
|
|
150
159
|
error: null,
|
|
151
160
|
};
|
|
152
161
|
await d._handleFailover({ reason: "F3" }, c);
|
|
153
|
-
assert.equal(state.switches.length, 0, "
|
|
162
|
+
assert.equal(state.switches.length, 0, "冷却期内壳页不应触发切换");
|
|
154
163
|
});
|
|
155
164
|
|
|
156
|
-
test("F4
|
|
165
|
+
test("F4 冷却期约束:切换后冷却期内不连续切换,过期后再切", async () => {
|
|
157
166
|
resetState();
|
|
158
|
-
const { d, c } = makeCtx(
|
|
159
|
-
|
|
160
|
-
await
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
167
|
+
const { d, c } = makeCtx();
|
|
168
|
+
// 首次 failover(无锚点)→ 按冷却期外处理 → 轮换
|
|
169
|
+
await d._handleFailover({ reason: "F4-1" }, c);
|
|
170
|
+
assert.equal(state.switches.length, 1, "首次应轮换切换");
|
|
171
|
+
// 锚点刚更新,冷却期内再次 failover(探针健康)→ 不切
|
|
172
|
+
await d._handleFailover({ reason: "F4-2" }, c);
|
|
173
|
+
assert.equal(state.switches.length, 1, "冷却期内不应再切换");
|
|
174
|
+
// 冷却期过后 → 再次轮换
|
|
175
|
+
c._lastSwitchAt = Date.now() - 600_001;
|
|
176
|
+
await d._handleFailover({ reason: "F4-3" }, c);
|
|
177
|
+
assert.equal(state.switches.length, 2, "冷却期过后应再切换");
|
|
166
178
|
});
|
|
167
179
|
|
|
168
|
-
test("F5
|
|
180
|
+
test("F5 手动切换同样更新锚点:切换后冷却期内 failover 不再切", async () => {
|
|
169
181
|
resetState();
|
|
170
182
|
const { d, c } = makeCtx();
|
|
171
|
-
|
|
172
|
-
await d.
|
|
173
|
-
|
|
174
|
-
assert.
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
183
|
+
// 手动切换(用户主动切,立即执行)
|
|
184
|
+
await d._handleSwitchNode({ node: "🇲🇾 马来02", proxyPort: 7897 }, c);
|
|
185
|
+
assert.equal(state.switches.length, 1, "手动切换应立即执行");
|
|
186
|
+
assert.ok(
|
|
187
|
+
c._lastSwitchAt && c._lastSwitchAt > Date.now() - 5000,
|
|
188
|
+
"手动切换应更新锚点",
|
|
189
|
+
);
|
|
190
|
+
// 手动切换后冷却期内,探针健康的 failover 不再切
|
|
191
|
+
await d._handleFailover({ reason: "F5-2" }, c);
|
|
192
|
+
assert.equal(
|
|
193
|
+
state.switches.length,
|
|
194
|
+
1,
|
|
195
|
+
"手动切换后冷却期内 failover 不应再切",
|
|
196
|
+
);
|
|
180
197
|
});
|
|
181
198
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
const { d, c } = makeCtx({ failoverEscapeAfter: 1 });
|
|
185
|
-
await d._handleFailover({ reason: "F6" }, c);
|
|
186
|
-
assert.equal(state.switches.length, 1, "escapeAfter=1 应首次即切换");
|
|
187
|
-
});
|
|
199
|
+
// F6 已移除:failoverEscapeAfter 计数方案已弃用(2026-08-25 改为时间锚点),
|
|
200
|
+
// 不再存在"连报 N 次"阈值;字段保留仅为兼容外部传参,不影响行为。
|
|
188
201
|
|
|
189
202
|
test("F7 候选集为空:触发 delay 初测且不切换", async () => {
|
|
190
203
|
resetState();
|
|
@@ -195,3 +208,24 @@ test("F7 候选集为空:触发 delay 初测且不切换", async () => {
|
|
|
195
208
|
assert.equal(state.switches.length, 0, "无候选不应切换");
|
|
196
209
|
assert.ok(c._lastProbeAt, "应触发过 delay 初测");
|
|
197
210
|
});
|
|
211
|
+
|
|
212
|
+
test("F8 优雅断连失败:回退全断,切换仍生效", async () => {
|
|
213
|
+
resetState();
|
|
214
|
+
const { d, c } = makeCtx();
|
|
215
|
+
c._lastSwitchAt = Date.now() - 600_001; // 冷却期外
|
|
216
|
+
state.failIdleClose = true; // 优雅断连抛错
|
|
217
|
+
await d._handleFailover({ reason: "F8" }, c);
|
|
218
|
+
assert.deepEqual(state.switches, ["🇲🇾 马来02"], "切换仍应执行");
|
|
219
|
+
assert.equal(state.idleCloses, 0, "优雅断连失败未计数");
|
|
220
|
+
assert.equal(state.closes, 1, "失败后应回退全断");
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("F9 disconnectMode=full:直接全断", async () => {
|
|
224
|
+
resetState();
|
|
225
|
+
const { d, c } = makeCtx({ disconnectMode: "full" });
|
|
226
|
+
c._lastSwitchAt = Date.now() - 600_001;
|
|
227
|
+
await d._handleFailover({ reason: "F9" }, c);
|
|
228
|
+
assert.deepEqual(state.switches, ["🇲🇾 马来02"], "切换应执行");
|
|
229
|
+
assert.equal(state.idleCloses, 0, "full 模式不走优雅断连");
|
|
230
|
+
assert.equal(state.closes, 1, "full 模式直接全断");
|
|
231
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 探测模块集成测试(真实浏览器 + 真实 TikTok)
|
|
3
|
+
*
|
|
4
|
+
* 用法:
|
|
5
|
+
* node scripts/test-probe-live.mjs [ports] [points]
|
|
6
|
+
* 例: node scripts/test-probe-live.mjs 4222
|
|
7
|
+
* node scripts/test-probe-live.mjs 4222 video,comment
|
|
8
|
+
*
|
|
9
|
+
* 说明:
|
|
10
|
+
* - 连接本地已启动的 CDP 端口(不 kill 业务浏览器)
|
|
11
|
+
* - 会对 TikTok 真实发起访问(探测本身是访问行为)
|
|
12
|
+
* - 断言输出结构 + 至少能完成调度;业务可用性结果只打印不判成败
|
|
13
|
+
*/
|
|
14
|
+
import assert from "node:assert/strict";
|
|
15
|
+
import { runProbe, POINT_NAMES } from "../src/lib/probe/index.js";
|
|
16
|
+
|
|
17
|
+
const ports = (process.argv[2] || "4222")
|
|
18
|
+
.split(",")
|
|
19
|
+
.map((s) => Number(s.trim()))
|
|
20
|
+
.filter((n) => Number.isInteger(n) && n > 0);
|
|
21
|
+
const points = process.argv[3]
|
|
22
|
+
? process.argv[3].split(",").map((s) => s.trim()).filter(Boolean)
|
|
23
|
+
: POINT_NAMES;
|
|
24
|
+
|
|
25
|
+
console.error(`[live-test] ports=${ports.join(",")} points=${points.join(",")}`);
|
|
26
|
+
console.error(`[live-test] 说明:将对 TikTok 真实访问,属正常探测行为\n`);
|
|
27
|
+
|
|
28
|
+
const t0 = Date.now();
|
|
29
|
+
const result = await runProbe({ ports, points, log: console.error });
|
|
30
|
+
const totalMs = Date.now() - t0;
|
|
31
|
+
|
|
32
|
+
console.error(`\n[结果] 总耗时 ${totalMs}ms`);
|
|
33
|
+
console.log(JSON.stringify(result, null, 2));
|
|
34
|
+
|
|
35
|
+
// ── 结构断言(不判业务成败,只判调度/输出正确) ──
|
|
36
|
+
let failed = 0;
|
|
37
|
+
try {
|
|
38
|
+
assert.ok(Array.isArray(result.ports), "ports 应为数组");
|
|
39
|
+
assert.equal(result.ports.length, ports.length, "端口数应一致");
|
|
40
|
+
assert.ok(result.ports.every((p) => Number.isInteger(p.port)), "端口号应为整数");
|
|
41
|
+
assert.ok(
|
|
42
|
+
result.ports.every((p) => typeof p.loggedIn === "boolean"),
|
|
43
|
+
"loggedIn 应为布尔",
|
|
44
|
+
);
|
|
45
|
+
// 每端口包含请求的功能点键
|
|
46
|
+
for (const p of result.ports) {
|
|
47
|
+
for (const name of points) {
|
|
48
|
+
assert.ok(
|
|
49
|
+
name in p.points,
|
|
50
|
+
`端口 ${p.port} 应包含功能点 ${name}`,
|
|
51
|
+
);
|
|
52
|
+
const r = p.points[name];
|
|
53
|
+
assert.equal(typeof r.ok, "boolean", `${name}.ok 应为布尔`);
|
|
54
|
+
assert.equal(typeof r.count, "number", `${name}.count 应为数字`);
|
|
55
|
+
assert.equal(typeof r.elapsedMs, "number", `${name}.elapsedMs 应为数字`);
|
|
56
|
+
assert.ok(
|
|
57
|
+
typeof r.error === "string" || r.error === null,
|
|
58
|
+
`${name}.error 应为 string|null`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
console.error(`\n[断言] 结构校验全部通过 ✓`);
|
|
63
|
+
} catch (e) {
|
|
64
|
+
failed = 1;
|
|
65
|
+
console.error(`\n[断言] 失败: ${e.message}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 汇总业务结果
|
|
69
|
+
console.error("\n[业务汇总]");
|
|
70
|
+
for (const p of result.ports) {
|
|
71
|
+
for (const name of points) {
|
|
72
|
+
const r = p.points[name];
|
|
73
|
+
if (!r) continue;
|
|
74
|
+
const tag = r.skipped
|
|
75
|
+
? `跳过(${r.skipReason})`
|
|
76
|
+
: r.ok
|
|
77
|
+
? `✓ ${r.count}`
|
|
78
|
+
: `✗ ${r.error}`;
|
|
79
|
+
console.error(` port ${p.port} ${name.padEnd(8)} ${tag} [${r.elapsedMs}ms]`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
process.exit(failed);
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 探测模块单元测试(离线,不连浏览器)
|
|
3
|
+
*
|
|
4
|
+
* 覆盖:
|
|
5
|
+
* - common: classifyError / withTimeout / cleanMessage / makePointResult
|
|
6
|
+
* - targets: DEFAULT_TARGETS / resolveTargets
|
|
7
|
+
* - index: POINT_NAMES / normalizePoints / runProbe 空端口与异常端口
|
|
8
|
+
* - points: comment 无视频 skipped、follow 未登录 skipped、POINT_NAME 注册
|
|
9
|
+
*
|
|
10
|
+
* 运行: node scripts/test-probe.mjs
|
|
11
|
+
*/
|
|
12
|
+
import assert from "node:assert/strict";
|
|
13
|
+
import {
|
|
14
|
+
classifyError,
|
|
15
|
+
withTimeout,
|
|
16
|
+
cleanMessage,
|
|
17
|
+
makePointResult,
|
|
18
|
+
isTransientKind,
|
|
19
|
+
retryOnTransient,
|
|
20
|
+
} from "../src/lib/probe/common.js";
|
|
21
|
+
import { DEFAULT_TARGETS, resolveTargets } from "../src/lib/probe/targets.js";
|
|
22
|
+
import {
|
|
23
|
+
POINTS,
|
|
24
|
+
POINT_NAMES,
|
|
25
|
+
normalizePoints,
|
|
26
|
+
runProbe,
|
|
27
|
+
} from "../src/lib/probe/index.js";
|
|
28
|
+
import {
|
|
29
|
+
POINT_NAME as COMMENT_POINT_NAME,
|
|
30
|
+
probeComment,
|
|
31
|
+
} from "../src/lib/probe/points/comment.js";
|
|
32
|
+
import {
|
|
33
|
+
POINT_NAME as FOLLOW_POINT_NAME,
|
|
34
|
+
probeFollow,
|
|
35
|
+
} from "../src/lib/probe/points/follow.js";
|
|
36
|
+
|
|
37
|
+
let passed = 0;
|
|
38
|
+
const failures = [];
|
|
39
|
+
const allTests = [];
|
|
40
|
+
function test(name, fn) {
|
|
41
|
+
allTests.push(
|
|
42
|
+
Promise.resolve().then(() => {
|
|
43
|
+
try {
|
|
44
|
+
fn();
|
|
45
|
+
passed++;
|
|
46
|
+
console.log(`✓ ${name}`);
|
|
47
|
+
} catch (e) {
|
|
48
|
+
failures.push({ name, error: e });
|
|
49
|
+
console.error(`✗ ${name}: ${e.message}`);
|
|
50
|
+
}
|
|
51
|
+
}),
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
function testAsync(name, fn) {
|
|
55
|
+
allTests.push(
|
|
56
|
+
Promise.resolve().then(async () => {
|
|
57
|
+
try {
|
|
58
|
+
await fn();
|
|
59
|
+
passed++;
|
|
60
|
+
console.log(`✓ ${name}`);
|
|
61
|
+
} catch (e) {
|
|
62
|
+
failures.push({ name, error: e });
|
|
63
|
+
console.error(`✗ ${name}: ${e.message}`);
|
|
64
|
+
}
|
|
65
|
+
}),
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── common.classifyError ──
|
|
70
|
+
test("classifyError: 被封 → blocked", () => {
|
|
71
|
+
assert.equal(classifyError(new Error("被封: abc")).kind, "blocked");
|
|
72
|
+
});
|
|
73
|
+
test("classifyError: CDN限流 → rate_limited", () => {
|
|
74
|
+
assert.equal(classifyError(new Error("CDN限流: abc")).kind, "rate_limited");
|
|
75
|
+
});
|
|
76
|
+
test("classifyError: Access Denied → rate_limited", () => {
|
|
77
|
+
assert.equal(classifyError("Access Denied").kind, "rate_limited");
|
|
78
|
+
});
|
|
79
|
+
test("classifyError: 验证码 → captcha", () => {
|
|
80
|
+
assert.equal(classifyError("验证码出现").kind, "captcha");
|
|
81
|
+
});
|
|
82
|
+
test("classifyError: captcha → captcha", () => {
|
|
83
|
+
assert.equal(classifyError("captcha verify").kind, "captcha");
|
|
84
|
+
});
|
|
85
|
+
test("classifyError: Timeout → timeout", () => {
|
|
86
|
+
assert.equal(classifyError(new Error("Timeout 123")).kind, "timeout");
|
|
87
|
+
});
|
|
88
|
+
test("classifyError: 超时 → timeout", () => {
|
|
89
|
+
assert.equal(classifyError("请求超时").kind, "timeout");
|
|
90
|
+
});
|
|
91
|
+
test("classifyError: login_required → login_required", () => {
|
|
92
|
+
assert.equal(classifyError("login_required").kind, "login_required");
|
|
93
|
+
});
|
|
94
|
+
test("classifyError: 未登录 → login_required", () => {
|
|
95
|
+
assert.equal(classifyError("请先登录").kind, "login_required");
|
|
96
|
+
});
|
|
97
|
+
test("classifyError: region → region_blocked", () => {
|
|
98
|
+
assert.equal(
|
|
99
|
+
classifyError("not available in your region").kind,
|
|
100
|
+
"region_blocked",
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
test("classifyError: 普通错误 → error", () => {
|
|
104
|
+
assert.equal(classifyError("boom something").kind, "error");
|
|
105
|
+
});
|
|
106
|
+
test("classifyError: 空 → error + 未知错误", () => {
|
|
107
|
+
const c = classifyError(null);
|
|
108
|
+
assert.equal(c.kind, "error");
|
|
109
|
+
assert.equal(c.message, "未知错误");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// ── common.cleanMessage ──
|
|
113
|
+
test("cleanMessage: 去 ANSI + 首行 + 限长", () => {
|
|
114
|
+
const out = cleanMessage("\x1b[31mline1\x1b[0m\nline2\n".repeat(50));
|
|
115
|
+
assert.ok(!out.includes("\x1b"));
|
|
116
|
+
assert.ok(!out.includes("\n"));
|
|
117
|
+
assert.ok(out.length <= 300);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// ── common.withTimeout ──
|
|
121
|
+
testAsync("withTimeout: 正常完成返回结果", async () => {
|
|
122
|
+
const r = await withTimeout(Promise.resolve("ok"), 2000, "t");
|
|
123
|
+
assert.equal(r, "ok");
|
|
124
|
+
});
|
|
125
|
+
testAsync("withTimeout: 超时 reject", async () => {
|
|
126
|
+
let timedOut = false;
|
|
127
|
+
try {
|
|
128
|
+
await withTimeout(new Promise(() => {}), 30, "超时测试");
|
|
129
|
+
} catch (e) {
|
|
130
|
+
timedOut = true;
|
|
131
|
+
assert.ok(e.message.includes("超时测试"));
|
|
132
|
+
}
|
|
133
|
+
assert.ok(timedOut, "应超时 reject");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// ── common.makePointResult ──
|
|
137
|
+
test("makePointResult: 默认空 → kind=empty", () => {
|
|
138
|
+
const r = makePointResult({ ok: false });
|
|
139
|
+
assert.equal(r.kind, "empty");
|
|
140
|
+
assert.equal(r.count, 0);
|
|
141
|
+
assert.equal(r.skipped, false);
|
|
142
|
+
});
|
|
143
|
+
test("makePointResult: ok → kind=null + extra 合并", () => {
|
|
144
|
+
const r = makePointResult({ ok: true, count: 3, extra: { a: 1 } });
|
|
145
|
+
assert.equal(r.kind, null);
|
|
146
|
+
assert.equal(r.a, 1);
|
|
147
|
+
});
|
|
148
|
+
test("makePointResult: skipped + 显式 kind", () => {
|
|
149
|
+
const r = makePointResult({
|
|
150
|
+
ok: false,
|
|
151
|
+
skipped: true,
|
|
152
|
+
skipReason: "no_video",
|
|
153
|
+
kind: "login_required",
|
|
154
|
+
});
|
|
155
|
+
assert.equal(r.kind, "login_required");
|
|
156
|
+
assert.equal(r.skipReason, "no_video");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// ── common.isTransientKind / retryOnTransient ──
|
|
160
|
+
test("isTransientKind: 瞬时型为 true", () => {
|
|
161
|
+
for (const k of ["timeout", "empty", "error", "waf_challenge"]) {
|
|
162
|
+
assert.equal(isTransientKind(k), true, `${k} 应为瞬时`);
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
test("isTransientKind: 确定型与空为 false", () => {
|
|
166
|
+
for (const k of [
|
|
167
|
+
"blocked",
|
|
168
|
+
"rate_limited",
|
|
169
|
+
"captcha",
|
|
170
|
+
"login_required",
|
|
171
|
+
"region_blocked",
|
|
172
|
+
null,
|
|
173
|
+
undefined,
|
|
174
|
+
"",
|
|
175
|
+
]) {
|
|
176
|
+
assert.equal(isTransientKind(k), false, `${k} 不应为瞬时`);
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
testAsync("retryOnTransient: 成功即返回(只调 1 次)", async () => {
|
|
180
|
+
let calls = 0;
|
|
181
|
+
const r = await retryOnTransient(async () => {
|
|
182
|
+
calls++;
|
|
183
|
+
return makePointResult({ ok: true, count: 3 });
|
|
184
|
+
}, { retries: 2, retryDelayMs: 0 });
|
|
185
|
+
assert.equal(r.ok, true);
|
|
186
|
+
assert.equal(calls, 1);
|
|
187
|
+
});
|
|
188
|
+
testAsync("retryOnTransient: 确定性失败不重试(只调 1 次)", async () => {
|
|
189
|
+
let calls = 0;
|
|
190
|
+
const r = await retryOnTransient(async () => {
|
|
191
|
+
calls++;
|
|
192
|
+
return makePointResult({ ok: false, kind: "blocked" });
|
|
193
|
+
}, { retries: 2, retryDelayMs: 0 });
|
|
194
|
+
assert.equal(r.ok, false);
|
|
195
|
+
assert.equal(r.kind, "blocked");
|
|
196
|
+
assert.equal(calls, 1);
|
|
197
|
+
});
|
|
198
|
+
testAsync("retryOnTransient: 瞬时失败后第 2 次成功", async () => {
|
|
199
|
+
let calls = 0;
|
|
200
|
+
const r = await retryOnTransient(async () => {
|
|
201
|
+
calls++;
|
|
202
|
+
if (calls === 1) return makePointResult({ ok: false, kind: "waf_challenge" });
|
|
203
|
+
return makePointResult({ ok: true, count: 5 });
|
|
204
|
+
}, { retries: 2, retryDelayMs: 0 });
|
|
205
|
+
assert.equal(r.ok, true);
|
|
206
|
+
assert.equal(calls, 2);
|
|
207
|
+
});
|
|
208
|
+
testAsync("retryOnTransient: 瞬时失败耗尽返回最后结果", async () => {
|
|
209
|
+
let calls = 0;
|
|
210
|
+
const r = await retryOnTransient(async () => {
|
|
211
|
+
calls++;
|
|
212
|
+
return makePointResult({ ok: false, kind: "timeout" });
|
|
213
|
+
}, { retries: 2, retryDelayMs: 0 });
|
|
214
|
+
assert.equal(r.ok, false);
|
|
215
|
+
assert.equal(r.kind, "timeout");
|
|
216
|
+
assert.equal(calls, 3); // 1 次初始 + 2 次重试
|
|
217
|
+
});
|
|
218
|
+
testAsync("retryOnTransient: 重试间有间隔延迟", async () => {
|
|
219
|
+
const t0 = Date.now();
|
|
220
|
+
let calls = 0;
|
|
221
|
+
await retryOnTransient(
|
|
222
|
+
async () => {
|
|
223
|
+
calls++;
|
|
224
|
+
return makePointResult({ ok: false, kind: "empty" });
|
|
225
|
+
},
|
|
226
|
+
{ retries: 1, retryDelayMs: 150 },
|
|
227
|
+
);
|
|
228
|
+
assert.equal(calls, 2);
|
|
229
|
+
assert.ok(Date.now() - t0 >= 150, "应有重试间隔");
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// ── targets ──
|
|
233
|
+
test("targets: 默认值", () => {
|
|
234
|
+
assert.equal(DEFAULT_TARGETS.author, "tiktok");
|
|
235
|
+
assert.equal(DEFAULT_TARGETS.searchQuery, "dog");
|
|
236
|
+
assert.equal(DEFAULT_TARGETS.video, null);
|
|
237
|
+
});
|
|
238
|
+
test("targets: resolveTargets 覆盖 + 保留默认", () => {
|
|
239
|
+
const t = resolveTargets({ author: "foo", video: "x" });
|
|
240
|
+
assert.equal(t.author, "foo");
|
|
241
|
+
assert.equal(t.searchQuery, "dog");
|
|
242
|
+
assert.equal(t.video, "x");
|
|
243
|
+
});
|
|
244
|
+
test("targets: resolveTargets 空覆盖 → 全默认", () => {
|
|
245
|
+
const t = resolveTargets();
|
|
246
|
+
assert.deepEqual(t, DEFAULT_TARGETS);
|
|
247
|
+
});
|
|
248
|
+
test("targets: resolveTargets null → 全默认", () => {
|
|
249
|
+
const t = resolveTargets(null);
|
|
250
|
+
assert.deepEqual(t, DEFAULT_TARGETS);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// ── index.normalizePoints ──
|
|
254
|
+
test("normalizePoints: 空 → 全部功能点", () => {
|
|
255
|
+
assert.deepEqual(normalizePoints(), [...POINT_NAMES]);
|
|
256
|
+
assert.deepEqual(normalizePoints([]), [...POINT_NAMES]);
|
|
257
|
+
assert.deepEqual(normalizePoints(null), [...POINT_NAMES]);
|
|
258
|
+
});
|
|
259
|
+
test("normalizePoints: 全非法 → 全部功能点", () => {
|
|
260
|
+
assert.deepEqual(normalizePoints(["bad", "xxx"]), [...POINT_NAMES]);
|
|
261
|
+
});
|
|
262
|
+
test("normalizePoints: 过滤非法 + 去重 + 保序", () => {
|
|
263
|
+
assert.deepEqual(normalizePoints(["video", "video", "bad", "comment"]), [
|
|
264
|
+
"video",
|
|
265
|
+
"comment",
|
|
266
|
+
]);
|
|
267
|
+
});
|
|
268
|
+
test("POINTS 注册表完整", () => {
|
|
269
|
+
assert.equal(POINT_NAMES.length, 5);
|
|
270
|
+
for (const n of POINT_NAMES) assert.equal(typeof POINTS[n], "function");
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
// ── points 注册与离线可判定分支 ──
|
|
274
|
+
test("points: 各模块 POINT_NAME 常量正确", () => {
|
|
275
|
+
assert.equal(COMMENT_POINT_NAME, "comment");
|
|
276
|
+
assert.equal(FOLLOW_POINT_NAME, "follow");
|
|
277
|
+
});
|
|
278
|
+
testAsync("probeComment: 无视频 → skipped(no_video)", async () => {
|
|
279
|
+
const r = await probeComment(null, {
|
|
280
|
+
target: { video: null },
|
|
281
|
+
videoHref: null,
|
|
282
|
+
});
|
|
283
|
+
assert.equal(r.skipped, true);
|
|
284
|
+
assert.equal(r.skipReason, "no_video");
|
|
285
|
+
assert.equal(r.ok, false);
|
|
286
|
+
});
|
|
287
|
+
testAsync("probeFollow: 未登录 → skipped(not_logged_in)", async () => {
|
|
288
|
+
const r = await probeFollow(null, { target: {}, loggedIn: false });
|
|
289
|
+
assert.equal(r.skipped, true);
|
|
290
|
+
assert.equal(r.skipReason, "not_logged_in");
|
|
291
|
+
assert.equal(r.kind, "login_required");
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
// ── runProbe 空端口 / 非法端口 ──
|
|
295
|
+
testAsync("runProbe: 空端口 → 空结果结构", async () => {
|
|
296
|
+
const r = await runProbe({ ports: [], log: () => {} });
|
|
297
|
+
assert.deepEqual(r.ports, []);
|
|
298
|
+
assert.ok(Array.isArray(r.points));
|
|
299
|
+
assert.equal(typeof r.checkedAt, "string");
|
|
300
|
+
assert.equal(typeof r.durationMs, "number");
|
|
301
|
+
});
|
|
302
|
+
testAsync("runProbe: 非法端口被过滤", async () => {
|
|
303
|
+
const r = await runProbe({ ports: [0, -1, NaN, "abc"], log: () => {} });
|
|
304
|
+
assert.deepEqual(r.ports, []);
|
|
305
|
+
});
|
|
306
|
+
// 注:真实端口连接(存在/不存在端口)属集成测试范畴(会启动/连接 Edge),见 scripts/test-probe-live.mjs
|
|
307
|
+
|
|
308
|
+
// ── 汇总(等全部测试完成) ──
|
|
309
|
+
await Promise.all(allTests);
|
|
310
|
+
console.log(`\n${passed} 通过, ${failures.length} 失败`);
|
|
311
|
+
if (failures.length > 0) {
|
|
312
|
+
for (const f of failures)
|
|
313
|
+
console.error(` FAIL: ${f.name}\n ${f.error.stack}`);
|
|
314
|
+
process.exit(1);
|
|
315
|
+
}
|
package/src/clash/api.js
CHANGED
|
@@ -165,6 +165,45 @@ export async function closeConnection(id) {
|
|
|
165
165
|
export async function closeAllConnections() {
|
|
166
166
|
return request("/connections", { method: "DELETE" });
|
|
167
167
|
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* 优雅断连:只关闭"空闲"连接(两次采样间无流量增长),活跃连接放行让其自然完成
|
|
171
|
+
* 切换节点后调用:正在进行的请求不被打断,空闲 keep-alive 被回收使新请求立即走新节点
|
|
172
|
+
* @param {object} [opts]
|
|
173
|
+
* @param {number} [opts.sampleMs=1000] - 两次采样间隔(毫秒)
|
|
174
|
+
* @returns {Promise<{closed:number, active:number}>} closed=已断开空闲数, active=放行的活跃/新建数
|
|
175
|
+
*/
|
|
176
|
+
export async function closeIdleConnections({ sampleMs = 1000 } = {}) {
|
|
177
|
+
const snap1 = await getConnections();
|
|
178
|
+
await new Promise((r) => setTimeout(r, sampleMs));
|
|
179
|
+
const snap2 = await getConnections();
|
|
180
|
+
const map1 = new Map((snap1.connections || []).map((c) => [c.id, c]));
|
|
181
|
+
const idleIds = [];
|
|
182
|
+
let active = 0;
|
|
183
|
+
for (const c of snap2.connections || []) {
|
|
184
|
+
const c1 = map1.get(c.id);
|
|
185
|
+
// 新建连接(快照1没有):可能是刚发起的请求,保守放行避免打断
|
|
186
|
+
if (!c1) {
|
|
187
|
+
active++;
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (c1.download === c.download && c1.upload === c.upload)
|
|
191
|
+
idleIds.push(c.id);
|
|
192
|
+
else active++;
|
|
193
|
+
}
|
|
194
|
+
let closed = 0;
|
|
195
|
+
await Promise.all(
|
|
196
|
+
idleIds.map(async (id) => {
|
|
197
|
+
try {
|
|
198
|
+
await closeConnection(id);
|
|
199
|
+
closed++;
|
|
200
|
+
} catch {
|
|
201
|
+
/* 单个失败忽略,其余继续 */
|
|
202
|
+
}
|
|
203
|
+
}),
|
|
204
|
+
);
|
|
205
|
+
return { closed, active };
|
|
206
|
+
}
|
|
168
207
|
export async function getRules() {
|
|
169
208
|
return request("/rules");
|
|
170
209
|
}
|
package/src/clash/checker.js
CHANGED
|
@@ -26,7 +26,7 @@ const HEALTHY_FILE = join(tmpdir(), "clash-healthy-nodes.json");
|
|
|
26
26
|
let _healthyFile = HEALTHY_FILE;
|
|
27
27
|
|
|
28
28
|
export const CONFIG = {
|
|
29
|
-
testUrl: "https://www.tiktok.com
|
|
29
|
+
testUrl: "https://www.tiktok.com/@tiktok",
|
|
30
30
|
proxyUrl: "http://127.0.0.1:7897",
|
|
31
31
|
apiPort: 9097,
|
|
32
32
|
groupName: "GLOBAL",
|
package/src/clash/daemon.js
CHANGED
|
@@ -20,9 +20,10 @@ function fmtCountries(v) {
|
|
|
20
20
|
return list.length ? list.join(",") : "无";
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
// failover
|
|
23
|
+
// failover 冷却期(默认 10 分钟,与候选集 delay 初测周期对齐;可经 opts.failoverCooldownMs 覆盖)
|
|
24
|
+
// 锚点 = 最近一次实际切换节点的时间(_lastSwitchAt):冷却期内探针健康不切换
|
|
24
25
|
const FAILOVER_COOLDOWN_MS = 600_000;
|
|
25
|
-
//
|
|
26
|
+
// 已弃用(2026-08-25 改为时间锚点方案):旧版"窗口内连报 N 次"计数,字段保留仅为兼容外部传参
|
|
26
27
|
const FAILOVER_ESCAPE_AFTER = 3;
|
|
27
28
|
|
|
28
29
|
// ══════════════════════════════════════════════
|
|
@@ -41,6 +42,10 @@ export class ClashDaemon {
|
|
|
41
42
|
opts.failoverEscapeAfter > 0
|
|
42
43
|
? opts.failoverEscapeAfter
|
|
43
44
|
: FAILOVER_ESCAPE_AFTER;
|
|
45
|
+
// 切换后断连策略:graceful=优雅断连(只断空闲连接,活跃放行),full=全断(紧急回退)
|
|
46
|
+
this.disconnectMode = opts.disconnectMode === "full" ? "full" : "graceful";
|
|
47
|
+
this.disconnectSampleMs =
|
|
48
|
+
opts.disconnectSampleMs > 0 ? opts.disconnectSampleMs : 1000;
|
|
44
49
|
this.dryRun = !!opts.dryRun;
|
|
45
50
|
// 控制端发现参数(CLI 显式值优先,内部再合并 ~/.tt-help.json)
|
|
46
51
|
this._discoverOpts = {
|
|
@@ -97,6 +102,36 @@ export class ClashDaemon {
|
|
|
97
102
|
c._switchHistory.set(nodeName, Date.now());
|
|
98
103
|
}
|
|
99
104
|
|
|
105
|
+
/** 记录最近一次实际切换节点的时间(failover/巡检/手动切换共用锚点,冷却期判断依据) */
|
|
106
|
+
_markSwitchTime(c) {
|
|
107
|
+
if (c) c._lastSwitchAt = Date.now();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 切换后断连(策略可配):
|
|
112
|
+
* graceful(默认)— 优雅断连:只关"空闲"连接(两次采样无流量增长),
|
|
113
|
+
* 活跃连接放行自然完成,不打断正在进行的请求;
|
|
114
|
+
* 失败回退全断,确保切换即时生效。
|
|
115
|
+
* full — 全量断连(旧行为,紧急回退)。
|
|
116
|
+
*/
|
|
117
|
+
async _disconnect(c) {
|
|
118
|
+
if (this.disconnectMode === "full") {
|
|
119
|
+
await api.closeAllConnections().catch(() => {});
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
const r = await api.closeIdleConnections({
|
|
124
|
+
sampleMs: this.disconnectSampleMs,
|
|
125
|
+
});
|
|
126
|
+
this._log(
|
|
127
|
+
`[${c.proxyPort}] 优雅断连: 空闲 ${r.closed} 断 / 活跃 ${r.active} 放行`,
|
|
128
|
+
);
|
|
129
|
+
} catch (e) {
|
|
130
|
+
this._log(`[${c.proxyPort}] 优雅断连失败,回退全断: ${e.message}`);
|
|
131
|
+
await api.closeAllConnections().catch(() => {});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
100
135
|
/**
|
|
101
136
|
* 壳页判定:TikTok SSR 降级响应(HTTP 200 + 无跳转 + 无 error + 无 title)。
|
|
102
137
|
* 壳页 = 加载页(data-downgrade 骨架屏),链路正常、浏览器会自行渲染,不算节点故障。
|
|
@@ -428,7 +463,8 @@ export class ClashDaemon {
|
|
|
428
463
|
// 全部候选不可用,恢复原节点
|
|
429
464
|
await checker.switchToNode(node).catch(() => {});
|
|
430
465
|
this._markSwitch(c, node);
|
|
431
|
-
|
|
466
|
+
this._markSwitchTime(c);
|
|
467
|
+
await this._disconnect(c);
|
|
432
468
|
this._log(
|
|
433
469
|
`✗ [${c.proxyPort}] 恢复原节点 ${node}(原因: ${reason} — 候选全部不可用)`,
|
|
434
470
|
);
|
|
@@ -444,9 +480,10 @@ export class ClashDaemon {
|
|
|
444
480
|
this._log(
|
|
445
481
|
`⚠ [${c.proxyPort}] 节点切换 [巡检异常: ${reason}]: ${node} → ${target} (${h2.timeTotal.toFixed(1)}s ${(h2.sizeDownload / 1024).toFixed(0)}KB)`,
|
|
446
482
|
);
|
|
447
|
-
//
|
|
448
|
-
//
|
|
449
|
-
await
|
|
483
|
+
// 切换后优雅断连:回收空闲 keep-alive 使新请求走新节点;活跃请求放行不打断
|
|
484
|
+
// 旧连接若不回收会继续走旧节点导致"时好时坏"(实测可存活 8+ 分钟)
|
|
485
|
+
await this._disconnect(c);
|
|
486
|
+
this._markSwitchTime(c);
|
|
450
487
|
this._writeHealth(c, {
|
|
451
488
|
node: target,
|
|
452
489
|
healthy: h2.healthy,
|
|
@@ -585,9 +622,10 @@ export class ClashDaemon {
|
|
|
585
622
|
});
|
|
586
623
|
}
|
|
587
624
|
this._markSwitch(c, cmd.node);
|
|
625
|
+
this._markSwitchTime(c);
|
|
588
626
|
const h = checker.checkNodeHealth();
|
|
589
|
-
//
|
|
590
|
-
await
|
|
627
|
+
// 切换后优雅断连:回收空闲连接,活跃放行(同巡检切换)
|
|
628
|
+
await this._disconnect(c);
|
|
591
629
|
this._log(
|
|
592
630
|
`🔄 看门狗指令: [${c.proxyPort}] 切换完成 (${h.timeTotal.toFixed(1)}s ${(h.sizeDownload / 1024).toFixed(0)}KB, ${h.healthy ? "健康" : "异常"})`,
|
|
593
631
|
);
|
|
@@ -609,40 +647,36 @@ export class ClashDaemon {
|
|
|
609
647
|
* 看门狗指令: 主动故障切换(不依赖巡检发现,强制从候选集切下一个可用节点)
|
|
610
648
|
* 用于业务进程连续报错(CDN限流/网络异常/封 IP 等)时换出口节点
|
|
611
649
|
*
|
|
612
|
-
* failover
|
|
650
|
+
* failover 处理(2026-08-25,改为时间锚点方案):
|
|
613
651
|
* 1. 每次 failover 先检查当前节点(消除冷却期外盲切);
|
|
614
|
-
* 2. 探针不健康 →
|
|
615
|
-
* 3.
|
|
616
|
-
*
|
|
617
|
-
* TikTok
|
|
618
|
-
*
|
|
619
|
-
*
|
|
652
|
+
* 2. 探针不健康 → 立即切换(有依据才切,无视冷却);
|
|
653
|
+
* 3. 探针健康(含壳页)→ 距上次实际切换(含巡检/手动/failover,见
|
|
654
|
+
* _lastSwitchAt)< failoverCooldownMs(默认 10 分钟)→ 仅检查不切换;
|
|
655
|
+
* 距上次切换 >= 冷却期 → 视为业务级封禁(curl 探针感知不到 TikTok 业务
|
|
656
|
+
* 风控)→ 按轮换逻辑切一次。
|
|
657
|
+
* 每次实际切换(成功采用候选或恢复原节点)后更新 _lastSwitchAt 锚点,保证
|
|
658
|
+
* 10 分钟内最多切 1 次,给新节点干净观察期。
|
|
620
659
|
* 按控制端独立记录(多控制端互不影响);对外接口(命令动作/IPC/health)不变。
|
|
621
660
|
*/
|
|
622
661
|
async _handleFailover(cmd, c) {
|
|
623
662
|
try {
|
|
624
663
|
const reason = (cmd && cmd.reason) || "看门狗主动故障切换";
|
|
625
664
|
const now = Date.now();
|
|
626
|
-
const w =
|
|
627
|
-
c._failoverWindow || (c._failoverWindow = { startAt: 0, count: 0 });
|
|
628
|
-
if (now - w.startAt >= this.failoverCooldownMs) {
|
|
629
|
-
w.startAt = now;
|
|
630
|
-
w.count = 0;
|
|
631
|
-
}
|
|
632
|
-
w.count += 1;
|
|
633
665
|
|
|
634
666
|
// 第 1 级:先检查当前节点(壳页 = 链路正常,按健康对待,与巡检口径一致)
|
|
635
667
|
const h = checker.checkNodeHealth();
|
|
636
668
|
const probeOk = !!h && (h.healthy || this._isShellPage(h));
|
|
637
|
-
if (probeOk && w.count < this.failoverEscapeAfter) {
|
|
638
|
-
this._log(
|
|
639
|
-
`🔄 [${c.proxyPort}] 探针健康,窗口内第 ${w.count}/${this.failoverEscapeAfter} 次 failover 仅检查不切换 [${reason}]`,
|
|
640
|
-
);
|
|
641
|
-
return;
|
|
642
|
-
}
|
|
643
669
|
if (probeOk) {
|
|
670
|
+
// 探针健康:受冷却期约束(锚点 = 最近一次实际切换节点的时间 _lastSwitchAt)
|
|
671
|
+
const gap = now - (c._lastSwitchAt || 0);
|
|
672
|
+
if (gap < this.failoverCooldownMs) {
|
|
673
|
+
this._log(
|
|
674
|
+
`🔄 [${c.proxyPort}] 探针健康,距上次切换 ${Math.round(gap / 1000)}s(<${this.failoverCooldownMs / 60000}min 冷却),仅检查不切换 [${reason}]`,
|
|
675
|
+
);
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
644
678
|
this._log(
|
|
645
|
-
`🔄 [${c.proxyPort}]
|
|
679
|
+
`🔄 [${c.proxyPort}] 探针健康但业务持续报错,距上次切换已过冷却期,按业务级封禁轮换 [${reason}]`,
|
|
646
680
|
);
|
|
647
681
|
} else {
|
|
648
682
|
this._log(
|
|
@@ -658,11 +692,15 @@ export class ClashDaemon {
|
|
|
658
692
|
);
|
|
659
693
|
const { nodes } = checker.loadHealthyList();
|
|
660
694
|
if (!nodes.length) {
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
695
|
+
const gap = now - (c._lastProbeAt || 0);
|
|
696
|
+
if (gap >= this.benchmarkMs) {
|
|
697
|
+
this._log("🔄 候选集为空,触发 delay 初测");
|
|
698
|
+
await this._runDelayProbeController(c);
|
|
699
|
+
} else {
|
|
700
|
+
this._log(
|
|
701
|
+
`🔄 候选集为空(初测冷却中 ${Math.round((this.benchmarkMs - gap) / 1000)}s)`,
|
|
702
|
+
);
|
|
703
|
+
}
|
|
666
704
|
return;
|
|
667
705
|
}
|
|
668
706
|
// 逐个切换候选 + 内容验证,第一个满足即采用(跳过当前节点)
|
|
@@ -685,14 +723,13 @@ export class ClashDaemon {
|
|
|
685
723
|
}
|
|
686
724
|
this._log(` ↺ ${cand} 内容验证未通过,下一个`);
|
|
687
725
|
}
|
|
688
|
-
// 本次 failover
|
|
689
|
-
|
|
690
|
-
w.count = 0;
|
|
726
|
+
// 本次 failover 已尽力处置(成功切换或恢复原节点):更新锚点,给新节点干净观察期
|
|
727
|
+
this._markSwitchTime(c);
|
|
691
728
|
|
|
692
729
|
if (!target) {
|
|
693
730
|
await checker.switchToNode(node).catch(() => {});
|
|
694
731
|
this._markSwitch(c, node);
|
|
695
|
-
await
|
|
732
|
+
await this._disconnect(c);
|
|
696
733
|
this._log(
|
|
697
734
|
`✗ failover [${reason}] — 候选全部不可用,恢复原节点 ${node}`,
|
|
698
735
|
);
|
|
@@ -704,8 +741,8 @@ export class ClashDaemon {
|
|
|
704
741
|
});
|
|
705
742
|
}
|
|
706
743
|
this._log(`⚠ 节点切换 [${reason}]: ${node} → ${target}`);
|
|
707
|
-
//
|
|
708
|
-
await
|
|
744
|
+
// 切换后优雅断连:回收空闲连接,活跃放行(同巡检切换)
|
|
745
|
+
await this._disconnect(c);
|
|
709
746
|
this._writeHealth(c, {
|
|
710
747
|
node: target,
|
|
711
748
|
healthy: h2.healthy,
|
|
@@ -833,5 +870,7 @@ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
|
|
833
870
|
failoverCooldownMs: parseInt(getArg("--failover-cooldown-ms")) || undefined,
|
|
834
871
|
failoverEscapeAfter:
|
|
835
872
|
parseInt(getArg("--failover-escape-after")) || undefined,
|
|
873
|
+
disconnectMode: getArg("--disconnect-mode") || undefined,
|
|
874
|
+
disconnectSampleMs: parseInt(getArg("--disconnect-sample-ms")) || undefined,
|
|
836
875
|
}).start();
|
|
837
876
|
}
|
package/src/watch/db-stats.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { getDb } from "./db-schema.js";
|
|
8
|
-
import { getRawJobsCount
|
|
8
|
+
import { getRawJobsCount } from "./db-crud.js";
|
|
9
9
|
|
|
10
10
|
export function getDashboardStatsFromDb(targetLocations = [], options = {}) {
|
|
11
11
|
const db = getDb();
|
|
@@ -126,15 +126,15 @@ export function getDashboardStatsFromDb(targetLocations = [], options = {}) {
|
|
|
126
126
|
)
|
|
127
127
|
.get().total;
|
|
128
128
|
|
|
129
|
+
// 注:dbTotalUsers(COUNT(*) FROM users)已移除 ——
|
|
130
|
+
// users 表 3116 万行全表扫约 3s,且仪表盘前端未使用该字段
|
|
129
131
|
const rawJobsCount = skipSlow ? 0 : getRawJobsCount();
|
|
130
|
-
const dbUsersCount = skipSlow ? 0 : getUserDbCount();
|
|
131
132
|
|
|
132
133
|
return {
|
|
133
134
|
totalUsers: aggregateRow.total,
|
|
134
135
|
tagCount,
|
|
135
136
|
commentTaskCount,
|
|
136
137
|
rawJobs: rawJobsCount,
|
|
137
|
-
dbTotalUsers: dbUsersCount,
|
|
138
138
|
jobsTotal: aggregateRow.total,
|
|
139
139
|
jobsBaseTotal: jobsBaseCount,
|
|
140
140
|
jobsPending: aggregateRow.pending,
|
package/src/watch/server.js
CHANGED
|
@@ -26,61 +26,59 @@ function getLocalIP() {
|
|
|
26
26
|
const __dirname = dirname(__filename);
|
|
27
27
|
const publicDir = join(__dirname, "public");
|
|
28
28
|
|
|
29
|
-
// Stats
|
|
29
|
+
// Stats 缓存:避免每次请求都扫描百万行级大表数据
|
|
30
30
|
let statsCache = null;
|
|
31
31
|
let statsCacheTime = 0;
|
|
32
|
-
|
|
32
|
+
// 5 分钟缓存:users 表 3116 万行,COUNT 全表扫约 3s;jobs 162 万行,聚合约 2s。
|
|
33
|
+
// 原 15 秒 TTL 导致每 15 秒一次同步大查询,冻结事件循环 5~30s(成簇超时的根因)
|
|
34
|
+
const STATS_CACHE_TTL = 300000; // 5 分钟
|
|
33
35
|
|
|
34
36
|
// 用户列表短缓存:同一参数组合 3 秒内复用,无论多少客户端同时查只算一次
|
|
35
37
|
const usersCache = new Map(); // key -> { result, time }
|
|
36
38
|
const USERS_CACHE_TTL = 3000; // 3 秒
|
|
37
39
|
|
|
38
|
-
//
|
|
40
|
+
// 慢计数查询(分片执行)。
|
|
41
|
+
// 注:dbTotalUsers(COUNT(*) FROM users)已移除 —— users 表 3116 万行全表扫约 3s,
|
|
42
|
+
// 且该字段前端未使用;users 表为判重索引,数据不能删,只能从统计里去掉。
|
|
39
43
|
const SLOW_COUNT_SQLS = {
|
|
40
44
|
rawJobs: "SELECT COUNT(*) as c FROM raw_jobs",
|
|
41
45
|
commentTaskCount:
|
|
42
46
|
"SELECT COUNT(*) as c FROM videos WHERE user_update_count IS NULL OR user_update_count <= 0",
|
|
43
|
-
dbTotalUsers: "SELECT COUNT(*) as c FROM users",
|
|
44
47
|
tagCount: "SELECT COUNT(*) as c FROM tags",
|
|
45
48
|
tagHighScore: "SELECT COUNT(*) as c FROM tags WHERE score >= 70",
|
|
46
49
|
};
|
|
47
50
|
|
|
48
|
-
function computeSlowCounts(st) {
|
|
49
|
-
const result = {};
|
|
50
|
-
for (const [key, sql] of Object.entries(SLOW_COUNT_SQLS)) {
|
|
51
|
-
try {
|
|
52
|
-
const r = st.rawQuery(sql);
|
|
53
|
-
result[key] = r?.rows?.[0]?.c || 0;
|
|
54
|
-
} catch (e) {
|
|
55
|
-
console.error(`[stats] 慢计数 ${key} 查询失败: ${e.message}`);
|
|
56
|
-
result[key] = 0;
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
return result;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
51
|
let statsRefreshing = false; // 防止并发刷新
|
|
63
52
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
53
|
+
// 分片执行统计刷新:每个 SQL 单独一片,片间 setImmediate 让出事件循环,
|
|
54
|
+
// 避免多个大查询连续执行冻结事件循环 5~30 秒(此前成簇超时的直接原因)
|
|
55
|
+
function refreshStatsInShards(st) {
|
|
56
|
+
const slowKeys = Object.keys(SLOW_COUNT_SQLS);
|
|
57
|
+
const slowCounts = {};
|
|
58
|
+
|
|
59
|
+
function runSlowShard(i) {
|
|
60
|
+
if (i < slowKeys.length) {
|
|
61
|
+
const key = slowKeys[i];
|
|
62
|
+
try {
|
|
63
|
+
const r = st.rawQuery(SLOW_COUNT_SQLS[key]);
|
|
64
|
+
slowCounts[key] = r?.rows?.[0]?.c || 0;
|
|
65
|
+
} catch (e) {
|
|
66
|
+
console.error(`[stats] 慢计数 ${key} 查询失败: ${e.message}`);
|
|
67
|
+
slowCounts[key] = 0;
|
|
68
|
+
}
|
|
69
|
+
setImmediate(() => runSlowShard(i + 1));
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
// 慢计数全部完成 → dashboard 聚合作为最后一片执行
|
|
73
|
+
setImmediate(() => {
|
|
74
74
|
try {
|
|
75
|
-
const slowCounts = computeSlowCounts(st);
|
|
76
75
|
const stats = st.getDashboardStats(DEFAULT_TARGET_LOCATIONS, {
|
|
77
76
|
skipSlowCounts: true,
|
|
78
77
|
});
|
|
79
|
-
stats.rawJobs = slowCounts.rawJobs;
|
|
80
|
-
stats.commentTaskCount = slowCounts.commentTaskCount;
|
|
81
|
-
stats.
|
|
82
|
-
stats.
|
|
83
|
-
stats.tagHighScore = slowCounts.tagHighScore;
|
|
78
|
+
stats.rawJobs = slowCounts.rawJobs || 0;
|
|
79
|
+
stats.commentTaskCount = slowCounts.commentTaskCount || 0;
|
|
80
|
+
stats.tagCount = slowCounts.tagCount || 0;
|
|
81
|
+
stats.tagHighScore = slowCounts.tagHighScore || 0;
|
|
84
82
|
statsCache = stats;
|
|
85
83
|
statsCacheTime = Date.now();
|
|
86
84
|
} catch (e) {
|
|
@@ -89,21 +87,35 @@ function computeStatsIncremental(st) {
|
|
|
89
87
|
statsRefreshing = false;
|
|
90
88
|
}
|
|
91
89
|
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
setImmediate(() => runSlowShard(0));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function computeStatsIncremental(st) {
|
|
96
|
+
const now = Date.now();
|
|
97
|
+
// 缓存有效 → 直接返回
|
|
98
|
+
if (statsCache && now - statsCacheTime < STATS_CACHE_TTL) {
|
|
99
|
+
return statsCache;
|
|
100
|
+
}
|
|
101
|
+
// 缓存过期但存在 → 先返回旧数据,后台分片刷新
|
|
102
|
+
if (statsCache && !statsRefreshing) {
|
|
103
|
+
statsRefreshing = true;
|
|
104
|
+
refreshStatsInShards(st);
|
|
92
105
|
return statsCache;
|
|
93
106
|
}
|
|
94
|
-
// 首次加载 → 同步计算
|
|
95
|
-
|
|
107
|
+
// 首次加载 → 同步计算 dashboard 基础统计(启动后仅一次),
|
|
108
|
+
// 慢计数随后台分片补全,不阻塞本次响应之外的请求
|
|
96
109
|
const stats = st.getDashboardStats(DEFAULT_TARGET_LOCATIONS, {
|
|
97
110
|
skipSlowCounts: true,
|
|
98
111
|
});
|
|
99
|
-
stats.rawJobs = slowCounts.rawJobs;
|
|
100
|
-
stats.commentTaskCount = slowCounts.commentTaskCount;
|
|
101
|
-
stats.dbTotalUsers = slowCounts.dbTotalUsers;
|
|
102
|
-
stats.tagCount = slowCounts.tagCount;
|
|
103
|
-
stats.tagHighScore = slowCounts.tagHighScore;
|
|
104
112
|
statsCache = stats;
|
|
105
113
|
statsCacheTime = now;
|
|
106
|
-
|
|
114
|
+
if (!statsRefreshing) {
|
|
115
|
+
statsRefreshing = true;
|
|
116
|
+
refreshStatsInShards(st);
|
|
117
|
+
}
|
|
118
|
+
return statsCache;
|
|
107
119
|
}
|
|
108
120
|
|
|
109
121
|
function readBody(req) {
|
package/src/watchdog/agent.js
CHANGED
|
@@ -251,6 +251,11 @@ export class WatchdogAgent {
|
|
|
251
251
|
this._graceEnded = false; // 宽限期是否已结束
|
|
252
252
|
// 业务日志错误统计 → 触发节点切换
|
|
253
253
|
this._errorStates = new Map(); // cmdId -> {consecutive, firstErrorAt, lastErrorAt}
|
|
254
|
+
// 进程(重新)启动 → 重置该命令的错误计数(新进程实例从干净观察窗口开始,
|
|
255
|
+
// 避免把上一个进程实例的错误延续到新实例而误触发节点切换)
|
|
256
|
+
this.processManager.on("process-started", ({ cmdId }) => {
|
|
257
|
+
this._errorStates.delete(cmdId);
|
|
258
|
+
});
|
|
254
259
|
this._errorTriggerTimer = null;
|
|
255
260
|
this._errorTriggerEnabled = false; // 默认不启用(后端下发开启才生效)
|
|
256
261
|
this._errorTriggerCount = ERROR_TRIGGER_COUNT;
|
|
@@ -377,6 +377,8 @@ export class ProcessManager extends EventEmitter {
|
|
|
377
377
|
logs: [],
|
|
378
378
|
lastLogTime: Date.now(), // 初始化为启动时间,用于假死检测
|
|
379
379
|
});
|
|
380
|
+
// 通知上层:进程(重新)启动,可重置该命令的跨实例状态(如错误计数)
|
|
381
|
+
this.emit("process-started", { cmdId: id, cmdDef });
|
|
380
382
|
}
|
|
381
383
|
|
|
382
384
|
/**
|
|
@@ -420,7 +422,6 @@ export class ProcessManager extends EventEmitter {
|
|
|
420
422
|
} catch {}
|
|
421
423
|
}, 2000);
|
|
422
424
|
}
|
|
423
|
-
|
|
424
425
|
} catch (e) {
|
|
425
426
|
console.error(`[watchdog] 停止命令 ${cmdId} 失败: ${e.message}`);
|
|
426
427
|
}
|
|
@@ -487,7 +488,9 @@ export class ProcessManager extends EventEmitter {
|
|
|
487
488
|
const result = [];
|
|
488
489
|
for (const [cmdId, info] of this.processes) {
|
|
489
490
|
if (info.stopped || !info.logs || info.logs.length === 0) continue;
|
|
490
|
-
// 日志可能被 LLM
|
|
491
|
+
// 日志可能被 LLM 分析清空过(_errSeen 未同步):长度小于偏移说明
|
|
492
|
+
// 是清空后重建的日志,重置偏移,确保新日志从头扫描不遗漏
|
|
493
|
+
if ((info._errSeen || 0) > info.logs.length) info._errSeen = 0;
|
|
491
494
|
const seen = Math.min(info._errSeen || 0, info.logs.length);
|
|
492
495
|
const newLines = info.logs.slice(seen);
|
|
493
496
|
info._errSeen = info.logs.length;
|
|
@@ -515,6 +518,9 @@ export class ProcessManager extends EventEmitter {
|
|
|
515
518
|
});
|
|
516
519
|
// 清空已读取的日志,避免重复分析
|
|
517
520
|
info.logs = [];
|
|
521
|
+
// 同步重置增量扫描偏移:否则 scanErrorLogs 会把清空后新日志
|
|
522
|
+
// 误判为"已扫描"(logs.length < _errSeen 时 slice 到空),造成错误漏统计
|
|
523
|
+
info._errSeen = 0;
|
|
518
524
|
}
|
|
519
525
|
return result;
|
|
520
526
|
}
|