tt-help-cli-ycl 1.4.132 → 1.4.134
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/scripts/test-daemon-failover.mjs +32 -2
- package/src/clash/api.js +39 -0
- package/src/clash/daemon.js +40 -9
- package/src/watch/db-stats.js +3 -3
- package/src/watch/server.js +54 -42
package/package.json
CHANGED
|
@@ -28,7 +28,9 @@ const state = {
|
|
|
28
28
|
nodes: ["🇭🇰 香港01", "🇲🇾 马来02"],
|
|
29
29
|
currentNode: "🇭🇰 香港01",
|
|
30
30
|
switches: [],
|
|
31
|
-
closes: 0,
|
|
31
|
+
closes: 0, // closeAllConnections(全断)调用次数
|
|
32
|
+
idleCloses: 0, // closeIdleConnections(优雅断连)调用次数
|
|
33
|
+
failIdleClose: false, // 模拟优雅断连失败
|
|
32
34
|
};
|
|
33
35
|
|
|
34
36
|
function resetState() {
|
|
@@ -45,6 +47,8 @@ function resetState() {
|
|
|
45
47
|
state.currentNode = "🇭🇰 香港01";
|
|
46
48
|
state.switches = [];
|
|
47
49
|
state.closes = 0;
|
|
50
|
+
state.idleCloses = 0;
|
|
51
|
+
state.failIdleClose = false;
|
|
48
52
|
}
|
|
49
53
|
|
|
50
54
|
// ── 注册模块 mock(必须在 import daemon.js 之前) ──
|
|
@@ -82,6 +86,11 @@ before(async () => {
|
|
|
82
86
|
closeAllConnections: async () => {
|
|
83
87
|
state.closes += 1;
|
|
84
88
|
},
|
|
89
|
+
closeIdleConnections: async () => {
|
|
90
|
+
if (state.failIdleClose) throw new Error("mock 优雅断连失败");
|
|
91
|
+
state.idleCloses += 1;
|
|
92
|
+
return { closed: 3, active: 2 };
|
|
93
|
+
},
|
|
85
94
|
},
|
|
86
95
|
});
|
|
87
96
|
mock.module("../src/clash/ipc.js", {
|
|
@@ -119,7 +128,7 @@ test("F1 探针健康:冷却期外首次 failover 即轮换,并更新锚点"
|
|
|
119
128
|
c._lastSwitchAt = Date.now() - 600_001; // 距上次切换 > 10min 冷却
|
|
120
129
|
await d._handleFailover({ reason: "F1" }, c);
|
|
121
130
|
assert.deepEqual(state.switches, ["🇲🇾 马来02"], "冷却期外应轮换切换");
|
|
122
|
-
assert.equal(state.
|
|
131
|
+
assert.equal(state.idleCloses, 1, "切换后应优雅断连(空闲回收)");
|
|
123
132
|
assert.ok(
|
|
124
133
|
c._lastSwitchAt && c._lastSwitchAt > Date.now() - 5000,
|
|
125
134
|
"切换后应更新锚点",
|
|
@@ -199,3 +208,24 @@ test("F7 候选集为空:触发 delay 初测且不切换", async () => {
|
|
|
199
208
|
assert.equal(state.switches.length, 0, "无候选不应切换");
|
|
200
209
|
assert.ok(c._lastProbeAt, "应触发过 delay 初测");
|
|
201
210
|
});
|
|
211
|
+
|
|
212
|
+
test("F8 优雅断连失败:回退全断,切换仍生效", async () => {
|
|
213
|
+
resetState();
|
|
214
|
+
const { d, c } = makeCtx();
|
|
215
|
+
c._lastSwitchAt = Date.now() - 600_001; // 冷却期外
|
|
216
|
+
state.failIdleClose = true; // 优雅断连抛错
|
|
217
|
+
await d._handleFailover({ reason: "F8" }, c);
|
|
218
|
+
assert.deepEqual(state.switches, ["🇲🇾 马来02"], "切换仍应执行");
|
|
219
|
+
assert.equal(state.idleCloses, 0, "优雅断连失败未计数");
|
|
220
|
+
assert.equal(state.closes, 1, "失败后应回退全断");
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("F9 disconnectMode=full:直接全断", async () => {
|
|
224
|
+
resetState();
|
|
225
|
+
const { d, c } = makeCtx({ disconnectMode: "full" });
|
|
226
|
+
c._lastSwitchAt = Date.now() - 600_001;
|
|
227
|
+
await d._handleFailover({ reason: "F9" }, c);
|
|
228
|
+
assert.deepEqual(state.switches, ["🇲🇾 马来02"], "切换应执行");
|
|
229
|
+
assert.equal(state.idleCloses, 0, "full 模式不走优雅断连");
|
|
230
|
+
assert.equal(state.closes, 1, "full 模式直接全断");
|
|
231
|
+
});
|
package/src/clash/api.js
CHANGED
|
@@ -165,6 +165,45 @@ export async function closeConnection(id) {
|
|
|
165
165
|
export async function closeAllConnections() {
|
|
166
166
|
return request("/connections", { method: "DELETE" });
|
|
167
167
|
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* 优雅断连:只关闭"空闲"连接(两次采样间无流量增长),活跃连接放行让其自然完成
|
|
171
|
+
* 切换节点后调用:正在进行的请求不被打断,空闲 keep-alive 被回收使新请求立即走新节点
|
|
172
|
+
* @param {object} [opts]
|
|
173
|
+
* @param {number} [opts.sampleMs=1000] - 两次采样间隔(毫秒)
|
|
174
|
+
* @returns {Promise<{closed:number, active:number}>} closed=已断开空闲数, active=放行的活跃/新建数
|
|
175
|
+
*/
|
|
176
|
+
export async function closeIdleConnections({ sampleMs = 1000 } = {}) {
|
|
177
|
+
const snap1 = await getConnections();
|
|
178
|
+
await new Promise((r) => setTimeout(r, sampleMs));
|
|
179
|
+
const snap2 = await getConnections();
|
|
180
|
+
const map1 = new Map((snap1.connections || []).map((c) => [c.id, c]));
|
|
181
|
+
const idleIds = [];
|
|
182
|
+
let active = 0;
|
|
183
|
+
for (const c of snap2.connections || []) {
|
|
184
|
+
const c1 = map1.get(c.id);
|
|
185
|
+
// 新建连接(快照1没有):可能是刚发起的请求,保守放行避免打断
|
|
186
|
+
if (!c1) {
|
|
187
|
+
active++;
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (c1.download === c.download && c1.upload === c.upload)
|
|
191
|
+
idleIds.push(c.id);
|
|
192
|
+
else active++;
|
|
193
|
+
}
|
|
194
|
+
let closed = 0;
|
|
195
|
+
await Promise.all(
|
|
196
|
+
idleIds.map(async (id) => {
|
|
197
|
+
try {
|
|
198
|
+
await closeConnection(id);
|
|
199
|
+
closed++;
|
|
200
|
+
} catch {
|
|
201
|
+
/* 单个失败忽略,其余继续 */
|
|
202
|
+
}
|
|
203
|
+
}),
|
|
204
|
+
);
|
|
205
|
+
return { closed, active };
|
|
206
|
+
}
|
|
168
207
|
export async function getRules() {
|
|
169
208
|
return request("/rules");
|
|
170
209
|
}
|
package/src/clash/daemon.js
CHANGED
|
@@ -42,6 +42,10 @@ export class ClashDaemon {
|
|
|
42
42
|
opts.failoverEscapeAfter > 0
|
|
43
43
|
? opts.failoverEscapeAfter
|
|
44
44
|
: FAILOVER_ESCAPE_AFTER;
|
|
45
|
+
// 切换后断连策略:graceful=优雅断连(只断空闲连接,活跃放行),full=全断(紧急回退)
|
|
46
|
+
this.disconnectMode = opts.disconnectMode === "full" ? "full" : "graceful";
|
|
47
|
+
this.disconnectSampleMs =
|
|
48
|
+
opts.disconnectSampleMs > 0 ? opts.disconnectSampleMs : 1000;
|
|
45
49
|
this.dryRun = !!opts.dryRun;
|
|
46
50
|
// 控制端发现参数(CLI 显式值优先,内部再合并 ~/.tt-help.json)
|
|
47
51
|
this._discoverOpts = {
|
|
@@ -103,6 +107,31 @@ export class ClashDaemon {
|
|
|
103
107
|
if (c) c._lastSwitchAt = Date.now();
|
|
104
108
|
}
|
|
105
109
|
|
|
110
|
+
/**
|
|
111
|
+
* 切换后断连(策略可配):
|
|
112
|
+
* graceful(默认)— 优雅断连:只关"空闲"连接(两次采样无流量增长),
|
|
113
|
+
* 活跃连接放行自然完成,不打断正在进行的请求;
|
|
114
|
+
* 失败回退全断,确保切换即时生效。
|
|
115
|
+
* full — 全量断连(旧行为,紧急回退)。
|
|
116
|
+
*/
|
|
117
|
+
async _disconnect(c) {
|
|
118
|
+
if (this.disconnectMode === "full") {
|
|
119
|
+
await api.closeAllConnections().catch(() => {});
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
const r = await api.closeIdleConnections({
|
|
124
|
+
sampleMs: this.disconnectSampleMs,
|
|
125
|
+
});
|
|
126
|
+
this._log(
|
|
127
|
+
`[${c.proxyPort}] 优雅断连: 空闲 ${r.closed} 断 / 活跃 ${r.active} 放行`,
|
|
128
|
+
);
|
|
129
|
+
} catch (e) {
|
|
130
|
+
this._log(`[${c.proxyPort}] 优雅断连失败,回退全断: ${e.message}`);
|
|
131
|
+
await api.closeAllConnections().catch(() => {});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
106
135
|
/**
|
|
107
136
|
* 壳页判定:TikTok SSR 降级响应(HTTP 200 + 无跳转 + 无 error + 无 title)。
|
|
108
137
|
* 壳页 = 加载页(data-downgrade 骨架屏),链路正常、浏览器会自行渲染,不算节点故障。
|
|
@@ -435,7 +464,7 @@ export class ClashDaemon {
|
|
|
435
464
|
await checker.switchToNode(node).catch(() => {});
|
|
436
465
|
this._markSwitch(c, node);
|
|
437
466
|
this._markSwitchTime(c);
|
|
438
|
-
await
|
|
467
|
+
await this._disconnect(c);
|
|
439
468
|
this._log(
|
|
440
469
|
`✗ [${c.proxyPort}] 恢复原节点 ${node}(原因: ${reason} — 候选全部不可用)`,
|
|
441
470
|
);
|
|
@@ -451,9 +480,9 @@ export class ClashDaemon {
|
|
|
451
480
|
this._log(
|
|
452
481
|
`⚠ [${c.proxyPort}] 节点切换 [巡检异常: ${reason}]: ${node} → ${target} (${h2.timeTotal.toFixed(1)}s ${(h2.sizeDownload / 1024).toFixed(0)}KB)`,
|
|
453
482
|
);
|
|
454
|
-
//
|
|
455
|
-
//
|
|
456
|
-
await
|
|
483
|
+
// 切换后优雅断连:回收空闲 keep-alive 使新请求走新节点;活跃请求放行不打断
|
|
484
|
+
// 旧连接若不回收会继续走旧节点导致"时好时坏"(实测可存活 8+ 分钟)
|
|
485
|
+
await this._disconnect(c);
|
|
457
486
|
this._markSwitchTime(c);
|
|
458
487
|
this._writeHealth(c, {
|
|
459
488
|
node: target,
|
|
@@ -595,8 +624,8 @@ export class ClashDaemon {
|
|
|
595
624
|
this._markSwitch(c, cmd.node);
|
|
596
625
|
this._markSwitchTime(c);
|
|
597
626
|
const h = checker.checkNodeHealth();
|
|
598
|
-
//
|
|
599
|
-
await
|
|
627
|
+
// 切换后优雅断连:回收空闲连接,活跃放行(同巡检切换)
|
|
628
|
+
await this._disconnect(c);
|
|
600
629
|
this._log(
|
|
601
630
|
`🔄 看门狗指令: [${c.proxyPort}] 切换完成 (${h.timeTotal.toFixed(1)}s ${(h.sizeDownload / 1024).toFixed(0)}KB, ${h.healthy ? "健康" : "异常"})`,
|
|
602
631
|
);
|
|
@@ -700,7 +729,7 @@ export class ClashDaemon {
|
|
|
700
729
|
if (!target) {
|
|
701
730
|
await checker.switchToNode(node).catch(() => {});
|
|
702
731
|
this._markSwitch(c, node);
|
|
703
|
-
await
|
|
732
|
+
await this._disconnect(c);
|
|
704
733
|
this._log(
|
|
705
734
|
`✗ failover [${reason}] — 候选全部不可用,恢复原节点 ${node}`,
|
|
706
735
|
);
|
|
@@ -712,8 +741,8 @@ export class ClashDaemon {
|
|
|
712
741
|
});
|
|
713
742
|
}
|
|
714
743
|
this._log(`⚠ 节点切换 [${reason}]: ${node} → ${target}`);
|
|
715
|
-
//
|
|
716
|
-
await
|
|
744
|
+
// 切换后优雅断连:回收空闲连接,活跃放行(同巡检切换)
|
|
745
|
+
await this._disconnect(c);
|
|
717
746
|
this._writeHealth(c, {
|
|
718
747
|
node: target,
|
|
719
748
|
healthy: h2.healthy,
|
|
@@ -841,5 +870,7 @@ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
|
|
841
870
|
failoverCooldownMs: parseInt(getArg("--failover-cooldown-ms")) || undefined,
|
|
842
871
|
failoverEscapeAfter:
|
|
843
872
|
parseInt(getArg("--failover-escape-after")) || undefined,
|
|
873
|
+
disconnectMode: getArg("--disconnect-mode") || undefined,
|
|
874
|
+
disconnectSampleMs: parseInt(getArg("--disconnect-sample-ms")) || undefined,
|
|
844
875
|
}).start();
|
|
845
876
|
}
|
package/src/watch/db-stats.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { getDb } from "./db-schema.js";
|
|
8
|
-
import { getRawJobsCount
|
|
8
|
+
import { getRawJobsCount } from "./db-crud.js";
|
|
9
9
|
|
|
10
10
|
export function getDashboardStatsFromDb(targetLocations = [], options = {}) {
|
|
11
11
|
const db = getDb();
|
|
@@ -126,15 +126,15 @@ export function getDashboardStatsFromDb(targetLocations = [], options = {}) {
|
|
|
126
126
|
)
|
|
127
127
|
.get().total;
|
|
128
128
|
|
|
129
|
+
// 注:dbTotalUsers(COUNT(*) FROM users)已移除 ——
|
|
130
|
+
// users 表 3116 万行全表扫约 3s,且仪表盘前端未使用该字段
|
|
129
131
|
const rawJobsCount = skipSlow ? 0 : getRawJobsCount();
|
|
130
|
-
const dbUsersCount = skipSlow ? 0 : getUserDbCount();
|
|
131
132
|
|
|
132
133
|
return {
|
|
133
134
|
totalUsers: aggregateRow.total,
|
|
134
135
|
tagCount,
|
|
135
136
|
commentTaskCount,
|
|
136
137
|
rawJobs: rawJobsCount,
|
|
137
|
-
dbTotalUsers: dbUsersCount,
|
|
138
138
|
jobsTotal: aggregateRow.total,
|
|
139
139
|
jobsBaseTotal: jobsBaseCount,
|
|
140
140
|
jobsPending: aggregateRow.pending,
|
package/src/watch/server.js
CHANGED
|
@@ -26,61 +26,59 @@ function getLocalIP() {
|
|
|
26
26
|
const __dirname = dirname(__filename);
|
|
27
27
|
const publicDir = join(__dirname, "public");
|
|
28
28
|
|
|
29
|
-
// Stats
|
|
29
|
+
// Stats 缓存:避免每次请求都扫描百万行级大表数据
|
|
30
30
|
let statsCache = null;
|
|
31
31
|
let statsCacheTime = 0;
|
|
32
|
-
|
|
32
|
+
// 5 分钟缓存:users 表 3116 万行,COUNT 全表扫约 3s;jobs 162 万行,聚合约 2s。
|
|
33
|
+
// 原 15 秒 TTL 导致每 15 秒一次同步大查询,冻结事件循环 5~30s(成簇超时的根因)
|
|
34
|
+
const STATS_CACHE_TTL = 300000; // 5 分钟
|
|
33
35
|
|
|
34
36
|
// 用户列表短缓存:同一参数组合 3 秒内复用,无论多少客户端同时查只算一次
|
|
35
37
|
const usersCache = new Map(); // key -> { result, time }
|
|
36
38
|
const USERS_CACHE_TTL = 3000; // 3 秒
|
|
37
39
|
|
|
38
|
-
//
|
|
40
|
+
// 慢计数查询(分片执行)。
|
|
41
|
+
// 注:dbTotalUsers(COUNT(*) FROM users)已移除 —— users 表 3116 万行全表扫约 3s,
|
|
42
|
+
// 且该字段前端未使用;users 表为判重索引,数据不能删,只能从统计里去掉。
|
|
39
43
|
const SLOW_COUNT_SQLS = {
|
|
40
44
|
rawJobs: "SELECT COUNT(*) as c FROM raw_jobs",
|
|
41
45
|
commentTaskCount:
|
|
42
46
|
"SELECT COUNT(*) as c FROM videos WHERE user_update_count IS NULL OR user_update_count <= 0",
|
|
43
|
-
dbTotalUsers: "SELECT COUNT(*) as c FROM users",
|
|
44
47
|
tagCount: "SELECT COUNT(*) as c FROM tags",
|
|
45
48
|
tagHighScore: "SELECT COUNT(*) as c FROM tags WHERE score >= 70",
|
|
46
49
|
};
|
|
47
50
|
|
|
48
|
-
function computeSlowCounts(st) {
|
|
49
|
-
const result = {};
|
|
50
|
-
for (const [key, sql] of Object.entries(SLOW_COUNT_SQLS)) {
|
|
51
|
-
try {
|
|
52
|
-
const r = st.rawQuery(sql);
|
|
53
|
-
result[key] = r?.rows?.[0]?.c || 0;
|
|
54
|
-
} catch (e) {
|
|
55
|
-
console.error(`[stats] 慢计数 ${key} 查询失败: ${e.message}`);
|
|
56
|
-
result[key] = 0;
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
return result;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
51
|
let statsRefreshing = false; // 防止并发刷新
|
|
63
52
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
53
|
+
// 分片执行统计刷新:每个 SQL 单独一片,片间 setImmediate 让出事件循环,
|
|
54
|
+
// 避免多个大查询连续执行冻结事件循环 5~30 秒(此前成簇超时的直接原因)
|
|
55
|
+
function refreshStatsInShards(st) {
|
|
56
|
+
const slowKeys = Object.keys(SLOW_COUNT_SQLS);
|
|
57
|
+
const slowCounts = {};
|
|
58
|
+
|
|
59
|
+
function runSlowShard(i) {
|
|
60
|
+
if (i < slowKeys.length) {
|
|
61
|
+
const key = slowKeys[i];
|
|
62
|
+
try {
|
|
63
|
+
const r = st.rawQuery(SLOW_COUNT_SQLS[key]);
|
|
64
|
+
slowCounts[key] = r?.rows?.[0]?.c || 0;
|
|
65
|
+
} catch (e) {
|
|
66
|
+
console.error(`[stats] 慢计数 ${key} 查询失败: ${e.message}`);
|
|
67
|
+
slowCounts[key] = 0;
|
|
68
|
+
}
|
|
69
|
+
setImmediate(() => runSlowShard(i + 1));
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
// 慢计数全部完成 → dashboard 聚合作为最后一片执行
|
|
73
|
+
setImmediate(() => {
|
|
74
74
|
try {
|
|
75
|
-
const slowCounts = computeSlowCounts(st);
|
|
76
75
|
const stats = st.getDashboardStats(DEFAULT_TARGET_LOCATIONS, {
|
|
77
76
|
skipSlowCounts: true,
|
|
78
77
|
});
|
|
79
|
-
stats.rawJobs = slowCounts.rawJobs;
|
|
80
|
-
stats.commentTaskCount = slowCounts.commentTaskCount;
|
|
81
|
-
stats.
|
|
82
|
-
stats.
|
|
83
|
-
stats.tagHighScore = slowCounts.tagHighScore;
|
|
78
|
+
stats.rawJobs = slowCounts.rawJobs || 0;
|
|
79
|
+
stats.commentTaskCount = slowCounts.commentTaskCount || 0;
|
|
80
|
+
stats.tagCount = slowCounts.tagCount || 0;
|
|
81
|
+
stats.tagHighScore = slowCounts.tagHighScore || 0;
|
|
84
82
|
statsCache = stats;
|
|
85
83
|
statsCacheTime = Date.now();
|
|
86
84
|
} catch (e) {
|
|
@@ -89,21 +87,35 @@ function computeStatsIncremental(st) {
|
|
|
89
87
|
statsRefreshing = false;
|
|
90
88
|
}
|
|
91
89
|
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
setImmediate(() => runSlowShard(0));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function computeStatsIncremental(st) {
|
|
96
|
+
const now = Date.now();
|
|
97
|
+
// 缓存有效 → 直接返回
|
|
98
|
+
if (statsCache && now - statsCacheTime < STATS_CACHE_TTL) {
|
|
99
|
+
return statsCache;
|
|
100
|
+
}
|
|
101
|
+
// 缓存过期但存在 → 先返回旧数据,后台分片刷新
|
|
102
|
+
if (statsCache && !statsRefreshing) {
|
|
103
|
+
statsRefreshing = true;
|
|
104
|
+
refreshStatsInShards(st);
|
|
92
105
|
return statsCache;
|
|
93
106
|
}
|
|
94
|
-
// 首次加载 → 同步计算
|
|
95
|
-
|
|
107
|
+
// 首次加载 → 同步计算 dashboard 基础统计(启动后仅一次),
|
|
108
|
+
// 慢计数随后台分片补全,不阻塞本次响应之外的请求
|
|
96
109
|
const stats = st.getDashboardStats(DEFAULT_TARGET_LOCATIONS, {
|
|
97
110
|
skipSlowCounts: true,
|
|
98
111
|
});
|
|
99
|
-
stats.rawJobs = slowCounts.rawJobs;
|
|
100
|
-
stats.commentTaskCount = slowCounts.commentTaskCount;
|
|
101
|
-
stats.dbTotalUsers = slowCounts.dbTotalUsers;
|
|
102
|
-
stats.tagCount = slowCounts.tagCount;
|
|
103
|
-
stats.tagHighScore = slowCounts.tagHighScore;
|
|
104
112
|
statsCache = stats;
|
|
105
113
|
statsCacheTime = now;
|
|
106
|
-
|
|
114
|
+
if (!statsRefreshing) {
|
|
115
|
+
statsRefreshing = true;
|
|
116
|
+
refreshStatsInShards(st);
|
|
117
|
+
}
|
|
118
|
+
return statsCache;
|
|
107
119
|
}
|
|
108
120
|
|
|
109
121
|
function readBody(req) {
|