tt-help-cli-ycl 1.4.125 → 1.4.128
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-switch-history.mjs +141 -0
- package/scripts/test-wmi-free-upgrade.mjs +98 -0
- package/src/clash/daemon.js +99 -5
- package/src/cli/open.js +4 -3
- package/src/cli/refresh-mini.js +40 -13
- package/src/lib/api-interceptor.js +49 -42
- package/src/lib/browser/cdp.js +70 -47
- package/src/watchdog/agent.js +34 -1
- package/src/watchdog/process-manager.js +30 -21
- package/src/watchdog/upgrader.js +154 -49
package/package.json
CHANGED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* daemon 切换历史排序单测(无需 Clash 运行)
|
|
3
|
+
* 验证 _sortCandidates / _markSwitch 的纯时间轮换(后进后出)行为
|
|
4
|
+
*/
|
|
5
|
+
import { ClashDaemon } from "../src/clash/daemon.js";
|
|
6
|
+
|
|
7
|
+
let pass = 0;
|
|
8
|
+
let fail = 0;
|
|
9
|
+
function ok(cond, name, detail = "") {
|
|
10
|
+
if (cond) {
|
|
11
|
+
pass++;
|
|
12
|
+
console.log(` ✓ ${name}`);
|
|
13
|
+
} else {
|
|
14
|
+
fail++;
|
|
15
|
+
console.error(` ✗ ${name} ${detail}`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const d = new ClashDaemon({});
|
|
20
|
+
const nodes = ["A", "B", "C", "D"];
|
|
21
|
+
const T0 = Date.now();
|
|
22
|
+
|
|
23
|
+
// T1 无历史 → 原序
|
|
24
|
+
{
|
|
25
|
+
const c = { proxyPort: 1 };
|
|
26
|
+
ok(
|
|
27
|
+
JSON.stringify(d._sortCandidates(nodes, c)) === JSON.stringify(nodes),
|
|
28
|
+
"T1 无历史保持原序",
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// T2 切过沉底,未切过保持原序
|
|
33
|
+
{
|
|
34
|
+
const c = { proxyPort: 2 };
|
|
35
|
+
c._switchHistory = new Map([["B", T0 - 10_000]]);
|
|
36
|
+
const r = d._sortCandidates(nodes, c);
|
|
37
|
+
ok(r.join() === "A,C,D,B", "T2 切过沉底,未切过保持原序", r.join());
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// T3 切过按切换时间升序,未切过最前
|
|
41
|
+
{
|
|
42
|
+
const c = { proxyPort: 3 };
|
|
43
|
+
c._switchHistory = new Map([
|
|
44
|
+
["C", T0 - 2_000],
|
|
45
|
+
["A", T0 - 1_000],
|
|
46
|
+
]);
|
|
47
|
+
const r = d._sortCandidates(nodes, c);
|
|
48
|
+
ok(r.join() === "B,D,C,A", "T3 切过按切换时间升序,未切过最前", r.join());
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// T4 全部切过 → 完全按切换时间轮换
|
|
52
|
+
{
|
|
53
|
+
const c = { proxyPort: 4 };
|
|
54
|
+
c._switchHistory = new Map([
|
|
55
|
+
["A", T0 - 4_000],
|
|
56
|
+
["B", T0 - 1_000],
|
|
57
|
+
["C", T0 - 3_000],
|
|
58
|
+
["D", T0 - 2_000],
|
|
59
|
+
]);
|
|
60
|
+
const r = d._sortCandidates(nodes, c);
|
|
61
|
+
ok(r.join() === "A,C,D,B", "T4 全切过按切换时间轮换", r.join());
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// T5 无时间窗口:无论多久前切过都按时间排队(不恢复 delay 原序)
|
|
65
|
+
{
|
|
66
|
+
const c = { proxyPort: 5 };
|
|
67
|
+
c._switchHistory = new Map([["C", T0 - 3_600_000]]); // 1 小时前切过
|
|
68
|
+
const r = d._sortCandidates(nodes, c);
|
|
69
|
+
ok(r.join() === "A,B,D,C", "T5 无窗口:1 小时前切过仍沉底", r.join());
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// T6 排序不改原数组
|
|
73
|
+
{
|
|
74
|
+
ok(nodes.join() === "A,B,C,D", "T6 原数组未被修改");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// T7 _markSwitch 惰性初始化 + 更新
|
|
78
|
+
{
|
|
79
|
+
const c = { proxyPort: 6 };
|
|
80
|
+
d._markSwitch(c, "A");
|
|
81
|
+
ok(
|
|
82
|
+
c._switchHistory instanceof Map && c._switchHistory.has("A"),
|
|
83
|
+
"T7 _markSwitch 惰性初始化并记录",
|
|
84
|
+
);
|
|
85
|
+
const t1 = c._switchHistory.get("A");
|
|
86
|
+
d._markSwitch(c, "A");
|
|
87
|
+
ok(c._switchHistory.get("A") >= t1, "T8 _markSwitch 重复记录更新时间戳");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// T9 时间戳相同时稳定排序(V8 sort 稳定,保持原相对序)
|
|
91
|
+
{
|
|
92
|
+
const c = { proxyPort: 7 };
|
|
93
|
+
c._switchHistory = new Map([
|
|
94
|
+
["A", T0 - 100],
|
|
95
|
+
["B", T0 - 100],
|
|
96
|
+
]);
|
|
97
|
+
const r = d._sortCandidates(nodes, c);
|
|
98
|
+
ok(r.join() === "C,D,A,B", "T9 相同时间戳保持原相对序(稳定排序)", r.join());
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// T10 壳页判定:200 + 无跳转 + 无 error + 无 title → 壳页
|
|
102
|
+
ok(
|
|
103
|
+
d._isShellPage({ error: null, httpCode: 200, numRedirects: 0, title: "" }) ===
|
|
104
|
+
true,
|
|
105
|
+
"T10 壳页判定:200+无title → true",
|
|
106
|
+
);
|
|
107
|
+
// T11 健康页(有 title)→ 非壳页
|
|
108
|
+
ok(
|
|
109
|
+
d._isShellPage({
|
|
110
|
+
error: null,
|
|
111
|
+
httpCode: 200,
|
|
112
|
+
numRedirects: 0,
|
|
113
|
+
title: "TikTok - Make Your Day",
|
|
114
|
+
}) === false,
|
|
115
|
+
"T11 健康页有 title → false",
|
|
116
|
+
);
|
|
117
|
+
// T12 连接错误 → 非壳页
|
|
118
|
+
ok(
|
|
119
|
+
d._isShellPage({
|
|
120
|
+
error: "timeout",
|
|
121
|
+
httpCode: 0,
|
|
122
|
+
numRedirects: 0,
|
|
123
|
+
title: "",
|
|
124
|
+
}) === false,
|
|
125
|
+
"T12 连接错误 → false",
|
|
126
|
+
);
|
|
127
|
+
// T13 跳转 → 非壳页
|
|
128
|
+
ok(
|
|
129
|
+
d._isShellPage({ error: null, httpCode: 200, numRedirects: 1, title: "" }) ===
|
|
130
|
+
false,
|
|
131
|
+
"T13 跳转 → false",
|
|
132
|
+
);
|
|
133
|
+
// T14 非 200 → 非壳页
|
|
134
|
+
ok(
|
|
135
|
+
d._isShellPage({ error: null, httpCode: 403, numRedirects: 0, title: "" }) ===
|
|
136
|
+
false,
|
|
137
|
+
"T14 非 200 → false",
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
console.log(`\n结果: ${pass} 通过 / ${fail} 失败`);
|
|
141
|
+
process.exit(fail ? 1 : 0);
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 测试:升级链绕开 WMI 的新逻辑(2026-08-21)
|
|
3
|
+
* 范围(macOS 可测部分):
|
|
4
|
+
* 1. parseCsvLine:tasklist /FO CSV 输出解析(引号/逗号边界)
|
|
5
|
+
* 2. 模拟 tasklist /V 输出的窗口标题匹配过滤逻辑(复算与实现一致的筛选规则)
|
|
6
|
+
* 3. agent 构造即写存活标记文件(STARTED_FILE),内容含 pid/version
|
|
7
|
+
*/
|
|
8
|
+
import assert from "assert";
|
|
9
|
+
import { readFileSync, rmSync } from "fs";
|
|
10
|
+
import { join, dirname } from "path";
|
|
11
|
+
import { tmpdir } from "os";
|
|
12
|
+
import { fileURLToPath } from "url";
|
|
13
|
+
|
|
14
|
+
const { parseCsvLine } = await import("../src/watchdog/upgrader.js");
|
|
15
|
+
|
|
16
|
+
let pass = 0;
|
|
17
|
+
const ok = (msg) => {
|
|
18
|
+
pass++;
|
|
19
|
+
console.log(` ✓ ${msg}`);
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// ── 1. parseCsvLine ──
|
|
23
|
+
const L1 = '"conhost.exe","5234","Console","2","8,312 K","Running","x\\user","0:00:01","tt-help-watchdog (F-7@WIN)"';
|
|
24
|
+
const f1 = parseCsvLine(L1);
|
|
25
|
+
assert.strictEqual(f1[0], "conhost.exe", "映像名");
|
|
26
|
+
assert.strictEqual(f1[1], "5234", "PID");
|
|
27
|
+
assert.strictEqual(f1[3], "2", "会话#");
|
|
28
|
+
assert.strictEqual(f1[4], "8,312 K", "带逗号字段 8,312 K");
|
|
29
|
+
assert.strictEqual(f1[8], "tt-help-watchdog (F-7@WIN)", "窗口标题");
|
|
30
|
+
ok("parseCsvLine: 常规行(含带逗号字段)");
|
|
31
|
+
|
|
32
|
+
// 标题内含转义引号(CSV 标准:双引号 "" 转义)
|
|
33
|
+
const L2 = '"cmd.exe","999","Console","2","1,024 K","Running","x\\user","0:00:00","say ""hi"", tt-help-watchdog"';
|
|
34
|
+
const f2 = parseCsvLine(L2);
|
|
35
|
+
assert.strictEqual(f2[8], 'say "hi", tt-help-watchdog', "标题含转义引号与逗号");
|
|
36
|
+
ok("parseCsvLine: 标题含转义引号+逗号");
|
|
37
|
+
|
|
38
|
+
// 无引号行(表头)
|
|
39
|
+
const f3 = parseCsvLine("映像名称,PID,会话名,会话#,内存使用,状态,用户名,CPU 时间,窗口标题");
|
|
40
|
+
assert.strictEqual(f3.length, 9, "表头 9 列");
|
|
41
|
+
ok("parseCsvLine: 无引号表头");
|
|
42
|
+
|
|
43
|
+
// ── 2. 模拟 tasklist /V 输出做标题匹配过滤(复算实现逻辑) ──
|
|
44
|
+
const mockTasklist = [
|
|
45
|
+
'"映像名称","PID","会话名","会话#","内存使用","状态","用户名","CPU 时间","窗口标题"',
|
|
46
|
+
'"System Idle Process","0","Services","0","8 K","Unknown","NT AUTHORITY\\SYSTEM","1:23:45:55","N/A"',
|
|
47
|
+
'"conhost.exe","5234","Console","2","8,312 K","Running","x\\user","0:00:01","tt-help-watchdog (F-7@WIN-AADJ1HTCCAQ)"',
|
|
48
|
+
'"cmd.exe","4000","Console","2","4,444 K","Running","x\\user","0:00:00","N/A"',
|
|
49
|
+
'"conhost.exe","7777","Console","2","2,048 K","Running","x\\user","0:00:00","C:\\Windows\\system32\\cmd.exe"',
|
|
50
|
+
'"node.exe","6400","Console","2","120,000 K","Running","x\\user","0:00:10","N/A"',
|
|
51
|
+
'"cmd.exe","6401","Console","2","3,000 K","Running","x\\user","0:00:00","tt-help-watchdog (old)"',
|
|
52
|
+
];
|
|
53
|
+
const TAG = "tt-help-watchdog";
|
|
54
|
+
const matched = [];
|
|
55
|
+
for (const line of mockTasklist) {
|
|
56
|
+
if (!line.toLowerCase().includes(TAG.toLowerCase())) continue;
|
|
57
|
+
const fields = parseCsvLine(line);
|
|
58
|
+
if (fields.length < 2) continue;
|
|
59
|
+
const name = (fields[0] || "").toLowerCase();
|
|
60
|
+
if (!name.includes("conhost") && !name.includes("cmd")) continue;
|
|
61
|
+
const pid = parseInt(fields[1], 10);
|
|
62
|
+
if (pid > 0) matched.push(pid);
|
|
63
|
+
}
|
|
64
|
+
assert.deepStrictEqual(
|
|
65
|
+
[...new Set(matched)].sort((a, b) => a - b),
|
|
66
|
+
[5234, 6401],
|
|
67
|
+
"只匹配到标题含 tag 的 conhost/cmd,普通窗口/其他进程不误匹配",
|
|
68
|
+
);
|
|
69
|
+
ok("标题匹配过滤: 命中旧窗口 conhost+cmd,排除无关进程");
|
|
70
|
+
|
|
71
|
+
// ── 3. agent 构造即写存活标记文件 ──
|
|
72
|
+
const STARTED_FILE = join(tmpdir(), "tt-help-watchdog-started.json");
|
|
73
|
+
rmSync(STARTED_FILE, { force: true });
|
|
74
|
+
const { WatchdogAgent } = await import("../src/watchdog/agent.js");
|
|
75
|
+
const agent = new WatchdogAgent({
|
|
76
|
+
serverUrl: "http://127.0.0.1:3001",
|
|
77
|
+
clientId: "test@unit",
|
|
78
|
+
intervalMs: 15000,
|
|
79
|
+
});
|
|
80
|
+
assert.ok(agent, "agent 构造成功");
|
|
81
|
+
const st = JSON.parse(readFileSync(STARTED_FILE, "utf-8"));
|
|
82
|
+
assert.strictEqual(st.pid, process.pid, "pid=当前进程");
|
|
83
|
+
assert.ok(typeof st.version === "string" && st.version.length > 0, "version 非空");
|
|
84
|
+
assert.ok(st.ts > 0, "ts 存在");
|
|
85
|
+
ok(`存活文件写入: pid=${st.pid}, version=${st.version}`);
|
|
86
|
+
|
|
87
|
+
// 重复构造覆盖写(模拟新版 watchdog 启动覆盖旧文件)
|
|
88
|
+
const agent2 = new WatchdogAgent({
|
|
89
|
+
serverUrl: "http://127.0.0.1:3001",
|
|
90
|
+
clientId: "test2@unit",
|
|
91
|
+
});
|
|
92
|
+
const st2 = JSON.parse(readFileSync(STARTED_FILE, "utf-8"));
|
|
93
|
+
assert.strictEqual(st2.pid, process.pid, "覆盖写仍有效");
|
|
94
|
+
ok("存活文件: 重复构造覆盖写正常");
|
|
95
|
+
|
|
96
|
+
// 清理测试文件
|
|
97
|
+
rmSync(STARTED_FILE, { force: true });
|
|
98
|
+
console.log(`\n全部通过: ${pass} 项`);
|
package/src/clash/daemon.js
CHANGED
|
@@ -84,6 +84,45 @@ export class ClashDaemon {
|
|
|
84
84
|
return run;
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
/** 记录一次成功切换(failover/巡检/手动切换共用),供候选排序使用 */
|
|
88
|
+
_markSwitch(c, nodeName) {
|
|
89
|
+
if (!c || !nodeName) return;
|
|
90
|
+
c._switchHistory = c._switchHistory || new Map();
|
|
91
|
+
c._switchHistory.set(nodeName, Date.now());
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 壳页判定:TikTok SSR 降级响应(HTTP 200 + 无跳转 + 无 error + 无 title)。
|
|
96
|
+
* 壳页 = 加载页(data-downgrade 骨架屏),链路正常、浏览器会自行渲染,不算节点故障。
|
|
97
|
+
*/
|
|
98
|
+
_isShellPage(h) {
|
|
99
|
+
return (
|
|
100
|
+
!!h && !h.error && h.httpCode === 200 && h.numRedirects === 0 && !h.title
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* 候选排序(切换历史感知,纯时间轮换):
|
|
106
|
+
* 业务级封禁(TikTok 风控)探针无法感知,用"最近切换时间"作为唯一可靠信号,
|
|
107
|
+
* 让 failover/巡检在候选池内公平轮换,避免少数节点间反复横跳。
|
|
108
|
+
* 排序规则:
|
|
109
|
+
* - 从未切过的节点排最前(保持 delay 原序,新入池节点优先尝试)
|
|
110
|
+
* - 切过的节点按切换时间升序沉底(最早切换的先试,最近切换的最后试 = 后进后出)
|
|
111
|
+
* 轮完一圈自然回访最早切过的节点(等于重新评估),无需时间窗口过期。
|
|
112
|
+
*/
|
|
113
|
+
_sortCandidates(nodes, c) {
|
|
114
|
+
const hist = c && c._switchHistory;
|
|
115
|
+
if (!hist || !hist.size || !Array.isArray(nodes)) return nodes;
|
|
116
|
+
return [...nodes].sort((a, b) => {
|
|
117
|
+
const aLast = hist.get(a) || 0;
|
|
118
|
+
const bLast = hist.get(b) || 0;
|
|
119
|
+
if (aLast && bLast) return aLast - bLast; // 都切过:最早切换的先试
|
|
120
|
+
if (aLast) return 1; // 仅 a 切过:沉底
|
|
121
|
+
if (bLast) return -1;
|
|
122
|
+
return 0; // 都未切过:保持 delay 原序(Array.sort 稳定排序)
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
87
126
|
/** 将全局 api/checker 上下文指向指定控制器 */
|
|
88
127
|
_useController(c) {
|
|
89
128
|
api.configure({ port: c.port, secret: c.secret });
|
|
@@ -271,7 +310,7 @@ export class ClashDaemon {
|
|
|
271
310
|
const node = await api
|
|
272
311
|
.getCurrentNode(checker.CONFIG.groupName)
|
|
273
312
|
.catch(() => "?");
|
|
274
|
-
|
|
313
|
+
let h = checker.checkNodeHealth();
|
|
275
314
|
this._writeHealth(c, {
|
|
276
315
|
node,
|
|
277
316
|
healthy: h.healthy,
|
|
@@ -290,6 +329,45 @@ export class ClashDaemon {
|
|
|
290
329
|
return;
|
|
291
330
|
}
|
|
292
331
|
|
|
332
|
+
// 空壳页(TikTok SSR 降级响应):链路可用、浏览器会自行渲染,
|
|
333
|
+
// 切换无收益且会打断业务连接,不触发切换
|
|
334
|
+
if (this._isShellPage(h)) {
|
|
335
|
+
c._apiFailCount = 0;
|
|
336
|
+
this._log(
|
|
337
|
+
`~ [${c.proxyPort}] 空壳页(降级响应,链路正常),不切换 (${(h.sizeDownload / 1024).toFixed(0)}KB ${h.timeTotal.toFixed(1)}s)`,
|
|
338
|
+
);
|
|
339
|
+
return this._writeHealth(c, {
|
|
340
|
+
node,
|
|
341
|
+
healthy: true,
|
|
342
|
+
httpCode: h.httpCode,
|
|
343
|
+
timeTotal: h.timeTotal,
|
|
344
|
+
sizeDownload: h.sizeDownload,
|
|
345
|
+
title: h.title,
|
|
346
|
+
shellPage: true,
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// 真异常先复测一次:过滤瞬时抖动(截断/丢包),连续异常才进入切换
|
|
351
|
+
const firstReason =
|
|
352
|
+
h.error || `HTTP ${h.httpCode} ${(h.sizeDownload / 1024).toFixed(0)}KB`;
|
|
353
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
354
|
+
const hr = checker.checkNodeHealth();
|
|
355
|
+
if (hr.healthy || this._isShellPage(hr)) {
|
|
356
|
+
this._log(
|
|
357
|
+
`~ [${c.proxyPort}] 复测恢复(首次异常: ${firstReason}),不切换`,
|
|
358
|
+
);
|
|
359
|
+
return this._writeHealth(c, {
|
|
360
|
+
node,
|
|
361
|
+
healthy: true,
|
|
362
|
+
httpCode: hr.httpCode,
|
|
363
|
+
timeTotal: hr.timeTotal,
|
|
364
|
+
sizeDownload: hr.sizeDownload,
|
|
365
|
+
title: hr.title,
|
|
366
|
+
shellPage: this._isShellPage(hr) ? true : undefined,
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
h = hr; // 复测仍异常,以最新数据为准
|
|
370
|
+
|
|
293
371
|
const reason =
|
|
294
372
|
h.error || `HTTP ${h.httpCode} ${(h.sizeDownload / 1024).toFixed(0)}KB`;
|
|
295
373
|
const { nodes } = checker.loadHealthyList();
|
|
@@ -320,17 +398,20 @@ export class ClashDaemon {
|
|
|
320
398
|
`✗ [${c.proxyPort}] 巡检异常 [${reason}],开始从候选集切换 (当前 ${node})`,
|
|
321
399
|
);
|
|
322
400
|
// 逐个切换候选 + 内容验证,第一个满足即采用
|
|
401
|
+
// 排序:切过的节点按切换时间沉底(后进后出),优先尝试"最久没被切过"的节点
|
|
402
|
+
const ordered = this._sortCandidates(nodes, c);
|
|
323
403
|
let target = null;
|
|
324
404
|
let h2 = null;
|
|
325
|
-
for (const cand of
|
|
405
|
+
for (const cand of ordered) {
|
|
326
406
|
if (cand === node) continue;
|
|
327
407
|
this._log(`↺ [${c.proxyPort}] 尝试候选: ${cand} [原因: ${reason}]`);
|
|
328
408
|
if (!(await checker.switchToNode(cand))) {
|
|
329
409
|
this._log(` ↺ ${cand} 切换失败,下一个`);
|
|
330
410
|
continue;
|
|
331
411
|
}
|
|
412
|
+
this._markSwitch(c, cand);
|
|
332
413
|
h2 = checker.checkNodeHealth();
|
|
333
|
-
if (h2.healthy) {
|
|
414
|
+
if (h2.healthy || this._isShellPage(h2)) {
|
|
334
415
|
target = cand;
|
|
335
416
|
break;
|
|
336
417
|
}
|
|
@@ -340,6 +421,7 @@ export class ClashDaemon {
|
|
|
340
421
|
if (!target) {
|
|
341
422
|
// 全部候选不可用,恢复原节点
|
|
342
423
|
await checker.switchToNode(node).catch(() => {});
|
|
424
|
+
this._markSwitch(c, node);
|
|
343
425
|
await api.closeAllConnections().catch(() => {});
|
|
344
426
|
this._log(
|
|
345
427
|
`✗ [${c.proxyPort}] 恢复原节点 ${node}(原因: ${reason} — 候选全部不可用)`,
|
|
@@ -428,6 +510,13 @@ export class ClashDaemon {
|
|
|
428
510
|
});
|
|
429
511
|
const elapsed = ((Date.now() - start) / 1000).toFixed(0);
|
|
430
512
|
checker.saveHealthyList(r.nodes);
|
|
513
|
+
// 清理切换历史中已不在候选集的节点,防止 Map 无限增长
|
|
514
|
+
if (c._switchHistory && c._switchHistory.size) {
|
|
515
|
+
const keep = new Set(r.nodes);
|
|
516
|
+
for (const k of [...c._switchHistory.keys()]) {
|
|
517
|
+
if (!keep.has(k)) c._switchHistory.delete(k);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
431
520
|
c._meta.lastBenchmark = new Date().toISOString();
|
|
432
521
|
c._meta.healthyNodesTotal = r.total;
|
|
433
522
|
c._meta.healthyNodesCount = r.nodes.length;
|
|
@@ -489,6 +578,7 @@ export class ClashDaemon {
|
|
|
489
578
|
lastSwitch: `manual → ${cmd.node} (失败)`,
|
|
490
579
|
});
|
|
491
580
|
}
|
|
581
|
+
this._markSwitch(c, cmd.node);
|
|
492
582
|
const h = checker.checkNodeHealth();
|
|
493
583
|
// 切换后断开所有旧连接,浏览器立即重建到新节点(同巡检切换)
|
|
494
584
|
await api.closeAllConnections().catch(() => {});
|
|
@@ -551,17 +641,20 @@ export class ClashDaemon {
|
|
|
551
641
|
return;
|
|
552
642
|
}
|
|
553
643
|
// 逐个切换候选 + 内容验证,第一个满足即采用(跳过当前节点)
|
|
644
|
+
// 排序:切过的节点按切换时间沉底(后进后出),优先尝试"最久没被切过"的节点
|
|
645
|
+
const ordered = this._sortCandidates(nodes, c);
|
|
554
646
|
let target = null;
|
|
555
647
|
let h2 = null;
|
|
556
|
-
for (const cand of
|
|
648
|
+
for (const cand of ordered) {
|
|
557
649
|
if (cand === node) continue;
|
|
558
650
|
this._log(`↺ 尝试候选: ${cand}`);
|
|
559
651
|
if (!(await checker.switchToNode(cand))) {
|
|
560
652
|
this._log(` ↺ ${cand} 切换失败,下一个`);
|
|
561
653
|
continue;
|
|
562
654
|
}
|
|
655
|
+
this._markSwitch(c, cand);
|
|
563
656
|
h2 = checker.checkNodeHealth();
|
|
564
|
-
if (h2.healthy) {
|
|
657
|
+
if (h2.healthy || this._isShellPage(h2)) {
|
|
565
658
|
target = cand;
|
|
566
659
|
break;
|
|
567
660
|
}
|
|
@@ -569,6 +662,7 @@ export class ClashDaemon {
|
|
|
569
662
|
}
|
|
570
663
|
if (!target) {
|
|
571
664
|
await checker.switchToNode(node).catch(() => {});
|
|
665
|
+
this._markSwitch(c, node);
|
|
572
666
|
await api.closeAllConnections().catch(() => {});
|
|
573
667
|
this._log(
|
|
574
668
|
`✗ failover [${reason}] — 候选全部不可用,恢复原节点 ${node}`,
|
package/src/cli/open.js
CHANGED
|
@@ -90,8 +90,8 @@ export async function handleOpen(parsed) {
|
|
|
90
90
|
|
|
91
91
|
browser = await ensureBrowserReady(cdpOptions);
|
|
92
92
|
|
|
93
|
-
// Ctrl+C
|
|
94
|
-
|
|
93
|
+
// Ctrl+C 关闭浏览器:优雅断开优先(超时保护),强杀进程兜底,最后强制退出
|
|
94
|
+
const shutdown = async () => {
|
|
95
95
|
console.error("\n正在关闭浏览器...");
|
|
96
96
|
// 先优雅断开 CDP 连接(带 3s 超时保护):进程已被强杀时 close() 会挂起等 close 事件
|
|
97
97
|
try {
|
|
@@ -109,7 +109,8 @@ export async function handleOpen(parsed) {
|
|
|
109
109
|
});
|
|
110
110
|
console.error("浏览器已关闭");
|
|
111
111
|
process.exit(0);
|
|
112
|
-
}
|
|
112
|
+
};
|
|
113
|
+
process.on("SIGINT", shutdown);
|
|
113
114
|
|
|
114
115
|
console.error("");
|
|
115
116
|
console.error("浏览器已启动并连接成功");
|
package/src/cli/refresh-mini.js
CHANGED
|
@@ -576,22 +576,49 @@ export async function handleRefreshMini(options) {
|
|
|
576
576
|
|
|
577
577
|
if (result.error) {
|
|
578
578
|
const isBlocked = result.error.startsWith("被封:");
|
|
579
|
-
|
|
579
|
+
// 导航打断(多为切换节点后的网络重建期)不计入连续任务错误,
|
|
580
|
+
// 避免"切换 → 掐断连接 → 导航打断 → 计错 → 再切换"的循环
|
|
581
|
+
const isNavBreak = result.error.includes(
|
|
582
|
+
"Execution context was destroyed",
|
|
583
|
+
);
|
|
584
|
+
if (!isNavBreak) consecutiveTaskErrors++;
|
|
580
585
|
errorCount++;
|
|
581
586
|
|
|
582
|
-
//
|
|
587
|
+
// 临时性错误:自动重试
|
|
583
588
|
if (result.retryable) {
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
589
|
+
if (isNavBreak) {
|
|
590
|
+
// 与被封同款渐退等待:30/40/50s,给切换后的网络重建时间
|
|
591
|
+
const navWaits = [30_000, 40_000, 50_000];
|
|
592
|
+
for (let retry = 0; retry < navWaits.length; retry++) {
|
|
593
|
+
console.error(
|
|
594
|
+
` [网络重建] 导航打断(切换节点后),等待 ${navWaits[retry] / 1000}s 稳定后第 ${retry + 1}/${navWaits.length} 次重试 @${username}...`,
|
|
595
|
+
);
|
|
596
|
+
await sleep(navWaits[retry]);
|
|
597
|
+
result = await processExplore(
|
|
598
|
+
page,
|
|
599
|
+
username,
|
|
600
|
+
processOptions(),
|
|
601
|
+
console.error,
|
|
602
|
+
);
|
|
603
|
+
if (!result.error) {
|
|
604
|
+
consecutiveTaskErrors = 0;
|
|
605
|
+
errorCount--;
|
|
606
|
+
break;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
} else {
|
|
610
|
+
console.error(` [临时错误] 等待 5 秒后重试 @${username}...`);
|
|
611
|
+
await sleep(5000);
|
|
612
|
+
result = await processExplore(
|
|
613
|
+
page,
|
|
614
|
+
username,
|
|
615
|
+
processOptions(),
|
|
616
|
+
console.error,
|
|
617
|
+
);
|
|
618
|
+
if (!result.error) {
|
|
619
|
+
consecutiveTaskErrors = 0;
|
|
620
|
+
errorCount--;
|
|
621
|
+
}
|
|
595
622
|
}
|
|
596
623
|
}
|
|
597
624
|
|
|
@@ -251,17 +251,16 @@ async function attemptItemListFetch(page, username, log, apiRequestUrlRef) {
|
|
|
251
251
|
};
|
|
252
252
|
}
|
|
253
253
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
254
|
+
/**
|
|
255
|
+
* CDP 健康检查:带超时保护,区分"页面忙"与"页面死"。
|
|
256
|
+
* evaluate 排队在页面主线程上,加载中的慢页面(肉眼可见转圈)10s 轮不到执行很正常,
|
|
257
|
+
* 此时是忙不是死:先观察等待(默认 2 轮 x 20s),慢页面通常在此期间自然加载完自愈。
|
|
258
|
+
* 观察后仍无响应才刷新页面(实测刷新可解除主线程死锁);刷新后页面处于最忙的加载期,
|
|
259
|
+
* 先缓冲等待再以更宽松的窗口重检,避免误杀正在恢复的页面。
|
|
260
|
+
* 全部失败抛 "CDP 连接异常(刷新后仍失败)"。
|
|
261
|
+
*/
|
|
262
|
+
async function checkCdpHealth(page, options, log) {
|
|
263
263
|
const {
|
|
264
|
-
blockedRetryWaitsMs = DEFAULT_BLOCKED_RETRY_WAITS,
|
|
265
264
|
cdpProbeTimeoutMs = 10000, // 单次探活超时
|
|
266
265
|
cdpObserveWaitMs = 20000, // 观察等待间隔(页面转圈加载期)
|
|
267
266
|
cdpObserveRounds = 2, // 观察轮数
|
|
@@ -269,11 +268,6 @@ async function fetchUserVideosAPI(
|
|
|
269
268
|
cdpReloadProbeTimeoutMs = 20000, // 刷新后重检窗口
|
|
270
269
|
} = options;
|
|
271
270
|
|
|
272
|
-
// CDP 健康检查:带超时保护,区分"页面忙"与"页面死"。
|
|
273
|
-
// evaluate 排队在页面主线程上,加载中的慢页面(肉眼可见转圈)10s 轮不到执行很正常,
|
|
274
|
-
// 此时是忙不是死:先观察等待(默认 2 轮 x 20s),慢页面通常在此期间自然加载完自愈。
|
|
275
|
-
// 观察后仍无响应才刷新页面(实测刷新可解除主线程死锁);刷新后页面处于最忙的加载期,
|
|
276
|
-
// 先缓冲等待再以更宽松的窗口重检,避免误杀正在恢复的页面。
|
|
277
271
|
const evalWithTimeout = (label, ms) =>
|
|
278
272
|
Promise.race([
|
|
279
273
|
page.evaluate(() => document.readyState),
|
|
@@ -285,49 +279,62 @@ async function fetchUserVideosAPI(
|
|
|
285
279
|
),
|
|
286
280
|
]);
|
|
287
281
|
|
|
288
|
-
|
|
282
|
+
// 首探:成功(即使页面仍在加载)即认为健康
|
|
289
283
|
try {
|
|
290
284
|
const state = await evalWithTimeout("CDP 健康检查", cdpProbeTimeoutMs);
|
|
291
|
-
healthy = true;
|
|
292
285
|
if (state !== "complete") {
|
|
293
286
|
log(` [API拦截] 健康检查通过,页面加载中 (readyState=${state})`);
|
|
294
287
|
}
|
|
288
|
+
return;
|
|
295
289
|
} catch (e) {
|
|
296
|
-
// 观察阶段:慢页面几十秒内通常自愈,期间不做任何操作
|
|
297
290
|
log(
|
|
298
291
|
` [API拦截] CDP 探活超时: ${e.message},页面可能仍在加载,观察等待...`,
|
|
299
292
|
);
|
|
300
|
-
for (let round = 1; round <= cdpObserveRounds && !healthy; round++) {
|
|
301
|
-
await new Promise((r) => setTimeout(r, cdpObserveWaitMs));
|
|
302
|
-
try {
|
|
303
|
-
await evalWithTimeout(
|
|
304
|
-
`观察重检 ${round}/${cdpObserveRounds}`,
|
|
305
|
-
cdpProbeTimeoutMs,
|
|
306
|
-
);
|
|
307
|
-
healthy = true;
|
|
308
|
-
log(` [API拦截] 页面已恢复(观察 ${round} 轮后)`);
|
|
309
|
-
} catch (e2) {
|
|
310
|
-
log(
|
|
311
|
-
` [API拦截] 观察 ${round}/${cdpObserveRounds} 后仍无响应: ${e2.message}`,
|
|
312
|
-
);
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
293
|
}
|
|
316
294
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
await
|
|
320
|
-
.reload({ waitUntil: "domcontentloaded", timeout: 30000 })
|
|
321
|
-
.catch(() => {});
|
|
322
|
-
await new Promise((r) => setTimeout(r, cdpReloadSettleMs));
|
|
295
|
+
// 观察阶段:慢页面几十秒内通常自愈,期间不做任何操作
|
|
296
|
+
for (let round = 1; round <= cdpObserveRounds; round++) {
|
|
297
|
+
await new Promise((r) => setTimeout(r, cdpObserveWaitMs));
|
|
323
298
|
try {
|
|
324
|
-
await evalWithTimeout(
|
|
325
|
-
|
|
299
|
+
await evalWithTimeout(
|
|
300
|
+
`观察重检 ${round}/${cdpObserveRounds}`,
|
|
301
|
+
cdpProbeTimeoutMs,
|
|
302
|
+
);
|
|
303
|
+
log(` [API拦截] 页面已恢复(观察 ${round} 轮后)`);
|
|
304
|
+
return;
|
|
326
305
|
} catch (e2) {
|
|
327
|
-
|
|
306
|
+
log(
|
|
307
|
+
` [API拦截] 观察 ${round}/${cdpObserveRounds} 后仍无响应: ${e2.message}`,
|
|
308
|
+
);
|
|
328
309
|
}
|
|
329
310
|
}
|
|
330
311
|
|
|
312
|
+
// 观察后仍无响应才刷新页面
|
|
313
|
+
log(` [API拦截] 观察后仍无响应,刷新页面恢复...`);
|
|
314
|
+
await page
|
|
315
|
+
.reload({ waitUntil: "domcontentloaded", timeout: 30000 })
|
|
316
|
+
.catch(() => {});
|
|
317
|
+
await new Promise((r) => setTimeout(r, cdpReloadSettleMs));
|
|
318
|
+
try {
|
|
319
|
+
await evalWithTimeout("刷新后重检", cdpReloadProbeTimeoutMs);
|
|
320
|
+
log(` [API拦截] 页面已恢复(刷新后)`);
|
|
321
|
+
} catch (e2) {
|
|
322
|
+
throw new Error(`CDP 连接异常(刷新后仍失败): ${e2.message}`);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function fetchUserVideosAPI(
|
|
327
|
+
page,
|
|
328
|
+
username,
|
|
329
|
+
maxVideos,
|
|
330
|
+
log,
|
|
331
|
+
options = {},
|
|
332
|
+
) {
|
|
333
|
+
log(` [API拦截] 获取 @${username} 视频 ...`);
|
|
334
|
+
|
|
335
|
+
await checkCdpHealth(page, options, log);
|
|
336
|
+
|
|
337
|
+
const { blockedRetryWaitsMs = DEFAULT_BLOCKED_RETRY_WAITS } = options;
|
|
331
338
|
let apiRequestUrlRef = { value: null };
|
|
332
339
|
const retryWaits = Array.isArray(blockedRetryWaitsMs)
|
|
333
340
|
? blockedRetryWaitsMs
|
package/src/lib/browser/cdp.js
CHANGED
|
@@ -70,6 +70,19 @@ function checkEdgeArgs() {
|
|
|
70
70
|
});
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
/**
|
|
74
|
+
* 解码 Windows 命令输出:中文系统 taskkill 输出为 GBK,
|
|
75
|
+
* 先按 UTF-8 解码,出现乱码替换符(U+FFFD)时改用 GBK 重解。
|
|
76
|
+
*/
|
|
77
|
+
function decodeWinOutput(data) {
|
|
78
|
+
if (!Buffer.isBuffer(data)) return String(data ?? "").trim();
|
|
79
|
+
let text = new TextDecoder("utf-8").decode(data);
|
|
80
|
+
if (text.includes("\uFFFD")) {
|
|
81
|
+
text = new TextDecoder("gbk").decode(data);
|
|
82
|
+
}
|
|
83
|
+
return text.trim();
|
|
84
|
+
}
|
|
85
|
+
|
|
73
86
|
/**
|
|
74
87
|
* 解析 netstat -ano 输出,返回监听指定端口的 PID 列表(去重)。
|
|
75
88
|
* 不依赖 WMI,适配 WMI 损坏/禁用的 Windows 机器。
|
|
@@ -89,6 +102,58 @@ export function parseNetstatListeningPids(netstatOutput, port) {
|
|
|
89
102
|
return pids;
|
|
90
103
|
}
|
|
91
104
|
|
|
105
|
+
/**
|
|
106
|
+
* Windows:按监听端口杀掉 Edge 主进程及其进程树(不依赖 WMI,
|
|
107
|
+
* Get-CimInstance 在 WMI 损坏/禁用的机器上查不到进程)。
|
|
108
|
+
* 返回后续清理 Singleton 锁文件的命令。
|
|
109
|
+
*/
|
|
110
|
+
function killEdgeOnWindowsByPort(port) {
|
|
111
|
+
let killCmds = "";
|
|
112
|
+
try {
|
|
113
|
+
const netstatOut = execSync("netstat -ano", {
|
|
114
|
+
encoding: "utf-8",
|
|
115
|
+
timeout: 5000,
|
|
116
|
+
windowsHide: true,
|
|
117
|
+
});
|
|
118
|
+
const pids = parseNetstatListeningPids(netstatOut, port);
|
|
119
|
+
if (pids.length > 0) {
|
|
120
|
+
// 不重定向 stderr:taskkill 的错误信息(拒绝访问/找不到进程)需要可见
|
|
121
|
+
killCmds = pids.map((pid) => `taskkill /F /T /PID ${pid}`).join(" & ");
|
|
122
|
+
console.error(
|
|
123
|
+
`[killEdgeProcesses] 端口 ${port} 监听进程 PID: ${pids.join(", ")}`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
} catch (e) {
|
|
127
|
+
console.error(`[killEdgeProcesses] netstat 查询失败: ${e.message}`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (killCmds) {
|
|
131
|
+
// 同步执行并打印结果:taskkill 失败(权限/会话不匹配等)必须可见,不能静默吞掉
|
|
132
|
+
try {
|
|
133
|
+
const out = execSync(`(${killCmds})`, {
|
|
134
|
+
encoding: "buffer",
|
|
135
|
+
timeout: 15000,
|
|
136
|
+
windowsHide: true,
|
|
137
|
+
});
|
|
138
|
+
const lines = decodeWinOutput(out).split(/\r?\n/).filter(Boolean);
|
|
139
|
+
console.error(
|
|
140
|
+
`[killEdgeProcesses] taskkill 结果: ${lines.join(" | ") || "(无输出)"}`,
|
|
141
|
+
);
|
|
142
|
+
} catch (e) {
|
|
143
|
+
console.error(`[killEdgeProcesses] taskkill 失败: ${e.message}`);
|
|
144
|
+
const detail = decodeWinOutput(
|
|
145
|
+
Buffer.concat([
|
|
146
|
+
Buffer.isBuffer(e.stdout) ? e.stdout : Buffer.alloc(0),
|
|
147
|
+
Buffer.isBuffer(e.stderr) ? e.stderr : Buffer.alloc(0),
|
|
148
|
+
]),
|
|
149
|
+
);
|
|
150
|
+
if (detail) console.error(`[killEdgeProcesses] 详情: ${detail}`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return `del /f /q "%LOCALAPPDATA%\\Microsoft\\Edge\\Singleton*" 2>nul; exit 0`;
|
|
155
|
+
}
|
|
156
|
+
|
|
92
157
|
function killEdgeProcesses(targetDir, port) {
|
|
93
158
|
return new Promise((resolve) => {
|
|
94
159
|
const platform = os.platform();
|
|
@@ -138,53 +203,11 @@ function killEdgeProcesses(targetDir, port) {
|
|
|
138
203
|
'killall -9 "Microsoft Edge" 2>/dev/null; rm -f ~/Library/Caches/Microsoft\\ Edge/Singleton*; true';
|
|
139
204
|
}
|
|
140
205
|
} else if (platform === "win32") {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
const netstatOut = execSync("netstat -ano", {
|
|
147
|
-
encoding: "utf-8",
|
|
148
|
-
timeout: 5000,
|
|
149
|
-
windowsHide: true,
|
|
150
|
-
});
|
|
151
|
-
const pids = parseNetstatListeningPids(netstatOut, port);
|
|
152
|
-
if (pids.length > 0) {
|
|
153
|
-
// 不重定向 stderr:taskkill 的错误信息(拒绝访问/找不到进程)需要可见
|
|
154
|
-
killCmds = pids
|
|
155
|
-
.map((pid) => `taskkill /F /T /PID ${pid}`)
|
|
156
|
-
.join(" & ");
|
|
157
|
-
console.error(
|
|
158
|
-
`[killEdgeProcesses] 端口 ${port} 监听进程 PID: ${pids.join(", ")}`,
|
|
159
|
-
);
|
|
160
|
-
}
|
|
161
|
-
} catch (e) {
|
|
162
|
-
console.error(`[killEdgeProcesses] netstat 查询失败: ${e.message}`);
|
|
163
|
-
}
|
|
164
|
-
if (killCmds) {
|
|
165
|
-
// 同步执行并打印结果:taskkill 失败(权限/会话不匹配等)必须可见,不能静默吞掉
|
|
166
|
-
const killCmd = `(${killCmds})`;
|
|
167
|
-
try {
|
|
168
|
-
const out = execSync(killCmd, {
|
|
169
|
-
encoding: "utf-8",
|
|
170
|
-
timeout: 15000,
|
|
171
|
-
windowsHide: true,
|
|
172
|
-
});
|
|
173
|
-
const lines = out.trim().split(/\r?\n/).filter(Boolean);
|
|
174
|
-
console.error(
|
|
175
|
-
`[killEdgeProcesses] taskkill 结果: ${lines.join(" | ") || "(无输出)"}`,
|
|
176
|
-
);
|
|
177
|
-
} catch (e) {
|
|
178
|
-
console.error(`[killEdgeProcesses] taskkill 失败: ${e.message}`);
|
|
179
|
-
const detail = `${e.stdout || ""}${e.stderr || ""}`.trim();
|
|
180
|
-
if (detail) console.error(`[killEdgeProcesses] 详情: ${detail}`);
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
// 找不到监听进程 = 浏览器可能已退出,不做按进程名的兜底(避免误杀其他端口实例)
|
|
184
|
-
command = `del /f /q "%LOCALAPPDATA%\\Microsoft\\Edge\\Singleton*" 2>nul; exit 0`;
|
|
185
|
-
} else {
|
|
186
|
-
command = "taskkill /F /IM msedge.exe 2>nul || exit 0";
|
|
187
|
-
}
|
|
206
|
+
// 有端口:按监听端口杀进程树(找不到监听进程 = 浏览器可能已退出,
|
|
207
|
+
// 不做按进程名的兜底,避免误杀其他端口实例)
|
|
208
|
+
command = port
|
|
209
|
+
? killEdgeOnWindowsByPort(port)
|
|
210
|
+
: "taskkill /F /IM msedge.exe 2>nul || exit 0";
|
|
188
211
|
} else {
|
|
189
212
|
if (targetDir) {
|
|
190
213
|
const escapedDir = targetDir.replace(/[-\/\\^$*+?.()|[\]{}]/g, "\\$&");
|
package/src/watchdog/agent.js
CHANGED
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
import { writeFileSync, existsSync, readFileSync } from "fs";
|
|
25
25
|
import { spawn } from "child_process";
|
|
26
26
|
import { join, dirname } from "path";
|
|
27
|
-
import { homedir } from "os";
|
|
27
|
+
import { homedir, tmpdir } from "os";
|
|
28
28
|
import { fileURLToPath } from "url";
|
|
29
29
|
|
|
30
30
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
@@ -32,6 +32,36 @@ const homeDir = homedir() || process.env.HOME || process.env.USERPROFILE || "";
|
|
|
32
32
|
const TT_HELP_CONFIG = join(homeDir, ".tt-help.json");
|
|
33
33
|
const IS_WIN = process.platform === "win32";
|
|
34
34
|
|
|
35
|
+
// ── 升级配套(绕开 WMI)──
|
|
36
|
+
// 部分 Windows 机器 WMI(WinMgmt)损坏,升级器用 Get-CimInstance 查进程表会失效,
|
|
37
|
+
// 导致旧 cmd 窗口不关、新版存活误判。故升级链改为:
|
|
38
|
+
// 1. 本进程启动时设置控制台窗口标题 → 升级器按标题匹配(tasklist /V)关旧窗
|
|
39
|
+
// 2. 本进程启动时写存活标记文件 → 升级器读文件确认新版存活(不再枚举进程表)
|
|
40
|
+
const STARTED_FILE = join(tmpdir(), "tt-help-watchdog-started.json");
|
|
41
|
+
const WINDOW_TITLE_TAG = "tt-help-watchdog";
|
|
42
|
+
|
|
43
|
+
/** 设置控制台窗口标题(仅 Windows 交互窗口生效,ESC ]0 转义;失败静默) */
|
|
44
|
+
function setConsoleTitle(clientId) {
|
|
45
|
+
if (process.platform !== "win32") return;
|
|
46
|
+
try {
|
|
47
|
+
process.stdout.write(`\x1b]0;${WINDOW_TITLE_TAG} (${clientId})\x07`);
|
|
48
|
+
} catch {}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 写存活标记文件(升级器据此确认新版 watchdog 已启动,绕开 WMI) */
|
|
52
|
+
function writeStartedFile() {
|
|
53
|
+
try {
|
|
54
|
+
writeFileSync(
|
|
55
|
+
STARTED_FILE,
|
|
56
|
+
JSON.stringify({
|
|
57
|
+
pid: process.pid,
|
|
58
|
+
version: getVersion(),
|
|
59
|
+
ts: Date.now(),
|
|
60
|
+
}),
|
|
61
|
+
);
|
|
62
|
+
} catch {}
|
|
63
|
+
}
|
|
64
|
+
|
|
35
65
|
// ── 业务日志错误统计 → 触发节点切换 ──
|
|
36
66
|
// 识别采集程序日志里已有的错误分类标签(explore 等已打标),连续错误达标则切换出口节点
|
|
37
67
|
const NETWORK_ERROR_PATTERNS = [
|
|
@@ -173,6 +203,9 @@ export class WatchdogAgent {
|
|
|
173
203
|
}) {
|
|
174
204
|
this.serverUrl = serverUrl.replace(/\/+$/, "");
|
|
175
205
|
this.clientId = clientId;
|
|
206
|
+
// 升级配套:设窗口标题(升级器按标题关旧窗)+ 写存活文件(升级器做存活校验)
|
|
207
|
+
setConsoleTitle(clientId);
|
|
208
|
+
writeStartedFile();
|
|
176
209
|
this.intervalMs = intervalMs;
|
|
177
210
|
this.spawnDaemon = !!spawnDaemon;
|
|
178
211
|
this.loginCheckIntervalMs = Math.max(
|
|
@@ -175,36 +175,45 @@ function getProcessStats(pid) {
|
|
|
175
175
|
}
|
|
176
176
|
}
|
|
177
177
|
|
|
178
|
+
/**
|
|
179
|
+
* Windows: 执行 PowerShell -Command 并返回 stdout(trim 后)。
|
|
180
|
+
* 统一 execSync 选项,避免各处重复配置。
|
|
181
|
+
*/
|
|
182
|
+
function runPowerShell(psCommand, timeoutMs = 3000) {
|
|
183
|
+
return execSync(`powershell -NoProfile -Command "${psCommand}"`, {
|
|
184
|
+
encoding: "utf-8",
|
|
185
|
+
timeout: timeoutMs,
|
|
186
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
187
|
+
windowsHide: true, // Windows 隐藏控制台窗口
|
|
188
|
+
}).trim();
|
|
189
|
+
}
|
|
190
|
+
|
|
178
191
|
// Windows: 从 cmd.exe 往下找真正的 node.exe PID(shell:true 时 cmd.exe 是中间层)
|
|
179
192
|
function getChildPid(parentPid) {
|
|
180
|
-
// 主路径:WMI
|
|
193
|
+
// 主路径:WMI 查询(WMI 正常机器零变化)
|
|
181
194
|
try {
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
).trim();
|
|
191
|
-
return parseInt(out, 10) || 0;
|
|
195
|
+
return (
|
|
196
|
+
parseInt(
|
|
197
|
+
runPowerShell(
|
|
198
|
+
`Get-CimInstance Win32_Process -Filter 'ParentProcessId=${parentPid}' | Where-Object { $_.Name -notlike 'conhost*' } | Select-Object -First 1 -ExpandProperty ProcessId`,
|
|
199
|
+
),
|
|
200
|
+
10,
|
|
201
|
+
) || 0
|
|
202
|
+
);
|
|
192
203
|
} catch {
|
|
193
204
|
// WMI 损坏/禁用时 Get-CimInstance 直接报错,落入 fallback
|
|
194
205
|
}
|
|
195
206
|
// fallback:不依赖 WMI,用 Get-Process(.NET API)按启动时间找
|
|
196
207
|
// cmd.exe 之后最早启动的 node.exe(watchdog spawn 后 3s 才调用,node 应已启动)
|
|
197
208
|
try {
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
).trim();
|
|
207
|
-
return parseInt(out, 10) || 0;
|
|
209
|
+
return (
|
|
210
|
+
parseInt(
|
|
211
|
+
runPowerShell(
|
|
212
|
+
`$cmd = Get-Process -Id ${parentPid} -ErrorAction SilentlyContinue; if ($cmd) { Get-Process node -ErrorAction SilentlyContinue | Where-Object { $_.StartTime -ge $cmd.StartTime } | Sort-Object StartTime | Select-Object -First 1 -ExpandProperty Id }`,
|
|
213
|
+
),
|
|
214
|
+
10,
|
|
215
|
+
) || 0
|
|
216
|
+
);
|
|
208
217
|
} catch {
|
|
209
218
|
return 0;
|
|
210
219
|
}
|
package/src/watchdog/upgrader.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import { exec, spawn, execFile } from "child_process";
|
|
10
10
|
import { promisify } from "util";
|
|
11
|
-
import { readFileSync, openSync } from "fs";
|
|
11
|
+
import { readFileSync, openSync, unlinkSync } from "fs";
|
|
12
12
|
import { fileURLToPath } from "url";
|
|
13
13
|
import { dirname, join } from "path";
|
|
14
14
|
import { homedir, tmpdir } from "os";
|
|
@@ -21,6 +21,12 @@ const homeDir = homedir() || process.env.HOME || process.env.USERPROFILE || "";
|
|
|
21
21
|
const TT_HELP_CONFIG = join(homeDir, ".tt-help.json");
|
|
22
22
|
const execFileP = promisify(execFile);
|
|
23
23
|
|
|
24
|
+
// 升级链绕开 WMI 的配套常量(与 agent.js 保持一致):
|
|
25
|
+
// 部分 Windows 机器 WMI(WinMgmt)损坏,Get-CimInstance 查不到进程表 →
|
|
26
|
+
// 关旧窗改用窗口标题匹配(tasklist /V),存活校验改用新版 watchdog 写出的标记文件
|
|
27
|
+
const STARTED_FILE = join(tmpdir(), "tt-help-watchdog-started.json");
|
|
28
|
+
const WINDOW_TITLE_TAG = "tt-help-watchdog";
|
|
29
|
+
|
|
24
30
|
/**
|
|
25
31
|
* 升级检测源:TT_HELP_REGISTRY 环境变量 > ~/.tt-help.json 的 npmRegistry > 官方 npmjs.org
|
|
26
32
|
* 注意:检测源应与本机 npm install 使用的 registry 保持一致(默认 npm 自身配置)
|
|
@@ -298,22 +304,101 @@ async function waitProcessExit(pid, waitMs) {
|
|
|
298
304
|
return false;
|
|
299
305
|
}
|
|
300
306
|
|
|
301
|
-
/**
|
|
302
|
-
function
|
|
303
|
-
const byId = new Map(rows.map((r) => [r.pid, r]));
|
|
307
|
+
/** 解析一行 CSV(处理引号包裹的逗号/转义引号),用于解析 tasklist /FO CSV 输出 */
|
|
308
|
+
export function parseCsvLine(line) {
|
|
304
309
|
const out = [];
|
|
305
|
-
|
|
306
|
-
let
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
310
|
+
let cur = "";
|
|
311
|
+
let inQ = false;
|
|
312
|
+
for (let i = 0; i < line.length; i++) {
|
|
313
|
+
const c = line[i];
|
|
314
|
+
if (inQ) {
|
|
315
|
+
if (c === '"') {
|
|
316
|
+
if (line[i + 1] === '"') {
|
|
317
|
+
cur += '"';
|
|
318
|
+
i++;
|
|
319
|
+
} else {
|
|
320
|
+
inQ = false;
|
|
321
|
+
}
|
|
322
|
+
} else {
|
|
323
|
+
cur += c;
|
|
324
|
+
}
|
|
325
|
+
} else if (c === '"') {
|
|
326
|
+
inQ = true;
|
|
327
|
+
} else if (c === ",") {
|
|
328
|
+
out.push(cur);
|
|
329
|
+
cur = "";
|
|
330
|
+
} else {
|
|
331
|
+
cur += c;
|
|
332
|
+
}
|
|
313
333
|
}
|
|
334
|
+
out.push(cur);
|
|
314
335
|
return out;
|
|
315
336
|
}
|
|
316
337
|
|
|
338
|
+
/**
|
|
339
|
+
* Windows:按窗口标题匹配 tasklist /V 输出中的控制台宿主进程 PID(绕开 WMI)。
|
|
340
|
+
* 窗口标题在 conhost.exe 行(Win10/11)或 cmd.exe 行(旧系统),两者都返回;
|
|
341
|
+
* 其余进程(含升级器 U 自身的隐藏 conhost,标题为空)不会误匹配。
|
|
342
|
+
*/
|
|
343
|
+
async function listWindowPidsByTitle(titleSubstr) {
|
|
344
|
+
if (process.platform !== "win32") return [];
|
|
345
|
+
try {
|
|
346
|
+
const { stdout } = await execFileP(
|
|
347
|
+
"tasklist.exe",
|
|
348
|
+
["/V", "/FO", "CSV"],
|
|
349
|
+
{ timeout: 20000, windowsHide: true },
|
|
350
|
+
);
|
|
351
|
+
const pids = [];
|
|
352
|
+
for (const line of String(stdout).split(/\r?\n/)) {
|
|
353
|
+
if (!line.toLowerCase().includes(titleSubstr.toLowerCase())) continue;
|
|
354
|
+
const fields = parseCsvLine(line);
|
|
355
|
+
if (fields.length < 2) continue;
|
|
356
|
+
const name = (fields[0] || "").toLowerCase();
|
|
357
|
+
if (!name.includes("conhost") && !name.includes("cmd")) continue;
|
|
358
|
+
const pid = parseInt(fields[1], 10);
|
|
359
|
+
if (pid > 0 && pid !== process.pid) pids.push(pid);
|
|
360
|
+
}
|
|
361
|
+
return [...new Set(pids)];
|
|
362
|
+
} catch (e) {
|
|
363
|
+
console.error(`[upgrade-proc] tasklist 查窗口失败: ${e.message}`);
|
|
364
|
+
return [];
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* 关闭旧 watchdog 所在的 cmd 窗口(绕开 WMI):
|
|
370
|
+
* 旧 watchdog 启动时已把窗口标题设为含 WINDOW_TITLE_TAG 的串(agent.js),
|
|
371
|
+
* 此时新版窗口尚未弹出,匹配到的窗口即旧窗口 → taskkill 杀 conhost/cmd 关窗。
|
|
372
|
+
* 旧 watchdog 版本过旧(未设标题)或非窗口运行时匹配不到,跳过。
|
|
373
|
+
*/
|
|
374
|
+
async function killOldWatchdogWindow() {
|
|
375
|
+
if (process.platform !== "win32") return;
|
|
376
|
+
const pids = await listWindowPidsByTitle(WINDOW_TITLE_TAG);
|
|
377
|
+
if (pids.length === 0) {
|
|
378
|
+
console.error(
|
|
379
|
+
`[upgrade-proc] 未找到标题含 "${WINDOW_TITLE_TAG}" 的窗口(旧 watchdog 非窗口运行或版本过旧未设标题),跳过关旧窗`,
|
|
380
|
+
);
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
await killPidList(pids, "已关闭旧 cmd 窗口");
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** 读存活标记文件(新版 watchdog 启动后写入 {pid, version, ts}) */
|
|
387
|
+
function readStartedFile() {
|
|
388
|
+
try {
|
|
389
|
+
return JSON.parse(readFileSync(STARTED_FILE, "utf-8"));
|
|
390
|
+
} catch {
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** 删存活标记文件(spawn 新版前清掉旧 watchdog 的残留,确保读到的是新进程的) */
|
|
396
|
+
function deleteStartedFile() {
|
|
397
|
+
try {
|
|
398
|
+
unlinkSync(STARTED_FILE);
|
|
399
|
+
} catch {}
|
|
400
|
+
}
|
|
401
|
+
|
|
317
402
|
/** 一次拉取全进程表 [{pid, ppid, cmd}](Windows: CIM;POSIX: ps) */
|
|
318
403
|
async function snapshotAllProcesses() {
|
|
319
404
|
const rows = [];
|
|
@@ -441,24 +526,13 @@ async function killPidList(pids, label) {
|
|
|
441
526
|
return killed;
|
|
442
527
|
}
|
|
443
528
|
|
|
444
|
-
/**
|
|
529
|
+
/**
|
|
530
|
+
* 枚举当前所有 watchdog 进程 PID(仅 POSIX 存活校验前后对比用)。
|
|
531
|
+
* Windows 不再走这里——部分机器 WMI 损坏时枚举会静默返回空,已改为读
|
|
532
|
+
* 新版 watchdog 写出的存活标记文件(见 runUpgradeMode 第 5 步)。
|
|
533
|
+
*/
|
|
445
534
|
async function listWatchdogPids() {
|
|
446
535
|
try {
|
|
447
|
-
if (process.platform === "win32") {
|
|
448
|
-
const ps =
|
|
449
|
-
`Get-CimInstance Win32_Process -Filter "Name='node.exe'" ` +
|
|
450
|
-
`| Where-Object { $_.CommandLine -match 'cli\\.js watchdog' } ` +
|
|
451
|
-
`| Select-Object -ExpandProperty ProcessId`;
|
|
452
|
-
const { stdout } = await execFileP(
|
|
453
|
-
"powershell.exe",
|
|
454
|
-
["-NoProfile", "-Command", ps],
|
|
455
|
-
{ timeout: 15000, windowsHide: true },
|
|
456
|
-
);
|
|
457
|
-
return String(stdout)
|
|
458
|
-
.split(/\r?\n/)
|
|
459
|
-
.map((s) => parseInt(s.trim(), 10))
|
|
460
|
-
.filter((n) => n > 0);
|
|
461
|
-
}
|
|
462
536
|
const { stdout } = await execFileP("pgrep", ["-f", "cli.js watchdog"], {
|
|
463
537
|
timeout: 15000,
|
|
464
538
|
});
|
|
@@ -467,9 +541,7 @@ async function listWatchdogPids() {
|
|
|
467
541
|
.map((s) => parseInt(s.trim(), 10))
|
|
468
542
|
.filter((n) => n > 0);
|
|
469
543
|
} catch (e) {
|
|
470
|
-
console.error(
|
|
471
|
-
`[upgrader] 枚举 watchdog 进程失败(WMI 不可用?): ${e.message}`,
|
|
472
|
-
);
|
|
544
|
+
console.error(`[upgrader] 枚举 watchdog 进程失败: ${e.message}`);
|
|
473
545
|
return [];
|
|
474
546
|
}
|
|
475
547
|
}
|
|
@@ -551,11 +623,11 @@ export async function runUpgradeMode(argv) {
|
|
|
551
623
|
// 会触发旧 watchdog 的 SIGINT → gracefulShutdown 优雅停业务。因此先向旧 watchdog
|
|
552
624
|
// 的控制台发送 CTRL_C_EVENT(等效手动按键),8s 未退再强杀兜底;随后按【杀旧前】
|
|
553
625
|
// 快照强杀整棵后代树(排除 U 自己;杀旧后中间层死亡会断父链,必须提前快照),
|
|
554
|
-
// 再兜底清理「父已死」的孤儿业务进程,最后关闭旧 watchdog 所在的 cmd
|
|
626
|
+
// 再兜底清理「父已死」的孤儿业务进程,最后关闭旧 watchdog 所在的 cmd 窗口
|
|
627
|
+
// (按窗口标题匹配 tasklist /V,绕开 WMI——部分机器 WMI 损坏时快照为空)。
|
|
555
628
|
console.error(`[upgrade-proc] 通知旧 watchdog (pid=${oldPid}) 退出 ...`);
|
|
556
629
|
const procSnapshot = await snapshotAllProcesses();
|
|
557
630
|
const descPids = listDescendantPids(procSnapshot, oldPid, process.pid);
|
|
558
|
-
const cmdAncestors = listCmdAncestorPids(procSnapshot, oldPid);
|
|
559
631
|
|
|
560
632
|
let exited = false;
|
|
561
633
|
const gracefulSent =
|
|
@@ -584,8 +656,9 @@ export async function runUpgradeMode(argv) {
|
|
|
584
656
|
// 兜底:快照时父链已断(中间层先死)的孤儿业务进程,杀旧后按特征清理
|
|
585
657
|
const orphanPids = listOrphanBusinessPids(await snapshotAllProcesses());
|
|
586
658
|
await killPidList(orphanPids, "已清理孤儿业务进程");
|
|
587
|
-
// 关闭旧 watchdog 所在的 cmd
|
|
588
|
-
|
|
659
|
+
// 关闭旧 watchdog 所在的 cmd 窗口(业务已停,窗口只剩空壳提示符);
|
|
660
|
+
// 按窗口标题匹配(不依赖 WMI),旧 watchdog 已设标题含 WINDOW_TITLE_TAG
|
|
661
|
+
await killOldWatchdogWindow();
|
|
589
662
|
|
|
590
663
|
// 4) 旧进程已退,启新版 watchdog
|
|
591
664
|
// Windows:显式弹独立交互 cmd 窗口(start → cmd /k),日志直接显示在窗口,
|
|
@@ -602,9 +675,12 @@ export async function runUpgradeMode(argv) {
|
|
|
602
675
|
process.platform === "win32"
|
|
603
676
|
? undefined
|
|
604
677
|
: openLogFile("tt-help-watchdog", "upgrade-proc", "新版 watchdog 日志");
|
|
605
|
-
//
|
|
606
|
-
//
|
|
607
|
-
|
|
678
|
+
// Windows 存活校验改读新版 watchdog 写出的标记文件(WMI 损坏时枚举进程表失效),
|
|
679
|
+
// spawn 前先删掉旧 watchdog 的残留,确保读到的是新进程写的
|
|
680
|
+
if (process.platform === "win32") deleteStartedFile();
|
|
681
|
+
// POSIX:记录启动前的 watchdog 进程集合,用于存活校验前后对比
|
|
682
|
+
const beforePids =
|
|
683
|
+
process.platform === "win32" ? [] : await listWatchdogPids();
|
|
608
684
|
let child;
|
|
609
685
|
try {
|
|
610
686
|
child = spawnNewWatchdog(cliPath, args, logFd);
|
|
@@ -620,20 +696,49 @@ export async function runUpgradeMode(argv) {
|
|
|
620
696
|
process.exit(1);
|
|
621
697
|
}
|
|
622
698
|
|
|
623
|
-
// 5) 存活校验:3s
|
|
699
|
+
// 5) 存活校验:3s 后确认新版 watchdog 存活;未存活则明确报错,不静默
|
|
624
700
|
await new Promise((r) => setTimeout(r, 3000));
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
701
|
+
if (process.platform === "win32") {
|
|
702
|
+
// Windows:轮询存活标记文件(新版 watchdog 启动即写,含 pid/version),
|
|
703
|
+
// 版本匹配且 pid 可探测存活才算确认。15s 超时未出现则报错。
|
|
704
|
+
const deadline = Date.now() + 15_000;
|
|
705
|
+
let st = null;
|
|
706
|
+
while (Date.now() < deadline && !st) {
|
|
707
|
+
const cur = readStartedFile();
|
|
708
|
+
if (
|
|
709
|
+
cur &&
|
|
710
|
+
cur.pid > 0 &&
|
|
711
|
+
String(cur.version) === String(verify.version) &&
|
|
712
|
+
(await isProcessAlive(cur.pid))
|
|
713
|
+
) {
|
|
714
|
+
st = cur;
|
|
715
|
+
}
|
|
716
|
+
if (!st) await new Promise((r) => setTimeout(r, 1000));
|
|
717
|
+
}
|
|
718
|
+
if (st) {
|
|
719
|
+
console.error(
|
|
720
|
+
`[upgrade-proc] 新版 watchdog 存活确认 (pid=${st.pid}, v${st.version}),升级完成`,
|
|
721
|
+
);
|
|
722
|
+
} else {
|
|
723
|
+
console.error(
|
|
724
|
+
`[upgrade-proc] 新版 watchdog 启动后未存活,请查看窗口/日志排障`,
|
|
725
|
+
);
|
|
726
|
+
process.exit(1);
|
|
727
|
+
}
|
|
632
728
|
} else {
|
|
633
|
-
|
|
634
|
-
|
|
729
|
+
const newPids = (await listWatchdogPids()).filter(
|
|
730
|
+
(p) => !beforePids.includes(p),
|
|
635
731
|
);
|
|
636
|
-
|
|
732
|
+
if (newPids.length > 0) {
|
|
733
|
+
console.error(
|
|
734
|
+
`[upgrade-proc] 新版 watchdog 存活确认 (pid=${newPids.join(",")}),升级完成`,
|
|
735
|
+
);
|
|
736
|
+
} else {
|
|
737
|
+
console.error(
|
|
738
|
+
`[upgrade-proc] 新版 watchdog 启动后未存活,请查看窗口/日志排障`,
|
|
739
|
+
);
|
|
740
|
+
process.exit(1);
|
|
741
|
+
}
|
|
637
742
|
}
|
|
638
743
|
process.exit(0);
|
|
639
744
|
}
|