tt-help-cli-ycl 1.4.164 → 1.4.166
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/add-claim-fallback-index.mjs +25 -0
- package/scripts/add-claim-index.mjs +3 -5
- package/scripts/test-clash-hysteresis.mjs +131 -0
- package/scripts/test-client-error-monitor.mjs +130 -45
- package/scripts/test-webserver-error-classify.mjs +30 -0
- package/src/clash/checker.js +56 -3
- package/src/clash/daemon.js +17 -3
- package/src/clash/proxy-switch.js +5 -1
- package/src/clash/runtime.js +8 -1
- package/src/cli/tag.js +2 -0
- package/src/lib/api-client.js +16 -0
- package/src/lib/browser/cdp.js +104 -1
- package/src/lib/client-error-report.js +4 -1
- package/src/watch/data-store.js +5 -0
- package/src/watch/db-schema.js +5 -4
- package/src/watch/public/app.js +4 -0
- package/src/watch/server.js +1 -0
- package/src/watchdog/agent.js +41 -12
- package/src/watchdog/client-error-monitor.js +50 -14
- package/src/watchdog/reporter.js +42 -1
- package/src/webserver/server.mjs +100 -11
package/src/clash/runtime.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync, statSync, readdirSync, copyFileSync, openSync, renameSync, lstatSync, symlinkSync } from "fs";
|
|
17
17
|
import net from "net";
|
|
18
|
-
import { homedir, platform, arch } from "os";
|
|
18
|
+
import { homedir, platform, arch, tmpdir } from "os";
|
|
19
19
|
import { join, dirname } from "path";
|
|
20
20
|
import { spawn, spawnSync, execSync } from "child_process";
|
|
21
21
|
import { gunzipSync } from "zlib";
|
|
@@ -681,6 +681,13 @@ export async function stopRuntimeInstance(index = 1, { log = () => {} } = {}) {
|
|
|
681
681
|
} catch {}
|
|
682
682
|
}
|
|
683
683
|
}
|
|
684
|
+
if (killed) {
|
|
685
|
+
// 回收成功 → 一并清理通道健康文件,避免幽灵通道残留
|
|
686
|
+
//(daemon _rediscover 后不再巡检该实例,文件永远不会被覆盖,看门狗页面会多出幽灵订阅)
|
|
687
|
+
try {
|
|
688
|
+
rmSync(join(tmpdir(), `clash-health-${spec.mixedPort}.json`), { force: true });
|
|
689
|
+
} catch {}
|
|
690
|
+
}
|
|
684
691
|
return killed;
|
|
685
692
|
}
|
|
686
693
|
|
package/src/cli/tag.js
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
formatMemoryWithBrowser,
|
|
8
8
|
} from "../lib/browser-memory.js";
|
|
9
9
|
import { reportClientErrorAndFailover } from "../lib/client-error-report.js";
|
|
10
|
+
import { getWatchdogClientId } from "../lib/api-client.js";
|
|
10
11
|
import { fetchTagData, enrichVideosWithLocation } from "../lib/tag-fetcher.js";
|
|
11
12
|
import { killEdgeProcesses, ensureBrowserReady } from "../lib/browser/cdp.js";
|
|
12
13
|
import { getOrCreatePage } from "../lib/browser/page.js";
|
|
@@ -1324,6 +1325,7 @@ export async function handleScoreAll(parsed) {
|
|
|
1324
1325
|
stage: "tag-scoring",
|
|
1325
1326
|
errorStack: "",
|
|
1326
1327
|
hostname: os.hostname(),
|
|
1328
|
+
clientId: getWatchdogClientId(), // 看门狗按 clientId 关联本机异常
|
|
1327
1329
|
}),
|
|
1328
1330
|
});
|
|
1329
1331
|
} catch {}
|
package/src/lib/api-client.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
2
|
import os from "os";
|
|
3
|
+
import { getWatchdogClientId } from "../watchdog/reporter.js";
|
|
4
|
+
|
|
5
|
+
// 供业务侧(client-error-report / tag / webserver 等)统一从本模块取机器标识
|
|
6
|
+
export { getWatchdogClientId };
|
|
3
7
|
|
|
4
8
|
const MAX_RETRY_WAIT = 5 * 60 * 1000;
|
|
5
9
|
|
|
@@ -82,11 +86,23 @@ export function createApiClient(opts = {}) {
|
|
|
82
86
|
// - header 放净化后的 ASCII 值(仅保证请求能发出)
|
|
83
87
|
// - 原始 hostname 塞进 body(JSON 为 UTF-8,无 ByteString 限制),
|
|
84
88
|
// 服务端 body.hostname 优先,看门狗仍能按原始主机名关联本机异常
|
|
89
|
+
// 另:克隆机群 hostname 全部相同(如 F-2~F-10 均为 WIN-AADJ1HTCCAQ),
|
|
90
|
+
// hostname 已不能唯一标识机器 → 同时携带 watchdog clientId(每台唯一,
|
|
91
|
+
// watchdog 启动时写入 ~/.tt-help.json 的 watchdogClientId 字段),
|
|
92
|
+
// 看门狗按 clientId 关联本机异常,hostname 仅作展示。
|
|
85
93
|
if (/\/api\/error-report(\?|$)/.test(String(url))) {
|
|
86
94
|
headers["X-Client-Hostname"] = os
|
|
87
95
|
.hostname()
|
|
88
96
|
.replace(/[^\x20-\x7e]/g, "_");
|
|
89
97
|
body = { ...body, hostname: os.hostname() };
|
|
98
|
+
const wdClientId = getWatchdogClientId();
|
|
99
|
+
if (wdClientId) {
|
|
100
|
+
headers["X-Watchdog-Client-Id"] = wdClientId.replace(
|
|
101
|
+
/[^\x20-\x7e]/g,
|
|
102
|
+
"_",
|
|
103
|
+
);
|
|
104
|
+
body = { ...body, clientId: wdClientId };
|
|
105
|
+
}
|
|
90
106
|
}
|
|
91
107
|
const res = await fetch(url, {
|
|
92
108
|
method: "POST",
|
package/src/lib/browser/cdp.js
CHANGED
|
@@ -222,6 +222,92 @@ function killEdgeProcesses(targetDir, port) {
|
|
|
222
222
|
});
|
|
223
223
|
}
|
|
224
224
|
|
|
225
|
+
/**
|
|
226
|
+
* 查找"残留" Edge 主进程:命令行包含指定 user-data-dir、但不带
|
|
227
|
+
* --remote-debugging-port(Edge 自动更新/崩溃自恢复、上次强杀残留等,
|
|
228
|
+
* 占着 profile 却没监听 CDP 端口)。
|
|
229
|
+
* 排除带 --type= 的子进程:渲染进程等子进程同样带 user-data-dir 却没有
|
|
230
|
+
* CDP 参数,误杀会打断同 profile 的健康实例。
|
|
231
|
+
*
|
|
232
|
+
* 这类实例必须先杀掉再启动:否则新 msedge 会因 Chromium ProcessSingleton
|
|
233
|
+
* 把命令行转发给旧实例,变成"旧实例开一个 about:blank 新 tab",CDP 端口
|
|
234
|
+
* 永远起不来 → 等待超时 → watchdog 自动重启 → 每轮再开一个 tab。
|
|
235
|
+
*
|
|
236
|
+
* killEdgeProcesses 在 Windows 上按监听端口找 PID,对这类实例无效,
|
|
237
|
+
* 故单独按命令行匹配精确清理。
|
|
238
|
+
*
|
|
239
|
+
* @param {string} targetDir - user-data-dir 路径
|
|
240
|
+
* @returns {string[]|null} PID 列表;无法枚举进程命令行(PowerShell/WMI 不可用)时返回 null
|
|
241
|
+
*/
|
|
242
|
+
function findStaleEdgePids(targetDir) {
|
|
243
|
+
const platform = os.platform();
|
|
244
|
+
try {
|
|
245
|
+
let out;
|
|
246
|
+
if (platform === "win32") {
|
|
247
|
+
// PowerShell 为主(wmic 已从新版 Win11 移除;项目其他模块同样依赖 PowerShell)
|
|
248
|
+
out = decodeWinOutput(
|
|
249
|
+
execSync(
|
|
250
|
+
'powershell -NoProfile -Command "Get-CimInstance Win32_Process | Where-Object { $_.Name -eq \'msedge.exe\' } | ForEach-Object { Write-Output ($_.ProcessId.ToString() + \'|\' + $_.CommandLine) }"',
|
|
251
|
+
{ encoding: "buffer", timeout: 10000, windowsHide: true },
|
|
252
|
+
),
|
|
253
|
+
);
|
|
254
|
+
} else {
|
|
255
|
+
out = execSync("ps -eo pid,args", {
|
|
256
|
+
encoding: "utf-8",
|
|
257
|
+
timeout: 10000,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
const dirLower = targetDir.toLowerCase();
|
|
261
|
+
const pids = [];
|
|
262
|
+
for (const line of out.split(/\r?\n/)) {
|
|
263
|
+
const lower = line.toLowerCase();
|
|
264
|
+
if (!lower.includes(dirLower)) continue; // 只匹配本 profile
|
|
265
|
+
// 边界保护:避免 _p9222 误匹配 _p92221 这类前缀目录
|
|
266
|
+
const idx = lower.indexOf(dirLower);
|
|
267
|
+
const next = lower[idx + dirLower.length];
|
|
268
|
+
if (next && /[a-z0-9_]/.test(next)) continue;
|
|
269
|
+
if (lower.includes("remote-debugging-port")) continue; // 健康 CDP 实例,不动
|
|
270
|
+
if (lower.includes("--type=")) continue; // 子进程,误杀会打断健康实例
|
|
271
|
+
// 提取 PID:win 格式 "PID|命令行",unix 格式 " PID 命令行"
|
|
272
|
+
const m = line.match(/^\s*(\d+)(?:\||\s)/);
|
|
273
|
+
if (m && !pids.includes(m[1])) pids.push(m[1]);
|
|
274
|
+
}
|
|
275
|
+
return pids;
|
|
276
|
+
} catch (e) {
|
|
277
|
+
console.error(`[findStaleEdgePids] 枚举 Edge 进程失败: ${e.message}`);
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* 强杀指定 PID 的 Edge 进程树(Windows 用 taskkill /T 保级联,其余平台 kill -9)。
|
|
284
|
+
* @param {string[]} pids
|
|
285
|
+
* @returns {Promise<void>}
|
|
286
|
+
*/
|
|
287
|
+
function killEdgeStale(pids) {
|
|
288
|
+
const platform = os.platform();
|
|
289
|
+
if (platform === "win32") {
|
|
290
|
+
const killCmds = pids.map((pid) => `taskkill /F /T /PID ${pid}`).join(" & ");
|
|
291
|
+
try {
|
|
292
|
+
const out = execSync(`(${killCmds})`, {
|
|
293
|
+
encoding: "buffer",
|
|
294
|
+
timeout: 15000,
|
|
295
|
+
windowsHide: true,
|
|
296
|
+
});
|
|
297
|
+
const lines = decodeWinOutput(out).split(/\r?\n/).filter(Boolean);
|
|
298
|
+
console.error(
|
|
299
|
+
`[killEdgeStale] taskkill 结果: ${lines.join(" | ") || "(无输出)"}`,
|
|
300
|
+
);
|
|
301
|
+
} catch (e) {
|
|
302
|
+
console.error(`[killEdgeStale] taskkill 失败: ${e.message}`);
|
|
303
|
+
}
|
|
304
|
+
return Promise.resolve();
|
|
305
|
+
}
|
|
306
|
+
return new Promise((resolve) => {
|
|
307
|
+
exec(`kill -9 ${pids.join(" ")} 2>/dev/null; true`, () => resolve());
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
225
311
|
function launchEdgeWithCDP(port, userDataDir, proxyServer) {
|
|
226
312
|
return new Promise((resolve, reject) => {
|
|
227
313
|
const platform = os.platform();
|
|
@@ -319,10 +405,27 @@ export async function ensureBrowserReady(options = {}) {
|
|
|
319
405
|
console.error(`CDP 端口 ${port} 未就绪,正在启动 Edge 浏览器...`);
|
|
320
406
|
}
|
|
321
407
|
}
|
|
408
|
+
// 残留实例清理:占着该 user-data-dir 却没开 CDP 的旧 Edge 必须先杀掉,
|
|
409
|
+
// 否则 launch 会被 ProcessSingleton 转发成"旧实例开新 tab",CDP 永远起不来。
|
|
410
|
+
const stalePids = findStaleEdgePids(userDataDir);
|
|
411
|
+
if (stalePids === null) {
|
|
412
|
+
console.error("[浏览器] 无法枚举 Edge 进程命令行,跳过残留实例检测");
|
|
413
|
+
} else if (stalePids.length > 0) {
|
|
414
|
+
console.error(
|
|
415
|
+
`[浏览器] 检测到占用 ${path.basename(userDataDir)} 但未开 CDP 的残留 Edge 实例 (PID: ${stalePids.join(", ")}),正在清理...`,
|
|
416
|
+
);
|
|
417
|
+
await killEdgeStale(stalePids);
|
|
418
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
419
|
+
}
|
|
420
|
+
|
|
322
421
|
await launchEdgeWithCDP(port, userDataDir, proxyServer);
|
|
323
422
|
|
|
324
423
|
console.error("等待浏览器启动...");
|
|
325
|
-
|
|
424
|
+
// Windows 冷启动(杀软扫描/更新后首次启动)可能超过 30s,放宽到 60s
|
|
425
|
+
const launched = await waitForCDP(
|
|
426
|
+
port,
|
|
427
|
+
os.platform() === "win32" ? 60000 : 30000,
|
|
428
|
+
);
|
|
326
429
|
if (!launched) {
|
|
327
430
|
throw new Error(
|
|
328
431
|
`等待 CDP 端口 ${port} 超时。请确认 Edge 浏览器已安装,\n` +
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import os from "os";
|
|
7
|
+
import { getWatchdogClientId } from "./api-client.js";
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* 触发 Clash daemon 的故障切换(换出口节点),与 watchdog 的 failover 一致。
|
|
@@ -49,7 +50,9 @@ export async function reportClientErrorAndFailover({
|
|
|
49
50
|
errorMessage: message,
|
|
50
51
|
stage,
|
|
51
52
|
errorStack: "",
|
|
52
|
-
hostname: os.hostname(), //
|
|
53
|
+
hostname: os.hostname(), // 机器标识(展示用;克隆机群 hostname 重复)
|
|
54
|
+
// 看门狗按 clientId 关联本机异常(每台唯一,克隆机 hostname 重复不可用)
|
|
55
|
+
clientId: getWatchdogClientId(),
|
|
53
56
|
});
|
|
54
57
|
} catch {}
|
|
55
58
|
await triggerClashFailover(message, log);
|
package/src/watch/data-store.js
CHANGED
|
@@ -2563,11 +2563,15 @@ export function createStore(filePath, options = {}) {
|
|
|
2563
2563
|
stage,
|
|
2564
2564
|
errorStack,
|
|
2565
2565
|
hostname,
|
|
2566
|
+
clientId,
|
|
2566
2567
|
) {
|
|
2567
2568
|
const existing = clientErrors.get(userId);
|
|
2568
2569
|
if (existing) {
|
|
2569
2570
|
existing.timestamp = Date.now();
|
|
2570
2571
|
if (hostname) existing.hostname = hostname;
|
|
2572
|
+
// clientId(机器唯一标识,如 F-9@HOST)随上报刷新;克隆机群 hostname 重复,
|
|
2573
|
+
// 看门狗只认 clientId 关联本机条目
|
|
2574
|
+
if (clientId) existing.clientId = clientId;
|
|
2571
2575
|
if (errorType === "captcha") {
|
|
2572
2576
|
existing.captchaCount = (existing.captchaCount || 0) + 1;
|
|
2573
2577
|
if (!existing.captchaStage) existing.captchaStage = stage || "";
|
|
@@ -2586,6 +2590,7 @@ export function createStore(filePath, options = {}) {
|
|
|
2586
2590
|
clientErrors.set(userId, {
|
|
2587
2591
|
userId,
|
|
2588
2592
|
hostname: hostname || "",
|
|
2593
|
+
clientId: clientId || "", // 机器唯一标识;空 = 旧版客户端上报,不参与看门狗触发
|
|
2589
2594
|
errorType,
|
|
2590
2595
|
errorMessage: errorMessage || "",
|
|
2591
2596
|
errorStack: errorStack || "",
|
package/src/watch/db-schema.js
CHANGED
|
@@ -173,7 +173,6 @@ export function initDb(filePath) {
|
|
|
173
173
|
product_review_count: "INTEGER",
|
|
174
174
|
product_sold_count: "INTEGER",
|
|
175
175
|
product_name: "TEXT",
|
|
176
|
-
product_skus: "TEXT",
|
|
177
176
|
user_create_time: "INTEGER",
|
|
178
177
|
recent_ec_videos: "TEXT",
|
|
179
178
|
});
|
|
@@ -210,7 +209,6 @@ export function initDb(filePath) {
|
|
|
210
209
|
product_review_count: "INTEGER",
|
|
211
210
|
product_sold_count: "INTEGER",
|
|
212
211
|
product_name: "TEXT",
|
|
213
|
-
product_skus: "TEXT",
|
|
214
212
|
user_create_time: "INTEGER",
|
|
215
213
|
recent_ec_videos: "TEXT",
|
|
216
214
|
});
|
|
@@ -248,7 +246,6 @@ export function initDb(filePath) {
|
|
|
248
246
|
product_review_count: "INTEGER",
|
|
249
247
|
product_sold_count: "INTEGER",
|
|
250
248
|
product_name: "TEXT",
|
|
251
|
-
product_skus: "TEXT",
|
|
252
249
|
user_create_time: "INTEGER",
|
|
253
250
|
recent_ec_videos: "TEXT",
|
|
254
251
|
});
|
|
@@ -451,13 +448,17 @@ export function initDb(filePath) {
|
|
|
451
448
|
|
|
452
449
|
// ===== task_queue 索引(claimNextJob 逐级短路查询 + 监控列表) =====
|
|
453
450
|
// idx_tq_claim_fast:claim 分支查询主索引(2026-09-17 实战验证:分支 7s→1ms)。
|
|
454
|
-
//
|
|
451
|
+
// idx_tq_claim_fallback:兜底分支索引(2026-09-28 实战验证:NOT IN 多国分支 4.2s→16ms)。
|
|
452
|
+
// 配置外国家(OTHER)的池子可达十几万行,NOT IN 多国无法用 loc 前导列 seek,
|
|
453
|
+
// 此索引跳过 loc、直接按 (status, follower_count) 排序 seek 第一行。
|
|
454
|
+
// 均为普通复合索引,不依赖部分索引的 WHERE 表达式文本匹配(旧 4 个部分索引因
|
|
455
455
|
// pinned=0 与 COALESCE(pinned,0)=0 文本不一致、首国分支 OR 空串,从未被命中,已废弃清理)。
|
|
456
456
|
const tqIndexes = [
|
|
457
457
|
`CREATE INDEX IF NOT EXISTS idx_tq_status ON task_queue(status)`,
|
|
458
458
|
`CREATE INDEX IF NOT EXISTS idx_tq_claimed_by_status ON task_queue(claimed_by, status, claimed_at)`,
|
|
459
459
|
`CREATE INDEX IF NOT EXISTS idx_tq_status_claimed_at ON task_queue(status, claimed_at)`,
|
|
460
460
|
`CREATE INDEX IF NOT EXISTS idx_tq_claim_fast ON task_queue(status, UPPER(COALESCE(guessed_location,'')), follower_count DESC, created_at ASC, unique_id ASC)`,
|
|
461
|
+
`CREATE INDEX IF NOT EXISTS idx_tq_claim_fallback ON task_queue(status, follower_count DESC, created_at ASC, unique_id ASC)`,
|
|
461
462
|
// 监控页面用户列表查询加速
|
|
462
463
|
`CREATE INDEX IF NOT EXISTS idx_tq_list_pending ON task_queue(pinned, processed_at, follower_count, unique_id) WHERE status = 'pending'`,
|
|
463
464
|
`CREATE INDEX IF NOT EXISTS idx_tq_list_processing ON task_queue(pinned, processed_at, follower_count, unique_id) WHERE status = 'processing'`,
|
package/src/watch/public/app.js
CHANGED
|
@@ -5316,6 +5316,8 @@ function renderProxySection(c, userId) {
|
|
|
5316
5316
|
}
|
|
5317
5317
|
var html = renderSubsOverview(c, userId, builtin, channels);
|
|
5318
5318
|
for (var j = 0; j < external.length; j++) {
|
|
5319
|
+
// 幽灵外部通道(>2min 未更新的残留健康文件)不渲染
|
|
5320
|
+
if (external[j].stale) continue;
|
|
5319
5321
|
// 外部通道的订阅配置入口:仅当没有总览区时显示(配置入口全局收敛为一个)
|
|
5320
5322
|
html += renderExternalChannel(external[j], userId, !html);
|
|
5321
5323
|
}
|
|
@@ -5391,6 +5393,8 @@ function renderSubsOverview(c, userId, builtinChannels, allChannels) {
|
|
|
5391
5393
|
}
|
|
5392
5394
|
var count = configured.length;
|
|
5393
5395
|
for (var p in byPort) {
|
|
5396
|
+
// 幽灵通道(实例已回收但健康文件残留,>2min 未更新)不得撑大订阅数
|
|
5397
|
+
if (byPort[p] && byPort[p].stale) continue;
|
|
5394
5398
|
var n = parseInt(p, 10) - _SUBS_BASE_PROXY_PORT + 1;
|
|
5395
5399
|
if (n > count) count = n;
|
|
5396
5400
|
}
|
package/src/watch/server.js
CHANGED
|
@@ -2047,6 +2047,7 @@ export function startWatchServer(
|
|
|
2047
2047
|
body.stage || "",
|
|
2048
2048
|
body.errorStack || "",
|
|
2049
2049
|
body.hostname || req.headers["x-client-hostname"] || "",
|
|
2050
|
+
body.clientId || req.headers["x-watchdog-client-id"] || "",
|
|
2050
2051
|
);
|
|
2051
2052
|
sendJSON(res, 200, { ok: true });
|
|
2052
2053
|
} else {
|
package/src/watchdog/agent.js
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
getLocalIP,
|
|
14
14
|
getClashHealth,
|
|
15
15
|
getClashHealthChannels,
|
|
16
|
+
setWatchdogClientId,
|
|
16
17
|
} from "./reporter.js";
|
|
17
18
|
import { ProcessManager } from "./process-manager.js";
|
|
18
19
|
import {
|
|
@@ -27,7 +28,10 @@ import { join, dirname } from "path";
|
|
|
27
28
|
import { homedir, tmpdir, hostname as osHostname } from "os";
|
|
28
29
|
import { fileURLToPath } from "url";
|
|
29
30
|
import { pushSelfLog, getSelfLogs } from "./self-log-buffer.js";
|
|
30
|
-
import {
|
|
31
|
+
import {
|
|
32
|
+
ClientErrorMonitor,
|
|
33
|
+
DETECT_CAPTCHA_COUNT_THRESHOLD,
|
|
34
|
+
} from "./client-error-monitor.js";
|
|
31
35
|
import { takeoverEnvironment } from "./env-init.js";
|
|
32
36
|
|
|
33
37
|
// ── 看门狗自身日志入环(供后台页面按需拉取,经 report-logs 回传落盘) ──
|
|
@@ -340,6 +344,14 @@ export class WatchdogAgent {
|
|
|
340
344
|
`[watchdog] 升级: ${isGlobalInstall() ? "已启用" : "跳过(源码模式)"}`,
|
|
341
345
|
);
|
|
342
346
|
|
|
347
|
+
// 把 clientId 写入 ~/.tt-help.json:业务进程上报异常时携带,看门狗按
|
|
348
|
+
// clientId(每台唯一)关联本机条目;克隆机群 hostname 重复不可用
|
|
349
|
+
if (!setWatchdogClientId(this.clientId)) {
|
|
350
|
+
console.error(
|
|
351
|
+
`[watchdog] ⚠️ clientId 写入 ~/.tt-help.json 失败,客户端异常监控将无法关联本机条目`,
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
|
|
343
355
|
// 注册信号处理
|
|
344
356
|
this._setupSignalHandlers();
|
|
345
357
|
|
|
@@ -350,7 +362,11 @@ export class WatchdogAgent {
|
|
|
350
362
|
spawnDaemon: this.spawnDaemon,
|
|
351
363
|
selfPid: process.pid,
|
|
352
364
|
});
|
|
353
|
-
if (
|
|
365
|
+
if (
|
|
366
|
+
takeover.agents.length ||
|
|
367
|
+
takeover.daemons.length ||
|
|
368
|
+
takeover.orphans.length
|
|
369
|
+
) {
|
|
354
370
|
console.error(
|
|
355
371
|
`[watchdog] 环境初始化: 接管旧 agent ${takeover.agents.length} 个、旧 daemon ${takeover.daemons.length} 个、孤儿命令 ${takeover.orphans.length} 个`,
|
|
356
372
|
);
|
|
@@ -719,7 +735,8 @@ export class WatchdogAgent {
|
|
|
719
735
|
|
|
720
736
|
/**
|
|
721
737
|
* 启动客户端异常监控:定时拉取 watch 服务端异常列表,
|
|
722
|
-
* 命中本机条目(reportCount > 8
|
|
738
|
+
* 命中本机条目(reportCount > 8 或 captchaCount ≥ 3,且最近 2 分钟内)
|
|
739
|
+
* → 强切代理 + 删除命中条目(防下一轮轮询重复强切)。
|
|
723
740
|
*/
|
|
724
741
|
_startClientErrorMonitor() {
|
|
725
742
|
const cfg = getClientConfig();
|
|
@@ -732,18 +749,27 @@ export class WatchdogAgent {
|
|
|
732
749
|
}
|
|
733
750
|
this.clientErrorMonitor = new ClientErrorMonitor({
|
|
734
751
|
serverUrl: server,
|
|
735
|
-
hostname: osHostname(),
|
|
752
|
+
hostname: osHostname(), // 仅展示;关联用 clientId
|
|
753
|
+
clientId: this.clientId, // 唯一机器标识,克隆机 hostname 重复不可用
|
|
736
754
|
intervalMs: 60_000,
|
|
737
|
-
onAlert: async (mine) => {
|
|
738
|
-
const ids =
|
|
739
|
-
|
|
740
|
-
|
|
755
|
+
onAlert: async (mine, hit) => {
|
|
756
|
+
const ids = hit.map((e) => e.userId).join(",");
|
|
757
|
+
// 验证码计数与业务错误累计共用监控通道,强切原因区分便于排障
|
|
758
|
+
const hasCaptcha = hit.some(
|
|
759
|
+
(e) =>
|
|
760
|
+
typeof e.captchaCount === "number" &&
|
|
761
|
+
e.captchaCount >= DETECT_CAPTCHA_COUNT_THRESHOLD,
|
|
741
762
|
);
|
|
742
|
-
await this._forceFailover("client-errors");
|
|
743
|
-
const removed = await this.clientErrorMonitor.removeErrors(mine);
|
|
744
763
|
console.error(
|
|
745
|
-
`[watchdog]
|
|
764
|
+
`[watchdog] 客户端异常监控命中 ${hit.length} 条本机异常 (${ids})${hasCaptcha ? " [含验证码]" : ""},强制切换代理并清理`,
|
|
765
|
+
);
|
|
766
|
+
await this._forceFailover(
|
|
767
|
+
hasCaptcha ? "client-captcha" : "client-errors",
|
|
746
768
|
);
|
|
769
|
+
// 仅清理命中条目:命中条目留存会持续满足阈值 → 下轮轮询重复强切;
|
|
770
|
+
// 未命中的本机条目(计数未达标)保留继续累计
|
|
771
|
+
const removed = await this.clientErrorMonitor.removeErrors(hit);
|
|
772
|
+
console.error(`[watchdog] 已清理命中异常条目 ${removed}/${hit.length}`);
|
|
747
773
|
},
|
|
748
774
|
});
|
|
749
775
|
this.clientErrorMonitor.start();
|
|
@@ -844,7 +870,10 @@ export class WatchdogAgent {
|
|
|
844
870
|
applyClientConfig(data.clientConfig);
|
|
845
871
|
}
|
|
846
872
|
// 服务端下发的代理订阅配置(变化才转发给 daemon 热应用;daemon 侧幂等)
|
|
847
|
-
if (
|
|
873
|
+
if (
|
|
874
|
+
data.proxySettings &&
|
|
875
|
+
Array.isArray(data.proxySettings.subscriptions)
|
|
876
|
+
) {
|
|
848
877
|
const sig = JSON.stringify(data.proxySettings.subscriptions);
|
|
849
878
|
if (sig !== this._proxySubsSig) {
|
|
850
879
|
this._proxySubsSig = sig;
|
|
@@ -3,30 +3,55 @@
|
|
|
3
3
|
*
|
|
4
4
|
* 职责:
|
|
5
5
|
* 1. 定时拉取 watch 服务端 /api/client-errors
|
|
6
|
-
* 2. 按
|
|
6
|
+
* 2. 按 clientId 过滤出本机条目(异常条目由业务进程上报时携带 watchdog clientId)
|
|
7
7
|
* 3. 逐条过独立检测函数,命中 → onAlert(本机全部条目, 命中条目)
|
|
8
8
|
* 4. 提供 removeErrors 删除接口(处理完成后的清理)
|
|
9
9
|
*
|
|
10
|
-
*
|
|
10
|
+
* 检测函数(两条命中路径,满足任一即命中):
|
|
11
|
+
* A. reportCount > 8(非验证码业务错误累计)且最近一次上报在 2 分钟内
|
|
12
|
+
* B. captchaCount ≥ 3(webserver 验证码上报,服务端 10 分钟过期窗口内累计)
|
|
13
|
+
*
|
|
14
|
+
* ⚠️ 本机关联必须用 clientId(如 F-9@WIN-AADJ1HTCCAQ,每台唯一):克隆机群
|
|
15
|
+
* hostname 全部相同,按 hostname 关联会把兄弟机器的条目当自己的 → 误删条目 +
|
|
16
|
+
* 误强切(2026-09-28 F-9 条目被 F-10/F-6 抢删事故)。旧条目无 clientId 不参与
|
|
17
|
+
* 触发(宁漏不误:漏切由业务自带 failover + daemon 冷却兑底)。
|
|
11
18
|
*/
|
|
12
19
|
|
|
13
20
|
import os from "os";
|
|
14
21
|
|
|
15
|
-
/**
|
|
22
|
+
/** 检测阈值:非验证码错误累计上报次数 > 8 */
|
|
16
23
|
export const DETECT_REPORT_COUNT_THRESHOLD = 8;
|
|
24
|
+
/** 检测阈值:验证码次数 ≥ 3(10 分钟过期窗口内 3 次独立验证码事件) */
|
|
25
|
+
export const DETECT_CAPTCHA_COUNT_THRESHOLD = 3;
|
|
17
26
|
/** 检测窗口:最近一次上报距今 < 2 分钟 */
|
|
18
27
|
export const DETECT_FRESH_MS = 2 * 60 * 1000;
|
|
19
28
|
|
|
20
29
|
/**
|
|
21
|
-
*
|
|
30
|
+
* 独立检测函数
|
|
22
31
|
* @param {object} err - /api/client-errors 返回的单条异常
|
|
23
32
|
* @param {number} [now] - 当前时间戳(测试注入用)
|
|
24
33
|
* @returns {boolean}
|
|
25
34
|
*/
|
|
26
35
|
export function detectClientError(err, now = Date.now()) {
|
|
27
|
-
if (!err || typeof err.
|
|
36
|
+
if (!err || typeof err.timestamp !== "number") return false;
|
|
28
37
|
const fresh = now - err.timestamp < DETECT_FRESH_MS;
|
|
29
|
-
|
|
38
|
+
if (!fresh) return false;
|
|
39
|
+
// 路径 A:非验证码业务错误累计
|
|
40
|
+
if (
|
|
41
|
+
typeof err.reportCount === "number" &&
|
|
42
|
+
err.reportCount > DETECT_REPORT_COUNT_THRESHOLD
|
|
43
|
+
) {
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
// 路径 B:验证码计数达标(每次验证码上报都刷新 timestamp,达标时必然新鲜,
|
|
47
|
+
// freshness 主要防陈旧累计延迟触发)
|
|
48
|
+
if (
|
|
49
|
+
typeof err.captchaCount === "number" &&
|
|
50
|
+
err.captchaCount >= DETECT_CAPTCHA_COUNT_THRESHOLD
|
|
51
|
+
) {
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
return false;
|
|
30
55
|
}
|
|
31
56
|
|
|
32
57
|
/**
|
|
@@ -36,14 +61,24 @@ export class ClientErrorMonitor {
|
|
|
36
61
|
/**
|
|
37
62
|
* @param {object} opts
|
|
38
63
|
* @param {string} opts.serverUrl - watch 服务端地址(~/.tt-help.json 的 server)
|
|
39
|
-
* @param {string}
|
|
64
|
+
* @param {string} opts.clientId - 本机 watchdog clientId(唯一机器标识,必传;
|
|
65
|
+
* 未传则不匹配任何条目,客户端异常监控不生效)
|
|
66
|
+
* @param {string} [opts.hostname] - 本机 hostname(仅日志展示用,不再参与关联)
|
|
40
67
|
* @param {number} [opts.intervalMs] - 拉取周期,默认 60s
|
|
41
68
|
* @param {(err: object, now: number) => boolean} [opts.detect] - 检测函数,默认 detectClientError
|
|
42
|
-
* @param {(
|
|
69
|
+
* @param {(err: object, now: number) => boolean} [opts.onAlert] - 命中回调
|
|
43
70
|
*/
|
|
44
|
-
constructor({
|
|
71
|
+
constructor({
|
|
72
|
+
serverUrl,
|
|
73
|
+
hostname,
|
|
74
|
+
clientId,
|
|
75
|
+
intervalMs = 60_000,
|
|
76
|
+
detect,
|
|
77
|
+
onAlert,
|
|
78
|
+
}) {
|
|
45
79
|
this.serverUrl = serverUrl ? String(serverUrl).replace(/\/+$/, "") : "";
|
|
46
|
-
this.hostname = hostname || os.hostname();
|
|
80
|
+
this.hostname = hostname || os.hostname(); // 仅展示用
|
|
81
|
+
this.clientId = clientId || ""; // 本机关联唯一依据
|
|
47
82
|
this.intervalMs = intervalMs;
|
|
48
83
|
this.detect = detect || detectClientError;
|
|
49
84
|
this.onAlert = onAlert || null;
|
|
@@ -85,10 +120,11 @@ export class ClientErrorMonitor {
|
|
|
85
120
|
if (!resp.ok) return null;
|
|
86
121
|
const data = await resp.json();
|
|
87
122
|
const clients = Array.isArray(data.clients) ? data.clients : [];
|
|
88
|
-
// hostname
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
123
|
+
// 严格 clientId 匹配:克隆机群 hostname 重复,绝不用 hostname 关联;
|
|
124
|
+
// 未配置 clientId(异常)或条目无 clientId(旧版客户端上报)均不匹配
|
|
125
|
+
const mine = this.clientId
|
|
126
|
+
? clients.filter((c) => c.clientId && c.clientId === this.clientId)
|
|
127
|
+
: [];
|
|
92
128
|
if (mine.length === 0) return null;
|
|
93
129
|
const now = Date.now();
|
|
94
130
|
const hit = mine.filter((c) => this.detect(c, now));
|
package/src/watchdog/reporter.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import os from "os";
|
|
7
|
-
import { existsSync, readFileSync, readdirSync } from "fs";
|
|
7
|
+
import { existsSync, readFileSync, readdirSync, writeFileSync } from "fs";
|
|
8
8
|
import { join } from "path";
|
|
9
9
|
import { homedir, tmpdir } from "os";
|
|
10
10
|
|
|
@@ -12,6 +12,47 @@ const homeDir = homedir() || process.env.HOME || process.env.USERPROFILE || "";
|
|
|
12
12
|
const CONFIG_PATH = join(homeDir, ".tt-help.json");
|
|
13
13
|
const CLASH_HEALTH_FILE = join(tmpdir(), "clash-health.json");
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* 写入本机 watchdog clientId 到 ~/.tt-help.json(业务进程上报异常时读取携带)
|
|
17
|
+
*
|
|
18
|
+
* 背景:克隆机群 hostname 全部相同(如 F-2~F-10 均为 WIN-AADJ1HTCCAQ),
|
|
19
|
+
* 看门狗按 hostname 关联「本机异常」会把兄弟机器的条目当自己的 → 互删条目、
|
|
20
|
+
* 误强切。clientId(如 F-9@WIN-AADJ1HTCCAQ)每台唯一,取代 hostname 做关联。
|
|
21
|
+
* @param {string} id - watchdog clientId(--client-id 参数值)
|
|
22
|
+
* @returns {boolean} 是否写入成功(失败不阻断启动,仅影响异常关联精度)
|
|
23
|
+
*/
|
|
24
|
+
export function setWatchdogClientId(id) {
|
|
25
|
+
if (!id) return false;
|
|
26
|
+
try {
|
|
27
|
+
let cfg = {};
|
|
28
|
+
if (existsSync(CONFIG_PATH)) {
|
|
29
|
+
cfg = JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
|
|
30
|
+
}
|
|
31
|
+
if (cfg.watchdogClientId === id) return true; // 已一致,免写
|
|
32
|
+
cfg.watchdogClientId = id;
|
|
33
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2), "utf-8");
|
|
34
|
+
return true;
|
|
35
|
+
} catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 读取本机 watchdog clientId(业务进程上报异常时携带;watchdog 未启动过/未写入时返回 "")
|
|
42
|
+
* @returns {string}
|
|
43
|
+
*/
|
|
44
|
+
export function getWatchdogClientId() {
|
|
45
|
+
try {
|
|
46
|
+
if (existsSync(CONFIG_PATH)) {
|
|
47
|
+
const cfg = JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
|
|
48
|
+
return cfg.watchdogClientId || "";
|
|
49
|
+
}
|
|
50
|
+
} catch {
|
|
51
|
+
// 配置不可读
|
|
52
|
+
}
|
|
53
|
+
return "";
|
|
54
|
+
}
|
|
55
|
+
|
|
15
56
|
/**
|
|
16
57
|
* 获取系统信息
|
|
17
58
|
*/
|