tt-help-cli-ycl 1.4.155 → 1.4.157
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/src/clash/api.js +49 -0
- package/src/clash/checker.js +28 -11
- package/src/clash/daemon.js +112 -1
- package/src/clash/runtime.js +823 -0
- package/src/cli/comments.js +7 -0
- package/src/cli/explore.js +12 -1
- package/src/cli/refresh-mini.js +13 -1
- package/src/cli/refresh.js +13 -1
- package/src/lib/app-config.js +25 -1
- package/src/lib/args.js +9 -0
- package/src/lib/client-locations.js +70 -0
- package/src/watch/public/app.js +163 -2
- package/src/watch/public/index.html +17 -0
- package/src/watch/watchdog-server.js +53 -0
- package/src/watchdog/agent.js +39 -0
- package/src/watchdog/env-init.js +187 -0
- package/src/watchdog/process-manager.js +17 -6
package/src/watchdog/agent.js
CHANGED
|
@@ -28,6 +28,7 @@ import { homedir, tmpdir, hostname as osHostname } from "os";
|
|
|
28
28
|
import { fileURLToPath } from "url";
|
|
29
29
|
import { pushSelfLog, getSelfLogs } from "./self-log-buffer.js";
|
|
30
30
|
import { ClientErrorMonitor } from "./client-error-monitor.js";
|
|
31
|
+
import { takeoverEnvironment } from "./env-init.js";
|
|
31
32
|
|
|
32
33
|
// ── 看门狗自身日志入环(供后台页面按需拉取,经 report-logs 回传落盘) ──
|
|
33
34
|
// 包装 console.error:本进程所有 [watchdog]/[llm-analyzer]/[upgrade] 等日志
|
|
@@ -320,6 +321,25 @@ export class WatchdogAgent {
|
|
|
320
321
|
// 注册信号处理
|
|
321
322
|
this._setupSignalHandlers();
|
|
322
323
|
|
|
324
|
+
// 环境初始化: 接管旧 agent/旧 daemon + 清理孤儿命令进程,防双开互踩
|
|
325
|
+
// (2026-09-25 双开乒乓事故修复,详见 env-init.js)
|
|
326
|
+
try {
|
|
327
|
+
const takeover = await takeoverEnvironment({
|
|
328
|
+
spawnDaemon: this.spawnDaemon,
|
|
329
|
+
selfPid: process.pid,
|
|
330
|
+
});
|
|
331
|
+
if (takeover.agents.length || takeover.daemons.length || takeover.orphans.length) {
|
|
332
|
+
console.error(
|
|
333
|
+
`[watchdog] 环境初始化: 接管旧 agent ${takeover.agents.length} 个、旧 daemon ${takeover.daemons.length} 个、孤儿命令 ${takeover.orphans.length} 个`,
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
// 清空残留 IPC 命令,防止接管后的 daemon 消费过期指令
|
|
337
|
+
const ipc = await import("../clash/ipc.js");
|
|
338
|
+
for (const ch of ipc.listCommandChannels()) ipc.clearCommand(ch);
|
|
339
|
+
} catch (e) {
|
|
340
|
+
console.error(`[watchdog] 环境初始化异常(不阻塞启动): ${e.message}`);
|
|
341
|
+
}
|
|
342
|
+
|
|
323
343
|
// 启动 clash daemon(保代理通道,--no-daemon 可关闭)
|
|
324
344
|
this._daemonStopping = false;
|
|
325
345
|
if (this._daemonRestartTimer) {
|
|
@@ -752,6 +772,25 @@ export class WatchdogAgent {
|
|
|
752
772
|
if (data.clientConfig) {
|
|
753
773
|
applyClientConfig(data.clientConfig);
|
|
754
774
|
}
|
|
775
|
+
// 服务端下发的代理订阅配置(变化才转发给 daemon 热应用;daemon 侧幂等)
|
|
776
|
+
if (data.proxySettings && Array.isArray(data.proxySettings.subscriptions)) {
|
|
777
|
+
const sig = JSON.stringify(data.proxySettings.subscriptions);
|
|
778
|
+
if (sig !== this._proxySubsSig) {
|
|
779
|
+
this._proxySubsSig = sig;
|
|
780
|
+
try {
|
|
781
|
+
const ipc = await import("../clash/ipc.js");
|
|
782
|
+
ipc.writeCommand({
|
|
783
|
+
action: "setSubscription",
|
|
784
|
+
subscriptions: data.proxySettings.subscriptions,
|
|
785
|
+
});
|
|
786
|
+
console.error(
|
|
787
|
+
`[watchdog] 下发订阅配置: ${data.proxySettings.subscriptions.length} 条`,
|
|
788
|
+
);
|
|
789
|
+
} catch (e) {
|
|
790
|
+
console.error(`[watchdog] 下发订阅配置失败: ${e.message}`);
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
}
|
|
755
794
|
// 服务端下发的错误触发切换配置(默认不启用,后端下发控制)
|
|
756
795
|
if (data.errorTrigger) {
|
|
757
796
|
this._applyErrorTriggerConfig(data.errorTrigger);
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* watchdog 环境初始化 — 新实例启动时接管旧环境
|
|
3
|
+
*
|
|
4
|
+
* 解决 2026-09-25 实测踩坑(Mac 本机双开事故):
|
|
5
|
+
* - 双 agent 同跑: 各自批量启动同名命令互杀(webserver 3000 端口乒乓循环)
|
|
6
|
+
* - 双 daemon 同跑: 抢同一 mihomo 控制端、共写 health/IPC 文件、切换指令抢消费
|
|
7
|
+
* - 孤儿命令进程: 旧实例异常死亡后 webserver/refresh 残留占端口/占 CDP
|
|
8
|
+
*
|
|
9
|
+
* 原则: 只清理"同签名"进程,不碰任何无关程序。
|
|
10
|
+
* agent 签名 = node + watchdog + (tt-help | main.js | cli.js)
|
|
11
|
+
* daemon 签名 = node + clash/daemon.js(仅 spawnDaemon 模式接管,独立部署的 daemon 不动)
|
|
12
|
+
* 接管顺序: 先 TERM 旧 agent(其退出钩子会自行清理托管子进程),
|
|
13
|
+
* 等待退出后对残留进程 KILL;孤儿命令进程(ppid=1)一并回收。
|
|
14
|
+
*
|
|
15
|
+
* 注意: 若未来用 LaunchAgent/nssm 服务化托管 webserver/refresh(不经 agent),
|
|
16
|
+
* 孤儿回收需按服务签名加白名单,避免与服务管理器互相拉起。
|
|
17
|
+
*/
|
|
18
|
+
import { execSync } from "node:child_process";
|
|
19
|
+
|
|
20
|
+
const IS_WIN = process.platform === "win32";
|
|
21
|
+
|
|
22
|
+
const delay = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
23
|
+
|
|
24
|
+
/** agent 进程签名(node + watchdog + 启动入口三选一;排除编辑器/grep/bash 包装等含关键字的无关进程) */
|
|
25
|
+
function isAgentCmd(cmd) {
|
|
26
|
+
if (!/watchdog/.test(cmd) || !/(tt-help|main\.js|cli\.js)/.test(cmd)) return false;
|
|
27
|
+
if (IS_WIN) return /\bnode\b/.test(cmd); // Windows 可能有 cmd.exe 包装层,宽松匹配
|
|
28
|
+
return /^(?:[^\s]*[\/\\])?node(\.exe)?\s/.test(cmd); // unix: 首 token 必须是 node
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** clash daemon 进程签名 */
|
|
32
|
+
function isDaemonCmd(cmd) {
|
|
33
|
+
if (!/clash[\/\\]+daemon\.js/.test(cmd)) return false;
|
|
34
|
+
if (IS_WIN) return /\bnode\b/.test(cmd);
|
|
35
|
+
return /^(?:[^\s]*[\/\\])?node(\.exe)?\s/.test(cmd);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** agent 托管命令进程签名(webserver/refresh,用于孤儿回收) */
|
|
39
|
+
function isManagedCmdCmd(cmd) {
|
|
40
|
+
if (!/(tt-help|main\.js|cli\.js) (webserver|refresh)\b/.test(cmd)) return false;
|
|
41
|
+
if (IS_WIN) return /\bnode\b/.test(cmd);
|
|
42
|
+
return /^(?:[^\s]*[\/\\])?node(\.exe)?\s/.test(cmd);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 列出本机进程 [{pid, ppid, cmd}](跨平台;失败返回空数组 = 跳过本次接管,不阻塞启动) */
|
|
46
|
+
export function listProcesses() {
|
|
47
|
+
try {
|
|
48
|
+
if (IS_WIN) {
|
|
49
|
+
let out;
|
|
50
|
+
try {
|
|
51
|
+
// wmic: CSV 首列主机名,末两列 ParentProcessId/ProcessId;cmdline 含逗号用切片解析
|
|
52
|
+
out = execSync("wmic process get ProcessId,ParentProcessId,CommandLine /format:csv", {
|
|
53
|
+
encoding: "utf-8",
|
|
54
|
+
timeout: 15000,
|
|
55
|
+
windowsHide: true,
|
|
56
|
+
});
|
|
57
|
+
const res = [];
|
|
58
|
+
for (const line of out.split(/\r?\n/)) {
|
|
59
|
+
if (!line.trim() || /^Node,/i.test(line)) continue;
|
|
60
|
+
const parts = line.split(",");
|
|
61
|
+
if (parts.length < 3) continue;
|
|
62
|
+
const pid = parseInt(parts[parts.length - 1], 10);
|
|
63
|
+
const ppid = parseInt(parts[parts.length - 2], 10);
|
|
64
|
+
const cmd = parts.slice(1, parts.length - 2).join(",");
|
|
65
|
+
if (pid && cmd) res.push({ pid, ppid: ppid || 0, cmd });
|
|
66
|
+
}
|
|
67
|
+
return res;
|
|
68
|
+
} catch {
|
|
69
|
+
// wmic 被移除(新 Win11)→ PowerShell 兜底
|
|
70
|
+
out = execSync(
|
|
71
|
+
"powershell -NoProfile -Command \"Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,CommandLine | ConvertTo-Json -Compress\"",
|
|
72
|
+
{ encoding: "utf-8", timeout: 20000, windowsHide: true },
|
|
73
|
+
);
|
|
74
|
+
const j = JSON.parse(out);
|
|
75
|
+
const arr = Array.isArray(j) ? j : [j];
|
|
76
|
+
return arr
|
|
77
|
+
.filter((p) => p && p.ProcessId && p.CommandLine)
|
|
78
|
+
.map((p) => ({
|
|
79
|
+
pid: Number(p.ProcessId),
|
|
80
|
+
ppid: Number(p.ParentProcessId) || 0,
|
|
81
|
+
cmd: String(p.CommandLine),
|
|
82
|
+
}));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const out = execSync("ps -eo pid=,ppid=,command=", { encoding: "utf-8", timeout: 10000 });
|
|
86
|
+
const res = [];
|
|
87
|
+
for (const line of out.split("\n")) {
|
|
88
|
+
const m = line.trim().match(/^(\d+)\s+(\d+)\s+(.+)$/);
|
|
89
|
+
if (!m) continue;
|
|
90
|
+
res.push({ pid: Number(m[1]), ppid: Number(m[2]), cmd: m[3] });
|
|
91
|
+
}
|
|
92
|
+
return res;
|
|
93
|
+
} catch {
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const alive = (pid) => {
|
|
99
|
+
try {
|
|
100
|
+
process.kill(pid, 0);
|
|
101
|
+
return true;
|
|
102
|
+
} catch {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/** 杀进程树: 先 TERM(agent 类进程的退出钩子会自行清理托管子进程),残留 KILL */
|
|
108
|
+
async function killTree(pid) {
|
|
109
|
+
if (IS_WIN) {
|
|
110
|
+
try {
|
|
111
|
+
execSync(`taskkill /PID ${pid} /T /F`, { stdio: "ignore", timeout: 15000, windowsHide: true });
|
|
112
|
+
} catch {}
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
process.kill(pid, "SIGTERM");
|
|
117
|
+
} catch {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
for (let i = 0; i < 6; i++) {
|
|
121
|
+
await delay(500);
|
|
122
|
+
if (!alive(pid)) break;
|
|
123
|
+
}
|
|
124
|
+
let kids = [];
|
|
125
|
+
try {
|
|
126
|
+
kids = execSync(`pgrep -P ${pid}`, { encoding: "utf-8", timeout: 5000 })
|
|
127
|
+
.split(/\s+/)
|
|
128
|
+
.filter(Boolean)
|
|
129
|
+
.map(Number);
|
|
130
|
+
} catch {}
|
|
131
|
+
for (const p of [pid, ...kids]) {
|
|
132
|
+
if (!alive(p)) continue;
|
|
133
|
+
try {
|
|
134
|
+
process.kill(p, "SIGKILL");
|
|
135
|
+
} catch {}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* 环境接管入口(agent.start() 最先调用)
|
|
141
|
+
* @param {object} opts
|
|
142
|
+
* @param {boolean} opts.spawnDaemon - 本实例是否托管 clash daemon(是则接管旧 daemon)
|
|
143
|
+
* @param {number} opts.selfPid - 自身 pid(排除)
|
|
144
|
+
* @param {(m: string) => void} [opts.log] - 逐条接管日志
|
|
145
|
+
* @returns {Promise<{agents: number[], daemons: number[], orphans: number[]}>} 被接管的 pid
|
|
146
|
+
*/
|
|
147
|
+
export async function takeoverEnvironment({ spawnDaemon, selfPid, log = () => {} } = {}) {
|
|
148
|
+
const procs = listProcesses();
|
|
149
|
+
const agents = [];
|
|
150
|
+
const daemons = [];
|
|
151
|
+
|
|
152
|
+
for (const p of procs) {
|
|
153
|
+
if (p.pid === selfPid) continue;
|
|
154
|
+
if (isAgentCmd(p.cmd)) agents.push(p);
|
|
155
|
+
else if (spawnDaemon && isDaemonCmd(p.cmd)) daemons.push(p);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
for (const p of agents) {
|
|
159
|
+
log(`🧹 环境初始化: 接管旧 watchdog agent (pid ${p.pid}, ${p.cmd.slice(0, 80)})`);
|
|
160
|
+
await killTree(p.pid);
|
|
161
|
+
}
|
|
162
|
+
for (const p of daemons) {
|
|
163
|
+
log(`🧹 环境初始化: 接管旧 clash daemon (pid ${p.pid})`);
|
|
164
|
+
await killTree(p.pid);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// 孤儿命令进程回收(ppid=1 = 父 agent 已死;仅 unix,Windows 依赖 taskkill /T 树杀)
|
|
168
|
+
const orphans = [];
|
|
169
|
+
if (!IS_WIN) {
|
|
170
|
+
for (const p of listProcesses()) {
|
|
171
|
+
if (p.pid === selfPid) continue;
|
|
172
|
+
if (p.ppid === 1 && isManagedCmdCmd(p.cmd)) orphans.push(p);
|
|
173
|
+
}
|
|
174
|
+
for (const p of orphans) {
|
|
175
|
+
log(`🧹 环境初始化: 清理孤儿命令进程 (pid ${p.pid})`);
|
|
176
|
+
try {
|
|
177
|
+
process.kill(p.pid, "SIGKILL");
|
|
178
|
+
} catch {}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
agents: agents.map((p) => p.pid),
|
|
184
|
+
daemons: daemons.map((p) => p.pid),
|
|
185
|
+
orphans: orphans.map((p) => p.pid),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
@@ -20,6 +20,7 @@ import { pushSelfLog } from "./self-log-buffer.js";
|
|
|
20
20
|
// === 2026-08-07 变更 ===
|
|
21
21
|
// syncCommands 改为 async,批量启动时每个进程间隔 SPAWN_STAGGER_MS(30s),避免抢网络
|
|
22
22
|
const MAX_RESTART = 3;
|
|
23
|
+
const RESTART_STABLE_MS = 10 * 60 * 1000; // 稳定运行超过此时长后崩溃 → 重新计数(视为新的一轮)
|
|
23
24
|
const MAX_LOG_LINES = 500; // 每个进程最多保留的日志行数
|
|
24
25
|
const IS_WIN = process.platform === "win32";
|
|
25
26
|
const SPAWN_STAGGER_MS = 30000; // syncCommands 批量启动时每个进程间隔 30 秒,避免抢网络
|
|
@@ -245,6 +246,10 @@ export class ProcessManager extends EventEmitter {
|
|
|
245
246
|
super();
|
|
246
247
|
/** @type {Map<string, {cmdId:string, cmdDef:object, child:ChildProcess, startTime:number, restartCount:number, thresholds:object, stopped:boolean}>} */
|
|
247
248
|
this.processes = new Map();
|
|
249
|
+
/** @type {Map<string, number>} cmdId → 连续异常重启次数(跨实例累计;稳定运行后清零)
|
|
250
|
+
* 2026-09-25 修复: 原先计数存在每个实例的 info 上,自动重启重建 info 后归零,
|
|
251
|
+
* 导致 (1/3) 永远不变、MAX_RESTART 熔断失效(双开乒乓循环无熔断的直接原因) */
|
|
252
|
+
this._restartCounts = new Map();
|
|
248
253
|
}
|
|
249
254
|
|
|
250
255
|
/**
|
|
@@ -352,22 +357,27 @@ export class ProcessManager extends EventEmitter {
|
|
|
352
357
|
const shouldRestart = !info.stopped && code !== 0;
|
|
353
358
|
|
|
354
359
|
if (shouldRestart) {
|
|
355
|
-
|
|
360
|
+
// 跨实例累计 + 稳定运行清零 + 线性退避
|
|
361
|
+
const restartCount = uptimeMs >= RESTART_STABLE_MS ? 1 : (this._restartCounts.get(id) || 0) + 1;
|
|
362
|
+
this._restartCounts.set(id, restartCount);
|
|
356
363
|
if (restartCount <= MAX_RESTART) {
|
|
364
|
+
const delayMs = Math.min(5000 * restartCount, 30000);
|
|
357
365
|
console.error(
|
|
358
|
-
`[watchdog] 命令 ${id} 异常退出,自动重启 (${restartCount}/${MAX_RESTART})
|
|
366
|
+
`[watchdog] 命令 ${id} 异常退出,自动重启 (${restartCount}/${MAX_RESTART}),${Math.round(delayMs / 1000)}s 后执行...`,
|
|
359
367
|
);
|
|
360
368
|
this.processes.delete(id);
|
|
361
|
-
setTimeout(() => this.startCommand(cmdDef),
|
|
369
|
+
setTimeout(() => this.startCommand(cmdDef), delayMs);
|
|
362
370
|
} else {
|
|
363
371
|
console.error(
|
|
364
|
-
`[watchdog] 命令 ${id} 已达最大重启次数 (${MAX_RESTART})
|
|
372
|
+
`[watchdog] 命令 ${id} 已达最大重启次数 (${MAX_RESTART}),停止重启(稳定运行 ${Math.round(RESTART_STABLE_MS / 60000)} 分钟后可恢复)`,
|
|
365
373
|
);
|
|
366
374
|
this.processes.delete(id);
|
|
375
|
+
this._restartCounts.delete(id);
|
|
367
376
|
this.emit("process-crashed", { cmdId: id, cmdDef, code, signal });
|
|
368
377
|
}
|
|
369
378
|
} else {
|
|
370
379
|
this.processes.delete(id);
|
|
380
|
+
this._restartCounts.delete(id);
|
|
371
381
|
}
|
|
372
382
|
});
|
|
373
383
|
|
|
@@ -435,6 +445,7 @@ export class ProcessManager extends EventEmitter {
|
|
|
435
445
|
}
|
|
436
446
|
}
|
|
437
447
|
|
|
448
|
+
this._restartCounts.delete(cmdId); // 显式停止 = 清空重启计数
|
|
438
449
|
this.processes.delete(cmdId);
|
|
439
450
|
}
|
|
440
451
|
|
|
@@ -479,7 +490,7 @@ export class ProcessManager extends EventEmitter {
|
|
|
479
490
|
String(s).padStart(2, "0")
|
|
480
491
|
: m + ":" + String(s).padStart(2, "0");
|
|
481
492
|
})(uptimeSeconds),
|
|
482
|
-
restartCount:
|
|
493
|
+
restartCount: this._restartCounts.get(cmdId) || 0,
|
|
483
494
|
error: info.spawnError || null,
|
|
484
495
|
});
|
|
485
496
|
}
|
|
@@ -522,7 +533,7 @@ export class ProcessManager extends EventEmitter {
|
|
|
522
533
|
cmdId,
|
|
523
534
|
logs: info.logs.slice(), // 快照,避免分析时被修改
|
|
524
535
|
uptimeSeconds: Math.round((Date.now() - info.startTime) / 1000),
|
|
525
|
-
restartCount:
|
|
536
|
+
restartCount: this._restartCounts.get(cmdId) || 0,
|
|
526
537
|
});
|
|
527
538
|
// 清空已读取的日志,避免重复分析
|
|
528
539
|
info.logs = [];
|