tt-help-cli-ycl 1.4.128 → 1.4.131
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 +197 -0
- package/scripts/verify-kill-edge.mjs +108 -0
- package/scripts/verify-stop-command.mjs +159 -0
- package/src/clash/daemon.js +52 -20
- package/src/cli/attach.js +6 -1
- package/src/cli/precheck.js +17 -10
- package/src/watchdog/agent.js +11 -0
- package/src/watchdog/process-manager.js +29 -13
package/package.json
CHANGED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* daemon _handleFailover 三级响应单测(2026-08-22,无需 Clash 运行)
|
|
3
|
+
*
|
|
4
|
+
* 验证:
|
|
5
|
+
* - 探针健康 → 窗口内 failover 仅检查不切换(前 N-1 次)
|
|
6
|
+
* - 探针不健康 → 立即切换(无视窗口)
|
|
7
|
+
* - 壳页 → 视为链路正常,仅检查不切换(与巡检口径一致)
|
|
8
|
+
* - 窗口过期 → 计数重置
|
|
9
|
+
* - 窗口内到达 failoverEscapeAfter 次 → 按业务级封禁轮换一次
|
|
10
|
+
* - 实际切换后 → 窗口与计数重置
|
|
11
|
+
* - escapeAfter 可配置
|
|
12
|
+
* - 候选集为空 → 触发 delay 初测且不切换
|
|
13
|
+
*
|
|
14
|
+
* 运行: node --test scripts/test-daemon-failover.mjs
|
|
15
|
+
* (若当前 Node 版本 module mock 仍需 flag: --experimental-test-module-mocks)
|
|
16
|
+
*/
|
|
17
|
+
import { test, mock, before } from "node:test";
|
|
18
|
+
import assert from "node:assert/strict";
|
|
19
|
+
|
|
20
|
+
// ── 可配置假依赖状态 ──
|
|
21
|
+
const state = {
|
|
22
|
+
health: {
|
|
23
|
+
healthy: true,
|
|
24
|
+
httpCode: 200,
|
|
25
|
+
timeTotal: 1.2,
|
|
26
|
+
sizeDownload: 100000,
|
|
27
|
+
numRedirects: 0,
|
|
28
|
+
title: "TikTok - Make Your Day",
|
|
29
|
+
error: null,
|
|
30
|
+
},
|
|
31
|
+
nodes: ["🇭🇰 香港01", "🇲🇾 马来02"],
|
|
32
|
+
currentNode: "🇭🇰 香港01",
|
|
33
|
+
switches: [],
|
|
34
|
+
closes: 0,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function resetState() {
|
|
38
|
+
state.health = {
|
|
39
|
+
healthy: true,
|
|
40
|
+
httpCode: 200,
|
|
41
|
+
timeTotal: 1.2,
|
|
42
|
+
sizeDownload: 100000,
|
|
43
|
+
numRedirects: 0,
|
|
44
|
+
title: "TikTok - Make Your Day",
|
|
45
|
+
error: null,
|
|
46
|
+
};
|
|
47
|
+
state.nodes = ["🇭🇰 香港01", "🇲🇾 马来02"];
|
|
48
|
+
state.currentNode = "🇭🇰 香港01";
|
|
49
|
+
state.switches = [];
|
|
50
|
+
state.closes = 0;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ── 注册模块 mock(必须在 import daemon.js 之前) ──
|
|
54
|
+
before(async () => {
|
|
55
|
+
mock.module("../src/clash/checker.js", {
|
|
56
|
+
namedExports: {
|
|
57
|
+
normalizeCountries: (v) => (Array.isArray(v) ? [...v] : v ? [v] : []),
|
|
58
|
+
CONFIG: {
|
|
59
|
+
groupName: "GLOBAL",
|
|
60
|
+
testUrl: "https://www.tiktok.com/explore",
|
|
61
|
+
proxyUrl: "http://127.0.0.1:7897",
|
|
62
|
+
nodeCountryFilter: null,
|
|
63
|
+
},
|
|
64
|
+
checkNodeHealth: () => ({ ...state.health }),
|
|
65
|
+
loadHealthyList: () => ({ nodes: [...state.nodes] }),
|
|
66
|
+
switchToNode: async (n) => {
|
|
67
|
+
state.switches.push(n);
|
|
68
|
+
state.currentNode = n;
|
|
69
|
+
// 模拟真实世界:故障属于原节点,切换后的新节点健康(候选内容验证通过)
|
|
70
|
+
state.health = { ...state.health, healthy: true, error: null };
|
|
71
|
+
return true;
|
|
72
|
+
},
|
|
73
|
+
applyDiscovered: () => {},
|
|
74
|
+
runDelayProbe: async () => ({
|
|
75
|
+
nodes: [...state.nodes],
|
|
76
|
+
total: state.nodes.length,
|
|
77
|
+
}),
|
|
78
|
+
saveHealthyList: () => {},
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
mock.module("../src/clash/api.js", {
|
|
82
|
+
namedExports: {
|
|
83
|
+
configure: () => {},
|
|
84
|
+
getCurrentNode: async () => state.currentNode,
|
|
85
|
+
closeAllConnections: async () => {
|
|
86
|
+
state.closes += 1;
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
mock.module("../src/clash/ipc.js", {
|
|
91
|
+
namedExports: {
|
|
92
|
+
writeHealth: () => {},
|
|
93
|
+
listCommandChannels: () => [],
|
|
94
|
+
readCommand: () => null,
|
|
95
|
+
clearCommand: () => {},
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
mock.module("../src/clash/discover.js", {
|
|
99
|
+
namedExports: {
|
|
100
|
+
discoverAll: async () => [],
|
|
101
|
+
invalidateDiscoverCache: () => {},
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
({ ClashDaemon } = await import("../src/clash/daemon.js"));
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
let ClashDaemon = null;
|
|
108
|
+
|
|
109
|
+
function makeCtx(opts = {}) {
|
|
110
|
+
const d = new ClashDaemon({
|
|
111
|
+
failoverCooldownMs: 600000,
|
|
112
|
+
failoverEscapeAfter: 3,
|
|
113
|
+
...opts,
|
|
114
|
+
});
|
|
115
|
+
const c = { proxyPort: 7897 };
|
|
116
|
+
return { d, c };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
test("F1 探针健康:前 2 次 failover 仅检查不切换,第 3 次轮换", async () => {
|
|
120
|
+
resetState();
|
|
121
|
+
const { d, c } = makeCtx();
|
|
122
|
+
await d._handleFailover({ reason: "F1-1" }, c);
|
|
123
|
+
await d._handleFailover({ reason: "F1-2" }, c);
|
|
124
|
+
assert.equal(state.switches.length, 0, "前 2 次不应切换");
|
|
125
|
+
assert.equal(state.closes, 0, "前 2 次不应断开连接");
|
|
126
|
+
await d._handleFailover({ reason: "F1-3" }, c);
|
|
127
|
+
assert.deepEqual(state.switches, ["🇲🇾 马来02"], "第 3 次按轮换切换到候选");
|
|
128
|
+
assert.equal(state.closes, 1, "切换后应断开旧连接");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("F2 探针不健康:立即切换(无视窗口)", async () => {
|
|
132
|
+
resetState();
|
|
133
|
+
const { d, c } = makeCtx();
|
|
134
|
+
state.health = { ...state.health, healthy: false, error: "timeout" };
|
|
135
|
+
await d._handleFailover({ reason: "F2" }, c);
|
|
136
|
+
assert.deepEqual(state.switches, ["🇲🇾 马来02"], "不健康应立即切换");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("F3 壳页视为链路正常:仅检查不切换", async () => {
|
|
140
|
+
resetState();
|
|
141
|
+
const { d, c } = makeCtx();
|
|
142
|
+
// 壳页签名:200 + 无跳转 + 无 error + 无 title
|
|
143
|
+
state.health = {
|
|
144
|
+
healthy: false,
|
|
145
|
+
httpCode: 200,
|
|
146
|
+
timeTotal: 1.0,
|
|
147
|
+
sizeDownload: 43000,
|
|
148
|
+
numRedirects: 0,
|
|
149
|
+
title: "",
|
|
150
|
+
error: null,
|
|
151
|
+
};
|
|
152
|
+
await d._handleFailover({ reason: "F3" }, c);
|
|
153
|
+
assert.equal(state.switches.length, 0, "壳页不应触发切换");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("F4 窗口过期:计数重置,不累计到逃生阈值", async () => {
|
|
157
|
+
resetState();
|
|
158
|
+
const { d, c } = makeCtx({ failoverCooldownMs: 150 });
|
|
159
|
+
await d._handleFailover({ reason: "F4-1" }, c); // count=1
|
|
160
|
+
await new Promise((r) => setTimeout(r, 200)); // 窗口过期
|
|
161
|
+
await d._handleFailover({ reason: "F4-2" }, c); // 重置后 count=1
|
|
162
|
+
await d._handleFailover({ reason: "F4-3" }, c); // count=2
|
|
163
|
+
assert.equal(state.switches.length, 0, "窗口过期后不应累计旧计数");
|
|
164
|
+
await d._handleFailover({ reason: "F4-4" }, c); // count=3 → 轮换
|
|
165
|
+
assert.equal(state.switches.length, 1, "第 3 次才应轮换");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("F5 切换后窗口重置:重新从仅检查开始", async () => {
|
|
169
|
+
resetState();
|
|
170
|
+
const { d, c } = makeCtx();
|
|
171
|
+
await d._handleFailover({ reason: "F5-1" }, c); // skip
|
|
172
|
+
await d._handleFailover({ reason: "F5-2" }, c); // skip
|
|
173
|
+
await d._handleFailover({ reason: "F5-3" }, c); // switch
|
|
174
|
+
assert.equal(state.switches.length, 1, "第 3 次应切换");
|
|
175
|
+
await d._handleFailover({ reason: "F5-4" }, c); // 重置后 count=1 → skip
|
|
176
|
+
assert.equal(state.switches.length, 1, "切换后应重置窗口,不连续切换");
|
|
177
|
+
await d._handleFailover({ reason: "F5-5" }, c); // count=2 → skip
|
|
178
|
+
await d._handleFailover({ reason: "F5-6" }, c); // count=3 → switch
|
|
179
|
+
assert.equal(state.switches.length, 2, "第 6 次才应再次轮换");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("F6 escapeAfter 可配置:=1 时首次健康 failover 即轮换", async () => {
|
|
183
|
+
resetState();
|
|
184
|
+
const { d, c } = makeCtx({ failoverEscapeAfter: 1 });
|
|
185
|
+
await d._handleFailover({ reason: "F6" }, c);
|
|
186
|
+
assert.equal(state.switches.length, 1, "escapeAfter=1 应首次即切换");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("F7 候选集为空:触发 delay 初测且不切换", async () => {
|
|
190
|
+
resetState();
|
|
191
|
+
const { d, c } = makeCtx();
|
|
192
|
+
state.health = { ...state.health, healthy: false, error: "timeout" };
|
|
193
|
+
state.nodes = [];
|
|
194
|
+
await d._handleFailover({ reason: "F7" }, c);
|
|
195
|
+
assert.equal(state.switches.length, 0, "无候选不应切换");
|
|
196
|
+
assert.ok(c._lastProbeAt, "应触发过 delay 初测");
|
|
197
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// verify-kill-edge.mjs — 在 Windows 上端到端验证 killEdgeProcesses(netstat 方案)
|
|
2
|
+
// 场景模拟:业务开一个带 --remote-debugging-port 的 Edge 实例,
|
|
3
|
+
// 看门狗关闭时调用 killEdgeProcesses(null, port) 杀浏览器。
|
|
4
|
+
// 安全:只用独立端口 19222 + 独立 user-data-dir,不碰机器上其他 Edge。
|
|
5
|
+
import { exec, execSync } from "node:child_process";
|
|
6
|
+
|
|
7
|
+
const PKG =
|
|
8
|
+
process.env.APPDATA.replace(/\\/g, "/") +
|
|
9
|
+
"/npm/node_modules/tt-help-cli-ycl";
|
|
10
|
+
const { killEdgeProcesses, parseNetstatListeningPids } = await import(
|
|
11
|
+
"file:///" + PKG + "/src/lib/browser/cdp.js"
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
const PORT = 19222;
|
|
15
|
+
const USER_DATA_DIR =
|
|
16
|
+
process.env.TEMP.replace(/\\/g, "/") + "/edge-test-" + PORT;
|
|
17
|
+
|
|
18
|
+
const delay = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
19
|
+
|
|
20
|
+
function netstatPids(port) {
|
|
21
|
+
const out = execSync("netstat -ano", {
|
|
22
|
+
encoding: "utf-8",
|
|
23
|
+
timeout: 5000,
|
|
24
|
+
windowsHide: true,
|
|
25
|
+
});
|
|
26
|
+
return parseNetstatListeningPids(out, port);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function cleanup() {
|
|
30
|
+
const pids = netstatPids(PORT);
|
|
31
|
+
for (const pid of pids) {
|
|
32
|
+
try {
|
|
33
|
+
execSync(`taskkill /F /T /PID ${pid}`, {
|
|
34
|
+
stdio: "ignore",
|
|
35
|
+
windowsHide: true,
|
|
36
|
+
});
|
|
37
|
+
} catch {}
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
execSync(`rd /s /q "${USER_DATA_DIR}"`, {
|
|
41
|
+
stdio: "ignore",
|
|
42
|
+
windowsHide: true,
|
|
43
|
+
});
|
|
44
|
+
} catch {}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function main() {
|
|
48
|
+
// [1] 解析器静态验证:用当前真实 netstat 输出
|
|
49
|
+
{
|
|
50
|
+
const out = execSync("netstat -ano", {
|
|
51
|
+
encoding: "utf-8",
|
|
52
|
+
timeout: 5000,
|
|
53
|
+
windowsHide: true,
|
|
54
|
+
});
|
|
55
|
+
const sample = out
|
|
56
|
+
.split(/\r?\n/)
|
|
57
|
+
.filter((l) => l.includes("LISTENING"))
|
|
58
|
+
.slice(0, 5);
|
|
59
|
+
console.log("[1] 当前真实 LISTENING 行:");
|
|
60
|
+
for (const l of sample) console.log(" " + l.trim());
|
|
61
|
+
const clash = parseNetstatListeningPids(out, 9097);
|
|
62
|
+
console.log("[1] 9097 解析 PID = " + JSON.stringify(clash));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// [2] 启动真实 Edge CDP 测试实例
|
|
66
|
+
console.log("[2] 启动 Edge CDP 测试实例 (port=" + PORT + ") ...");
|
|
67
|
+
exec(
|
|
68
|
+
`start "" msedge --remote-debugging-port=${PORT} --user-data-dir="${USER_DATA_DIR}" --no-first-run --no-default-browser-check --disable-background-mode --disable-component-update --disable-crash-reporter --disable-breakpad --disable-sync --disable-background-networking`,
|
|
69
|
+
{ windowsHide: true },
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
let readyPids = [];
|
|
73
|
+
for (let i = 0; i < 30; i++) {
|
|
74
|
+
await delay(1000);
|
|
75
|
+
readyPids = netstatPids(PORT);
|
|
76
|
+
if (readyPids.length > 0) break;
|
|
77
|
+
}
|
|
78
|
+
console.log("[2] 端口 " + PORT + " 监听 PID = " + JSON.stringify(readyPids));
|
|
79
|
+
if (readyPids.length === 0) {
|
|
80
|
+
console.log("RESULT: SETUP_FAIL(Edge 未监听端口)");
|
|
81
|
+
await cleanup();
|
|
82
|
+
process.exit(2);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// [3] 调用包内真实 killEdgeProcesses(与看门狗关闭时同一函数)
|
|
86
|
+
console.log("[3] 调用 killEdgeProcesses(null, " + PORT + ") ...");
|
|
87
|
+
const t0 = Date.now();
|
|
88
|
+
await killEdgeProcesses(null, PORT);
|
|
89
|
+
await delay(2000);
|
|
90
|
+
|
|
91
|
+
const after = netstatPids(PORT);
|
|
92
|
+
console.log(
|
|
93
|
+
"[3] 调用耗时 " +
|
|
94
|
+
(Date.now() - t0) +
|
|
95
|
+
"ms,杀后端口监听 PID = " +
|
|
96
|
+
JSON.stringify(after),
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
console.log(after.length === 0 ? "RESULT: PASS" : "RESULT: FAIL");
|
|
100
|
+
await cleanup();
|
|
101
|
+
process.exit(after.length === 0 ? 0 : 1);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
main().catch(async (e) => {
|
|
105
|
+
console.error("脚本异常: " + e.message);
|
|
106
|
+
await cleanup();
|
|
107
|
+
process.exit(3);
|
|
108
|
+
});
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// verify-stop-command.mjs — 211 上验证修复后的"看门狗关闭"完整时序
|
|
2
|
+
// 模拟:看门狗托管业务命令 refresh-mini --port 19223,业务启动 Edge CDP 实例。
|
|
3
|
+
// 修复后 stopCommand 顺序:先 killEdgeProcesses(端口) 杀浏览器 → 再 taskkill 业务进程树。
|
|
4
|
+
// 验证:Edge 进程树全灭(端口释放)+ 业务进程被杀。
|
|
5
|
+
import { execSync, spawn } from "node:child_process";
|
|
6
|
+
|
|
7
|
+
const PKG =
|
|
8
|
+
process.env.APPDATA.replace(/\\/g, "/") +
|
|
9
|
+
"/npm/node_modules/tt-help-cli-ycl";
|
|
10
|
+
// 包内真实函数(当前 1.4.128 的 netstat 实现)
|
|
11
|
+
const { killEdgeProcesses } = await import(
|
|
12
|
+
"file:///" + PKG + "/src/lib/browser/cdp.js"
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
const PORT = 19223;
|
|
16
|
+
const USER_DATA_DIR =
|
|
17
|
+
process.env.TEMP.replace(/\\/g, "/") + "/edge-test-" + PORT;
|
|
18
|
+
|
|
19
|
+
// 修复后的 extractCdpPorts(与本地 process-manager.js 修改一致)
|
|
20
|
+
const BROWSER_CMD_RE =
|
|
21
|
+
/\b(?:explore|auto|attach|scrape|comments|refresh|recheck|precheck|search|videos|videostats|open|tag|login-check)\b/;
|
|
22
|
+
function extractCdpPorts(cmdLine) {
|
|
23
|
+
const baseMatch = cmdLine.match(/--base-port\s+(\d+)/);
|
|
24
|
+
if (baseMatch) {
|
|
25
|
+
const base = parseInt(baseMatch[1], 10);
|
|
26
|
+
const countMatch = cmdLine.match(/--port-count\s+(\d+)/);
|
|
27
|
+
const count = countMatch ? parseInt(countMatch[1], 10) : 1;
|
|
28
|
+
const ports = [];
|
|
29
|
+
for (let i = 0; i < count; i++) ports.push(base + i);
|
|
30
|
+
return ports;
|
|
31
|
+
}
|
|
32
|
+
if (BROWSER_CMD_RE.test(cmdLine)) {
|
|
33
|
+
const m = cmdLine.match(/--port\s+(\d+)/);
|
|
34
|
+
if (m) return [parseInt(m[1], 10)];
|
|
35
|
+
}
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const delay = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
40
|
+
|
|
41
|
+
function listeningPids(port) {
|
|
42
|
+
const out = execSync("netstat -ano", {
|
|
43
|
+
encoding: "utf-8",
|
|
44
|
+
timeout: 5000,
|
|
45
|
+
windowsHide: true,
|
|
46
|
+
});
|
|
47
|
+
const pids = [];
|
|
48
|
+
for (const line of out.split(/\r?\n/)) {
|
|
49
|
+
if (!line.includes("LISTENING") || !line.includes(":" + port + " ")) continue;
|
|
50
|
+
const parts = line.trim().split(/\s+/);
|
|
51
|
+
const pid = parts[parts.length - 1];
|
|
52
|
+
if (/^\d+$/.test(pid) && !pids.includes(pid)) pids.push(pid);
|
|
53
|
+
}
|
|
54
|
+
return pids;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function pidAlive(pid) {
|
|
58
|
+
try {
|
|
59
|
+
const out = execSync(`tasklist /FI "PID eq ${pid}" /NH`, {
|
|
60
|
+
encoding: "utf-8",
|
|
61
|
+
timeout: 5000,
|
|
62
|
+
windowsHide: true,
|
|
63
|
+
});
|
|
64
|
+
// 解析输出文本判断(tasklist 无匹配时可能仍 exit 0,不能依赖退出码)
|
|
65
|
+
return new RegExp("\\b" + pid + "\\b").test(out);
|
|
66
|
+
} catch {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function cleanup() {
|
|
72
|
+
for (const pid of listeningPids(PORT)) {
|
|
73
|
+
try {
|
|
74
|
+
execSync(`taskkill /F /T /PID ${pid}`, {
|
|
75
|
+
stdio: "ignore",
|
|
76
|
+
windowsHide: true,
|
|
77
|
+
});
|
|
78
|
+
} catch {}
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
execSync(`rd /s /q "${USER_DATA_DIR}"`, {
|
|
82
|
+
stdio: "ignore",
|
|
83
|
+
windowsHide: true,
|
|
84
|
+
});
|
|
85
|
+
} catch {}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function main() {
|
|
89
|
+
const CMD_LINE = `tt-help refresh-mini --preset slow --port ${PORT} --rotate-min 0`;
|
|
90
|
+
|
|
91
|
+
// [1] 端口提取(修复后逻辑)
|
|
92
|
+
const ports = extractCdpPorts(CMD_LINE);
|
|
93
|
+
console.log("[1] extractCdpPorts(" + CMD_LINE + ") = " + JSON.stringify(ports));
|
|
94
|
+
if (JSON.stringify(ports) !== JSON.stringify([PORT])) {
|
|
95
|
+
console.log("RESULT: FAIL(端口提取不对)");
|
|
96
|
+
process.exit(1);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// [2] 启动业务进程(node 长驻占位)与 Edge CDP 实例(脱离进程树,模拟 start 启动)
|
|
100
|
+
const biz = spawn(
|
|
101
|
+
process.execPath,
|
|
102
|
+
["-e", "setInterval(()=>{},1000)"],
|
|
103
|
+
{ stdio: ["ignore", "ignore", "ignore"], windowsHide: true },
|
|
104
|
+
);
|
|
105
|
+
console.log("[2] 业务进程 PID = " + biz.pid);
|
|
106
|
+
|
|
107
|
+
execSync(
|
|
108
|
+
`start "" msedge --remote-debugging-port=${PORT} --user-data-dir="${USER_DATA_DIR}" --no-first-run --no-default-browser-check --disable-background-mode --disable-component-update --disable-crash-reporter --disable-breakpad --disable-sync --disable-background-networking`,
|
|
109
|
+
{ stdio: "ignore", windowsHide: true },
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
let edgePids = [];
|
|
113
|
+
for (let i = 0; i < 30; i++) {
|
|
114
|
+
await delay(1000);
|
|
115
|
+
edgePids = listeningPids(PORT);
|
|
116
|
+
if (edgePids.length > 0) break;
|
|
117
|
+
}
|
|
118
|
+
console.log("[2] Edge 监听 " + PORT + " PID = " + JSON.stringify(edgePids));
|
|
119
|
+
if (edgePids.length === 0) {
|
|
120
|
+
console.log("RESULT: SETUP_FAIL(Edge 未监听端口)");
|
|
121
|
+
await cleanup();
|
|
122
|
+
try { biz.kill(); } catch {}
|
|
123
|
+
process.exit(2);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// [3] 修复后的 stopCommand 顺序:先杀浏览器,再杀业务进程树
|
|
127
|
+
console.log("[3] 先 killEdgeProcesses(null, " + PORT + ") ...");
|
|
128
|
+
for (const port of ports) {
|
|
129
|
+
await killEdgeProcesses(null, port);
|
|
130
|
+
}
|
|
131
|
+
await delay(2000);
|
|
132
|
+
const edgeAfter = listeningPids(PORT);
|
|
133
|
+
console.log("[3] 杀浏览器后端口监听 = " + JSON.stringify(edgeAfter));
|
|
134
|
+
|
|
135
|
+
console.log("[3] 再 taskkill /F /T /PID " + biz.pid + " ...");
|
|
136
|
+
try {
|
|
137
|
+
execSync(`taskkill /F /T /PID ${biz.pid}`, {
|
|
138
|
+
stdio: "ignore",
|
|
139
|
+
timeout: 5000,
|
|
140
|
+
windowsHide: true,
|
|
141
|
+
});
|
|
142
|
+
} catch (e) {
|
|
143
|
+
console.log("[3] taskkill 业务返回: " + e.message);
|
|
144
|
+
}
|
|
145
|
+
await delay(3000);
|
|
146
|
+
const bizAlive = pidAlive(biz.pid);
|
|
147
|
+
console.log("[3] 业务进程存活: " + bizAlive);
|
|
148
|
+
|
|
149
|
+
const ok = edgeAfter.length === 0 && !bizAlive;
|
|
150
|
+
console.log(ok ? "RESULT: PASS" : "RESULT: FAIL");
|
|
151
|
+
await cleanup();
|
|
152
|
+
process.exit(ok ? 0 : 1);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
main().catch(async (e) => {
|
|
156
|
+
console.error("脚本异常: " + e.message);
|
|
157
|
+
await cleanup();
|
|
158
|
+
process.exit(3);
|
|
159
|
+
});
|
package/src/clash/daemon.js
CHANGED
|
@@ -20,8 +20,10 @@ function fmtCountries(v) {
|
|
|
20
20
|
return list.length ? list.join(",") : "无";
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
// failover
|
|
24
|
-
const FAILOVER_COOLDOWN_MS =
|
|
23
|
+
// failover 检查窗口(默认 10 分钟,与候选集 delay 初测周期对齐;可经 opts.failoverCooldownMs 覆盖)
|
|
24
|
+
const FAILOVER_COOLDOWN_MS = 600_000;
|
|
25
|
+
// 窗口内探针健康时的切换阈值(默认 3:前 2 次仅检查,第 3 次按业务级封禁轮换)
|
|
26
|
+
const FAILOVER_ESCAPE_AFTER = 3;
|
|
25
27
|
|
|
26
28
|
// ══════════════════════════════════════════════
|
|
27
29
|
// ClashDaemon
|
|
@@ -35,6 +37,10 @@ export class ClashDaemon {
|
|
|
35
37
|
opts.failoverCooldownMs > 0
|
|
36
38
|
? opts.failoverCooldownMs
|
|
37
39
|
: FAILOVER_COOLDOWN_MS;
|
|
40
|
+
this.failoverEscapeAfter =
|
|
41
|
+
opts.failoverEscapeAfter > 0
|
|
42
|
+
? opts.failoverEscapeAfter
|
|
43
|
+
: FAILOVER_ESCAPE_AFTER;
|
|
38
44
|
this.dryRun = !!opts.dryRun;
|
|
39
45
|
// 控制端发现参数(CLI 显式值优先,内部再合并 ~/.tt-help.json)
|
|
40
46
|
this._discoverOpts = {
|
|
@@ -602,32 +608,48 @@ export class ClashDaemon {
|
|
|
602
608
|
/**
|
|
603
609
|
* 看门狗指令: 主动故障切换(不依赖巡检发现,强制从候选集切下一个可用节点)
|
|
604
610
|
* 用于业务进程连续报错(CDN限流/网络异常/封 IP 等)时换出口节点
|
|
611
|
+
*
|
|
612
|
+
* failover 三级响应(2026-08-22,替代原 45s 冷却):
|
|
613
|
+
* 1. 每次 failover 先检查当前节点(消除冷却期外盲切);
|
|
614
|
+
* 2. 探针不健康 → 立即切换(有依据才切,无视窗口);
|
|
615
|
+
* 3. 探针健康 → 窗口内默认只检查不切换;同一窗口内 failover 到达
|
|
616
|
+
* failoverEscapeAfter 次(默认 3)→ 视为业务级封禁(curl 探针感知不到
|
|
617
|
+
* TikTok 业务风控)→ 按轮换逻辑切一次。
|
|
618
|
+
* 实际切换(成功或恢复原节点)或触发候选初测后重置窗口与计数,给新节点干净
|
|
619
|
+
* 观察窗口;窗口 = failoverCooldownMs(默认 10 分钟,与候选集初测周期对齐)。
|
|
620
|
+
* 按控制端独立记录(多控制端互不影响);对外接口(命令动作/IPC/health)不变。
|
|
605
621
|
*/
|
|
606
622
|
async _handleFailover(cmd, c) {
|
|
607
623
|
try {
|
|
608
|
-
|
|
609
|
-
// 冷却期内再次收到 failover:先验证当前节点健康,健康则跳过并续冷却
|
|
610
|
-
// (持续健康 → 持续不切,避免冷却期结束后又白切健康节点;同时过滤滞后/重连误报),
|
|
611
|
-
// 不健康才继续切换。按控制端独立记录冷却(多控制端互不影响);
|
|
612
|
-
// 对外接口(命令动作 / IPC / health 格式)保持不变。
|
|
624
|
+
const reason = (cmd && cmd.reason) || "看门狗主动故障切换";
|
|
613
625
|
const now = Date.now();
|
|
614
|
-
const
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
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
|
+
|
|
634
|
+
// 第 1 级:先检查当前节点(壳页 = 链路正常,按健康对待,与巡检口径一致)
|
|
635
|
+
const h = checker.checkNodeHealth();
|
|
636
|
+
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
|
+
if (probeOk) {
|
|
644
|
+
this._log(
|
|
645
|
+
`🔄 [${c.proxyPort}] 窗口内第 ${w.count} 次 failover:探针健康但业务持续报错,按业务级封禁轮换 [${reason}]`,
|
|
646
|
+
);
|
|
647
|
+
} else {
|
|
624
648
|
this._log(
|
|
625
|
-
`🔄
|
|
649
|
+
`🔄 [${c.proxyPort}] 探针不健康(${(h && (h.error || `HTTP ${h.httpCode}`)) || "无响应"}),立即故障切换 [${reason}]`,
|
|
626
650
|
);
|
|
627
651
|
}
|
|
628
|
-
c._lastFailoverAt = now;
|
|
629
652
|
|
|
630
|
-
const reason = (cmd && cmd.reason) || "看门狗主动故障切换";
|
|
631
653
|
const node = await api
|
|
632
654
|
.getCurrentNode(checker.CONFIG.groupName)
|
|
633
655
|
.catch(() => "?");
|
|
@@ -637,6 +659,9 @@ export class ClashDaemon {
|
|
|
637
659
|
const { nodes } = checker.loadHealthyList();
|
|
638
660
|
if (!nodes.length) {
|
|
639
661
|
this._log("🔄 候选集为空,触发 delay 初测");
|
|
662
|
+
// 初测即处置:重置窗口,避免健康探测每轮重复触发初测
|
|
663
|
+
w.startAt = now;
|
|
664
|
+
w.count = 0;
|
|
640
665
|
await this._runDelayProbeController(c);
|
|
641
666
|
return;
|
|
642
667
|
}
|
|
@@ -660,6 +685,10 @@ export class ClashDaemon {
|
|
|
660
685
|
}
|
|
661
686
|
this._log(` ↺ ${cand} 内容验证未通过,下一个`);
|
|
662
687
|
}
|
|
688
|
+
// 本次 failover 已尽力处置(成功切换或恢复原节点):重置窗口,给新节点干净观察窗口
|
|
689
|
+
w.startAt = now;
|
|
690
|
+
w.count = 0;
|
|
691
|
+
|
|
663
692
|
if (!target) {
|
|
664
693
|
await checker.switchToNode(node).catch(() => {});
|
|
665
694
|
this._markSwitch(c, node);
|
|
@@ -801,5 +830,8 @@ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
|
|
801
830
|
secret: getArg("--secret") || undefined,
|
|
802
831
|
selection: getArg("--selection") || undefined,
|
|
803
832
|
configPaths: configPaths.length ? configPaths : undefined,
|
|
833
|
+
failoverCooldownMs: parseInt(getArg("--failover-cooldown-ms")) || undefined,
|
|
834
|
+
failoverEscapeAfter:
|
|
835
|
+
parseInt(getArg("--failover-escape-after")) || undefined,
|
|
804
836
|
}).start();
|
|
805
837
|
}
|
package/src/cli/attach.js
CHANGED
|
@@ -389,7 +389,12 @@ export async function handleAttach(options) {
|
|
|
389
389
|
MAX_CDN_COOLDOWN_SEC,
|
|
390
390
|
);
|
|
391
391
|
const shouldSwitch = consecutiveCdnBlocks <= MAX_CDN_SWITCH_ATTEMPTS;
|
|
392
|
-
if (
|
|
392
|
+
if (!scraper) {
|
|
393
|
+
// webserver 模式:限流发生在远端 webserver 出口,本地切节点无效,仅冷却等待
|
|
394
|
+
attachLog(
|
|
395
|
+
` [Attach] CDN限流比例 ${(cdnRatio * 100).toFixed(0)}% (${cdnBlockedCount}/${tasks.length}),webserver 模式不切节点,冷却 ${coolDownSeconds} 秒...`,
|
|
396
|
+
);
|
|
397
|
+
} else if (shouldSwitch) {
|
|
393
398
|
// 用现有 IPC 接口通知 daemon 从候选集切下一个健康节点(换 IP 绕过 CDN 限流)
|
|
394
399
|
try {
|
|
395
400
|
writeCommand({
|
package/src/cli/precheck.js
CHANGED
|
@@ -429,18 +429,25 @@ export async function handlePrecheck(options) {
|
|
|
429
429
|
const cdnRatio =
|
|
430
430
|
candidates.length > 0 ? cdnBlockedCount / candidates.length : 0;
|
|
431
431
|
if (cdnRatio > 0.3) {
|
|
432
|
-
// 用现有 IPC 接口通知 daemon 从候选集切下一个健康节点(换 IP 绕过 CDN 限流)
|
|
433
|
-
try {
|
|
434
|
-
writeCommand({
|
|
435
|
-
action: "failover",
|
|
436
|
-
reason: `precheck CDN限流 (${(cdnRatio * 100).toFixed(0)}%)`,
|
|
437
|
-
});
|
|
438
|
-
} catch {}
|
|
439
432
|
// 切换节点后新 IP 生效需短暂等待,冷却可缩短(替代纯硬等 120s)
|
|
440
433
|
const coolDownSeconds = cdnRatio > 0.8 ? 90 : 60;
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
434
|
+
if (scraper) {
|
|
435
|
+
// 本地模式:用现有 IPC 接口通知 daemon 从候选集切下一个健康节点(换 IP 绕过 CDN 限流)
|
|
436
|
+
try {
|
|
437
|
+
writeCommand({
|
|
438
|
+
action: "failover",
|
|
439
|
+
reason: `precheck CDN限流 (${(cdnRatio * 100).toFixed(0)}%)`,
|
|
440
|
+
});
|
|
441
|
+
} catch {}
|
|
442
|
+
precheckLog(
|
|
443
|
+
` [Precheck] CDN限流比例 ${(cdnRatio * 100).toFixed(0)}% (${cdnBlockedCount}/${candidates.length}),已通知 daemon 切换节点,冷却 ${coolDownSeconds} 秒...`,
|
|
444
|
+
);
|
|
445
|
+
} else {
|
|
446
|
+
// webserver 模式:限流发生在远端 webserver 出口,本地切节点无效,仅冷却等待
|
|
447
|
+
precheckLog(
|
|
448
|
+
` [Precheck] CDN限流比例 ${(cdnRatio * 100).toFixed(0)}% (${cdnBlockedCount}/${candidates.length}),webserver 模式不切节点,冷却 ${coolDownSeconds} 秒...`,
|
|
449
|
+
);
|
|
450
|
+
}
|
|
444
451
|
await new Promise((r) => setTimeout(r, coolDownSeconds * 1000));
|
|
445
452
|
if (scraper) {
|
|
446
453
|
await recycleScraper(
|
package/src/watchdog/agent.js
CHANGED
|
@@ -103,6 +103,15 @@ function isCollectorCommand(cmdDef) {
|
|
|
103
103
|
return COLLECTOR_KEYWORDS.some((k) => s.includes(k));
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
/**
|
|
107
|
+
* 检测命令是否运行在 webserver 模式(不启动本地浏览器,抓取由远端集中完成)。
|
|
108
|
+
* 该模式下限流/网络错误发生在远端 webserver 出口,本地切换节点无法解决,
|
|
109
|
+
* 因此错误触发切换统计应排除此类进程。
|
|
110
|
+
*/
|
|
111
|
+
function isWebserverMode(cmdDef) {
|
|
112
|
+
return /--webserver(?=\s|=|$)/.test(cmdDef?.command || "");
|
|
113
|
+
}
|
|
114
|
+
|
|
106
115
|
/** 自动发现 daemon.js 路径 */
|
|
107
116
|
function findDaemonPath() {
|
|
108
117
|
// 1. 内部打包路径优先(npm publish 后 src/clash/daemon.js,升级即最新)
|
|
@@ -388,6 +397,8 @@ export class WatchdogAgent {
|
|
|
388
397
|
let failoverTriggered = false; // 本轮是否已触发全局切换
|
|
389
398
|
for (const { cmdId, cmdDef, newErrors } of scanned) {
|
|
390
399
|
if (!isCollectorCommand(cmdDef)) continue;
|
|
400
|
+
// webserver 模式:错误发生在远端出口,本地切节点无法解决,不计入错误触发统计
|
|
401
|
+
if (isWebserverMode(cmdDef)) continue;
|
|
391
402
|
let st = this._errorStates.get(cmdId) || {
|
|
392
403
|
consecutive: 0,
|
|
393
404
|
firstErrorAt: 0,
|
|
@@ -78,16 +78,29 @@ function printProcessLine(id, msg) {
|
|
|
78
78
|
}
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
//
|
|
81
|
+
// 会启动 CDP 浏览器的采集类命令(用于识别单端口形式 --port 的归属)
|
|
82
|
+
// 排除 webserver/watch 等 --port 是服务端口的命令,避免误杀
|
|
83
|
+
const BROWSER_CMD_RE =
|
|
84
|
+
/\b(?:explore|auto|attach|scrape|comments|refresh|recheck|precheck|search|videos|videostats|open|tag|login-check)\b/;
|
|
85
|
+
|
|
86
|
+
// 从命令行中提取 CDP 端口列表(解析 --base-port/--port-count 和单端口 --port)
|
|
82
87
|
function extractCdpPorts(cmdLine) {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
88
|
+
// 多端口形式:--base-port N [--port-count M] → N..N+M-1
|
|
89
|
+
const baseMatch = cmdLine.match(/--base-port\s+(\d+)/);
|
|
90
|
+
if (baseMatch) {
|
|
91
|
+
const base = parseInt(baseMatch[1], 10);
|
|
92
|
+
const countMatch = cmdLine.match(/--port-count\s+(\d+)/);
|
|
93
|
+
const count = countMatch ? parseInt(countMatch[1], 10) : 1;
|
|
94
|
+
const ports = [];
|
|
95
|
+
for (let i = 0; i < count; i++) ports.push(base + i);
|
|
96
|
+
return ports;
|
|
97
|
+
}
|
|
98
|
+
// 单端口形式(如 refresh-mini --port 5222):仅浏览器类命令提取
|
|
99
|
+
if (BROWSER_CMD_RE.test(cmdLine)) {
|
|
100
|
+
const m = cmdLine.match(/--port\s+(\d+)/);
|
|
101
|
+
if (m) return [parseInt(m[1], 10)];
|
|
102
|
+
}
|
|
103
|
+
return [];
|
|
91
104
|
}
|
|
92
105
|
|
|
93
106
|
// 引号感知的命令拆分:"a b" "c d" → ["a b", "c d"]
|
|
@@ -382,6 +395,13 @@ export class ProcessManager extends EventEmitter {
|
|
|
382
395
|
const cmdLine = info.cmdDef?.command || "";
|
|
383
396
|
const ports = extractCdpPorts(cmdLine);
|
|
384
397
|
|
|
398
|
+
// 必须先杀浏览器再杀业务进程树:业务进程被强杀后 Edge 主进程可能随之
|
|
399
|
+
// 退出、CDP 端口释放,此时按端口找监听 PID 会落空,导致 Edge 子进程残留。
|
|
400
|
+
// 先杀时端口必在监听,netstat 找 PID → taskkill /T 可杀干净整个 Edge 进程树。
|
|
401
|
+
for (const port of ports) {
|
|
402
|
+
killEdgeProcesses(null, port);
|
|
403
|
+
}
|
|
404
|
+
|
|
385
405
|
if (IS_WIN) {
|
|
386
406
|
// Windows: shell:true 产生 cmd.exe 中间层,必须杀整个进程树
|
|
387
407
|
execSync(`taskkill /PID ${info.child.pid} /T /F`, {
|
|
@@ -401,10 +421,6 @@ export class ProcessManager extends EventEmitter {
|
|
|
401
421
|
}, 2000);
|
|
402
422
|
}
|
|
403
423
|
|
|
404
|
-
// 全平台:关闭脱离进程树的浏览器(open -a / start msedge 启动的)
|
|
405
|
-
for (const port of ports) {
|
|
406
|
-
killEdgeProcesses(null, port);
|
|
407
|
-
}
|
|
408
424
|
} catch (e) {
|
|
409
425
|
console.error(`[watchdog] 停止命令 ${cmdId} 失败: ${e.message}`);
|
|
410
426
|
}
|