tt-help-cli-ycl 1.4.144 → 1.4.145
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 +61 -4
- package/src/clash/checker.js +41 -0
- package/src/clash/daemon.js +26 -0
- package/src/cli/tag.js +104 -329
- package/src/lib/constants.js +6 -5
- package/src/watch/data-store.js +88 -0
- package/src/watch/public/app.js +232 -0
- package/src/watch/public/index.html +53 -0
- package/src/watch/public/style.css +202 -0
- package/src/watch/server.js +60 -0
- package/src/watchdog/process-manager.js +11 -6
package/package.json
CHANGED
|
@@ -32,6 +32,12 @@ const state = {
|
|
|
32
32
|
closes: 0, // closeAllConnections(全断)调用次数
|
|
33
33
|
idleCloses: 0, // closeIdleConnections(优雅断连)调用次数
|
|
34
34
|
failIdleClose: false, // 模拟优雅断连失败
|
|
35
|
+
// 订阅切换感知(巡检开头候选集存在性校验)相关
|
|
36
|
+
proxyMap: { "🇭🇰 香港01": {}, "🇲🇾 马来02": {} }, // 当前 /proxies 中的代理名
|
|
37
|
+
pruneResult: { kept: 2, removed: 0, allGone: false },
|
|
38
|
+
pruneCalls: 0,
|
|
39
|
+
probeCalls: 0,
|
|
40
|
+
failProxies: false,
|
|
35
41
|
};
|
|
36
42
|
|
|
37
43
|
function resetState() {
|
|
@@ -50,6 +56,11 @@ function resetState() {
|
|
|
50
56
|
state.closes = 0;
|
|
51
57
|
state.idleCloses = 0;
|
|
52
58
|
state.failIdleClose = false;
|
|
59
|
+
state.proxyMap = { "🇭🇰 香港01": {}, "🇲🇾 马来02": {} };
|
|
60
|
+
state.pruneResult = { kept: 2, removed: 0, allGone: false };
|
|
61
|
+
state.pruneCalls = 0;
|
|
62
|
+
state.probeCalls = 0;
|
|
63
|
+
state.failProxies = false;
|
|
53
64
|
}
|
|
54
65
|
|
|
55
66
|
// ── 注册模块 mock(必须在 import daemon.js 之前) ──
|
|
@@ -73,16 +84,28 @@ before(async () => {
|
|
|
73
84
|
return true;
|
|
74
85
|
},
|
|
75
86
|
applyDiscovered: () => {},
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
87
|
+
checkClashAlive: () => true,
|
|
88
|
+
pruneHealthyListByCurrentProxies: () => {
|
|
89
|
+
state.pruneCalls += 1;
|
|
90
|
+
return { ...state.pruneResult };
|
|
91
|
+
},
|
|
92
|
+
runDelayProbe: async () => {
|
|
93
|
+
state.probeCalls += 1;
|
|
94
|
+
return {
|
|
95
|
+
nodes: [...state.nodes],
|
|
96
|
+
total: state.nodes.length,
|
|
97
|
+
};
|
|
98
|
+
},
|
|
80
99
|
saveHealthyList: () => {},
|
|
81
100
|
},
|
|
82
101
|
});
|
|
83
102
|
mock.module("../src/clash/api.js", {
|
|
84
103
|
namedExports: {
|
|
85
104
|
configure: () => {},
|
|
105
|
+
getProxies: async () => {
|
|
106
|
+
if (state.failProxies) throw new Error("mock /proxies 不可达");
|
|
107
|
+
return { proxies: { ...state.proxyMap } };
|
|
108
|
+
},
|
|
86
109
|
getCurrentNode: async () => state.currentNode,
|
|
87
110
|
closeAllConnections: async () => {
|
|
88
111
|
state.closes += 1;
|
|
@@ -242,3 +265,37 @@ test("F10 强切(force):冷却期内探针健康仍强制轮换", async ()
|
|
|
242
265
|
"强切后应更新锚点(给新节点观察期)",
|
|
243
266
|
);
|
|
244
267
|
});
|
|
268
|
+
|
|
269
|
+
// ── 订阅切换感知:巡检开头候选集存在性校验(2026-08-29) ──
|
|
270
|
+
|
|
271
|
+
test("S1 候选集全部失效:当前节点健康也立即触发 delay 初测", async () => {
|
|
272
|
+
resetState();
|
|
273
|
+
const { d, c } = makeCtx();
|
|
274
|
+
state.health = { ...state.health, healthy: true }; // 当前节点健康
|
|
275
|
+
state.pruneResult = { kept: 0, removed: 2, allGone: true };
|
|
276
|
+
await d._healthCheckController(c);
|
|
277
|
+
assert.equal(state.pruneCalls, 1, "巡检应执行候选集校验");
|
|
278
|
+
// 初测经串行锁排队执行,等队列清空后断言
|
|
279
|
+
await d._withLock(() => {});
|
|
280
|
+
assert.equal(state.probeCalls, 1, "全失效应立即触发 delay 初测");
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("S2 候选集部分失效:剔除后不触发初测,巡检正常", async () => {
|
|
284
|
+
resetState();
|
|
285
|
+
const { d, c } = makeCtx();
|
|
286
|
+
state.pruneResult = { kept: 1, removed: 1, allGone: false };
|
|
287
|
+
await d._healthCheckController(c);
|
|
288
|
+
await d._withLock(() => {});
|
|
289
|
+
assert.equal(state.pruneCalls, 1, "应执行候选集校验");
|
|
290
|
+
assert.equal(state.probeCalls, 0, "部分失效不应触发初测");
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("S3 /proxies 拉取失败:静默跳过校验,不阻断巡检", async () => {
|
|
294
|
+
resetState();
|
|
295
|
+
const { d, c } = makeCtx();
|
|
296
|
+
state.failProxies = true;
|
|
297
|
+
await d._healthCheckController(c);
|
|
298
|
+
await d._withLock(() => {});
|
|
299
|
+
assert.equal(state.pruneCalls, 0, "API 失败不应执行校验");
|
|
300
|
+
assert.equal(state.probeCalls, 0, "API 失败不应触发初测");
|
|
301
|
+
});
|
package/src/clash/checker.js
CHANGED
|
@@ -441,6 +441,47 @@ export function saveHealthyList(nodes, delays = {}) {
|
|
|
441
441
|
}
|
|
442
442
|
}
|
|
443
443
|
|
|
444
|
+
/**
|
|
445
|
+
* 剔除候选集中已不在当前内核配置中的节点(订阅切换 / 订阅更新场景)
|
|
446
|
+
*
|
|
447
|
+
* 基于原始全量列表过滤(不做国家过滤),避免把"国家过滤后的子集"写回文件造成数据丢失。
|
|
448
|
+
* 仅在确有剔除时写回文件。
|
|
449
|
+
*
|
|
450
|
+
* @param {Set<string>|string[]} validNames - 当前 /proxies 中的代理名集合
|
|
451
|
+
* @returns {{kept: number, removed: number, allGone: boolean}}
|
|
452
|
+
* allGone: 原列表非空且过滤后为空(判定"订阅已切换")
|
|
453
|
+
*/
|
|
454
|
+
export function pruneHealthyListByCurrentProxies(validNames) {
|
|
455
|
+
try {
|
|
456
|
+
if (!existsSync(_healthyFile)) {
|
|
457
|
+
return { kept: 0, removed: 0, allGone: false };
|
|
458
|
+
}
|
|
459
|
+
const d = JSON.parse(readFileSync(_healthyFile, "utf-8"));
|
|
460
|
+
const allNodes = Array.isArray(d.nodes) ? d.nodes : [];
|
|
461
|
+
const allDelays = d.delays && typeof d.delays === "object" ? d.delays : {};
|
|
462
|
+
const valid = new Set(validNames);
|
|
463
|
+
const keptNodes = allNodes.filter((n) => valid.has(n));
|
|
464
|
+
const removed = allNodes.length - keptNodes.length;
|
|
465
|
+
if (removed === 0) {
|
|
466
|
+
return { kept: keptNodes.length, removed: 0, allGone: false };
|
|
467
|
+
}
|
|
468
|
+
const keptDelays = {};
|
|
469
|
+
for (const n of keptNodes) {
|
|
470
|
+
const v = allDelays[n];
|
|
471
|
+
if (typeof v === "number" && v > 0) keptDelays[n] = v;
|
|
472
|
+
}
|
|
473
|
+
saveHealthyList(keptNodes, keptDelays);
|
|
474
|
+
return {
|
|
475
|
+
kept: keptNodes.length,
|
|
476
|
+
removed,
|
|
477
|
+
allGone: keptNodes.length === 0 && allNodes.length > 0,
|
|
478
|
+
};
|
|
479
|
+
} catch (e) {
|
|
480
|
+
console.error(`[checker] 剔除失效候选节点失败: ${e.message}`);
|
|
481
|
+
return { kept: 0, removed: 0, allGone: false };
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
444
485
|
function _emptyList() {
|
|
445
486
|
return {
|
|
446
487
|
updated: "",
|
package/src/clash/daemon.js
CHANGED
|
@@ -370,6 +370,32 @@ export class ClashDaemon {
|
|
|
370
370
|
title: h.title,
|
|
371
371
|
});
|
|
372
372
|
|
|
373
|
+
// 订阅切换感知:候选集节点若已不在当前内核配置中,旧候选集无意义
|
|
374
|
+
// —— 全失效立即触发 delay 初测刷新,部分失效剔除写回。
|
|
375
|
+
// /proxies 拉取失败时静默跳过,不阻断巡检。
|
|
376
|
+
try {
|
|
377
|
+
const proxies = await api.getProxies();
|
|
378
|
+
const valid = new Set(Object.keys(proxies?.proxies || {}));
|
|
379
|
+
const pruned = checker.pruneHealthyListByCurrentProxies(valid);
|
|
380
|
+
if (pruned.removed > 0) {
|
|
381
|
+
if (pruned.allGone) {
|
|
382
|
+
this._log(
|
|
383
|
+
`🔁 [${c.proxyPort}] 候选集 ${pruned.removed} 个节点全部失效(疑似订阅已切换),立即触发 delay 初测`,
|
|
384
|
+
);
|
|
385
|
+
// 初测经串行锁排队执行;本轮巡检直接返回,避免长时间占用锁饿死其它控制端
|
|
386
|
+
this._withLock(() => this._runDelayProbeController(c)).catch(
|
|
387
|
+
() => {},
|
|
388
|
+
);
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
this._log(
|
|
392
|
+
`🧹 [${c.proxyPort}] 候选集剔除 ${pruned.removed} 个失效节点,剩余 ${pruned.kept} 个`,
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
} catch {
|
|
396
|
+
// 忽略:校验仅作兜底,失败不影响巡检
|
|
397
|
+
}
|
|
398
|
+
|
|
373
399
|
if (h.healthy) {
|
|
374
400
|
c._apiFailCount = 0;
|
|
375
401
|
if (Math.random() < 0.17)
|