@kira905/dsh-diagnostics-extra 0.0.0-stage → 0.1.1
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/CHANGELOG.md +54 -0
- package/LICENSE +661 -0
- package/README.en.md +269 -0
- package/README.md +276 -2
- package/attachments-audit.mjs +333 -0
- package/attachments-fetch.mjs +507 -0
- package/dsh-why.mjs +499 -0
- package/lib/attach-refs.mjs +224 -0
- package/lib/cli-args.mjs +183 -0
- package/lib/dsh-home.mjs +95 -0
- package/lib/generated-docs.mjs +397 -0
- package/lib/powershell-exe.mjs +188 -0
- package/lib/redact.mjs +157 -0
- package/lib/session-file-name.mjs +160 -0
- package/owner-todo-scan.mjs +441 -0
- package/package.json +45 -4
package/dsh-why.mjs
ADDED
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* dsh-why.mjs — 只读「现状体检」:一条命令答清
|
|
4
|
+
* ① 现在跑在哪台机 ② dsh 与守护鱼何时起(谁先起;**dsh 主进程 = 3080 LISTEN 的 owner**,不靠命令行匹配) ③ 3080 端口连接情况 ④ 守护鱼状态与日志尾
|
|
5
|
+
*
|
|
6
|
+
* 守护鱼口径(2026-10-08 订正 · 卡 b0cb242b):
|
|
7
|
+
* 「守护鱼」行**只表示 `guard.mjs` 本体**;本体 spawn 的 **launcher**(`node -e "(async function launcherMain …)"`,
|
|
8
|
+
* 负责起 dsh 的那个子进程)单独一行显示,并标明它随每次 dsh 启动换新 —— 判「守护鱼换没换」只看本体 pid。
|
|
9
|
+
* 旧版对所有命令行含 `guard` 的 node 候选「取最小 pid」,会随 pid 大小偶发报到 launcher 上(判据见 guardRoleOf)。
|
|
10
|
+
* ⚠ 2026-10-08 实测追加:隔离/测试实例也跑 `guard.mjs`(`--port 3115 --state-dir %TEMP%\h1-iso\…`)⇒ 生产本体
|
|
11
|
+
* 还要过 `--state-dir` 这一关(判据见 isProductionGuardBody);非生产实例单独一行显示为「隔离守护鱼」。
|
|
12
|
+
* ⚠ 2026-10-16 实测追加(卡「V3·机器锁7」R-067):**测试实例(如 3090)也有自己的 launcher**(同一个
|
|
13
|
+
* `launcherMain` 内联脚本,只是 `-- --port 3090 --cwd <test-workspace>`)⇒ 旧版把两个 launcher
|
|
14
|
+
* 一起数,报出「发现 2 个 launcher」**假阳性**(与 2026-09-24 `guard-unhealthy` 把启动器数成第二只守护鱼**同坑复发**)。
|
|
15
|
+
* 现按 **launcher 所服务的 dsh 端口**分桶(判据见 launcherTargetPort):只有服务生产 PORT 的才算生产 launcher,
|
|
16
|
+
* 非生产单独归 `guardIsoLaunchers` 并**如实单列**(不猜、不隐藏,但**不计入生产 launcher 个数**)。
|
|
17
|
+
*
|
|
18
|
+
* 用法:
|
|
19
|
+
* node dsh-why.mjs # 人类可读
|
|
20
|
+
* node dsh-why.mjs --json # 机器可读(只落 stdout,日志不污染)
|
|
21
|
+
* node dsh-why.mjs --log-lines 30 # 日志尾行数(默认 12,0 = 不打印)
|
|
22
|
+
* node dsh-why.mjs --procs-file <p> # 用离线进程表 JSON 代替实时 CIM(自测/负样例/异机复现,输出标注来源)
|
|
23
|
+
* node dsh-why.mjs --help
|
|
24
|
+
*
|
|
25
|
+
* 定位:**按需调用的只读工具**,不挂钩子、不进任何强制流程、不写任何文件。
|
|
26
|
+
* 它替代的是「凭记忆猜哪台机在跑 / 猜 dsh 刚重启没」这种穷举猜测。
|
|
27
|
+
*/
|
|
28
|
+
import fs from 'node:fs';
|
|
29
|
+
import os from 'node:os';
|
|
30
|
+
import path from 'node:path';
|
|
31
|
+
import { execFileSync } from 'node:child_process';
|
|
32
|
+
import { fileURLToPath } from 'node:url';
|
|
33
|
+
// 【2026-10-09 · 卡「360弹窗治本-pwsh7」】PowerShell 优选统一走工具:本机 pwsh 是 WindowsApps 别名
|
|
34
|
+
// (fs.accessSync 判不到但能跑),原先「Program Files\PowerShell\7 → 否则 5.1」会漏判成 5.1(弹窗)。
|
|
35
|
+
import { powerShellExe } from './lib/powershell-exe.mjs';
|
|
36
|
+
// R-066(2026-10-16):本件打印**进程命令行 / 环境 / 日志尾**(命令行里常带 key/token)
|
|
37
|
+
// ⇒ 输出前按「键名 or 值形态」双判据脱敏(共享件;只影响显示,不改任何逻辑)。
|
|
38
|
+
import { createRedactor } from './lib/redact.mjs';
|
|
39
|
+
|
|
40
|
+
const redactOut = createRedactor({ secretsOnly: true }).redactAndCount;
|
|
41
|
+
|
|
42
|
+
const PORT = 3080;
|
|
43
|
+
const GUARD_DIR = path.join(os.tmpdir(), 'dsh-guard');
|
|
44
|
+
|
|
45
|
+
// ---------- 参数 ----------
|
|
46
|
+
const argv = process.argv.slice(2);
|
|
47
|
+
const opt = { json: false, logLines: 12, help: false, procsFile: null };
|
|
48
|
+
for (let i = 0; i < argv.length; i++) {
|
|
49
|
+
const a = argv[i];
|
|
50
|
+
if (a === '--json') opt.json = true;
|
|
51
|
+
else if (a === '--log-lines') opt.logLines = Number(argv[++i] ?? 12);
|
|
52
|
+
else if (a === '--procs-file') opt.procsFile = argv[++i] ?? null;
|
|
53
|
+
else if (a === '-h' || a === '--help') opt.help = true;
|
|
54
|
+
else { process.stderr.write(`unknown arg: ${a}\n`); opt.help = true; }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const USAGE = [
|
|
58
|
+
'usage: node dsh-why.mjs [--json] [--log-lines N] [--procs-file <p>] [--help]',
|
|
59
|
+
' 只读现状体检:机器 / dsh(3080) 与守护鱼进程启动时间 / 端口连接 / 守护鱼状态与日志尾。',
|
|
60
|
+
' 守护鱼口径:「守护鱼」行 = guard.mjs **本体**;它 spawn 的 launcher(起 dsh 的子进程)单独一行显示。',
|
|
61
|
+
' --procs-file <p>:改读离线进程表 JSON(自测/负样例/异机复现,输出会标注来源);本工具不写任何文件。',
|
|
62
|
+
].join('\n');
|
|
63
|
+
|
|
64
|
+
// 入口守卫:被 import(单测/守门脚本)时**不执行 main**。
|
|
65
|
+
// ⚠ 必须 realpath 双方再比:junction 下 argv[1] 是逻辑路径、而 import.meta.url 解析为物理路径,直接比永不相等。
|
|
66
|
+
if (isMainModule()) {
|
|
67
|
+
if (opt.help) {
|
|
68
|
+
console.log(USAGE);
|
|
69
|
+
process.exitCode = 0;
|
|
70
|
+
} else {
|
|
71
|
+
main();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function main() {
|
|
76
|
+
const now = new Date();
|
|
77
|
+
const report = {
|
|
78
|
+
at: stamp(now),
|
|
79
|
+
host: process.env.COMPUTERNAME || os.hostname(),
|
|
80
|
+
user: process.env.USERNAME || '',
|
|
81
|
+
port: PORT,
|
|
82
|
+
dsh: null,
|
|
83
|
+
dshSource: null, // 'port-owner' = 主进程由 3080 LISTEN 的 owner 判定(判据见 ② 段注释)
|
|
84
|
+
dshRunners: [], // dsh subprocess runner(命令行像 dsh,但不持有 3080)——单独归类,不当主进程
|
|
85
|
+
guard: null, // 守护鱼**本体**(guard.mjs 进程 · 且 state-dir = 生产)
|
|
86
|
+
guardBodies: [], // 全部 guard.mjs **生产**本体(>1 只 = 疑似第二只守护鱼,会在 notes 告警)
|
|
87
|
+
guardIsoBodies: [], // guard.mjs 的**隔离/测试实例**(--state-dir ≠ 生产)——单独归类,绝不当生产守护鱼
|
|
88
|
+
guardLauncher: null, // 本体 spawn 的 launcher(node -e "(async function launcherMain …)"):起 dsh 的子进程
|
|
89
|
+
guardLaunchers: [], // 全部 launcher(**仅生产实例**:所服务的 dsh 端口 = PORT)
|
|
90
|
+
guardIsoLaunchers: [],// launcher 里服务**非生产实例**的(如测试 3090)——单独归类,绝不计入生产 launcher 个数
|
|
91
|
+
guardSource: null, // 'cmdline:guard.mjs+state-dir' = 本体按「命令行以 guard.mjs 作脚本入口」+ state-dir 判定
|
|
92
|
+
otherDshNodes: [],
|
|
93
|
+
net: { listenPids: [], established: 0 },
|
|
94
|
+
guardState: null,
|
|
95
|
+
logTail: [],
|
|
96
|
+
notes: [],
|
|
97
|
+
};
|
|
98
|
+
report.device = shortCode(report.host);
|
|
99
|
+
|
|
100
|
+
// ① 3080 端口
|
|
101
|
+
report.net = netInfo(PORT);
|
|
102
|
+
if (report.net.listenPids.length === 0) report.notes.push(`3080 无 LISTENING —— 本机 dsh 未在跑`);
|
|
103
|
+
if (report.net.listenPids.length > 1) report.notes.push(`3080 有多个 LISTENING PID:${report.net.listenPids.join(',')}`);
|
|
104
|
+
|
|
105
|
+
// ② dsh 主进程判据 = **3080 LISTEN 的 owner**(不靠命令行匹配)
|
|
106
|
+
// 为什么(2026-09-23 修):命令行匹配会把 dsh 的 **subprocess runner** 也算成主进程 ——
|
|
107
|
+
// `...\@deepseek-ai\dsh\node_modules\@deepseek-ai\dsh-subprocess-local\lib\runner.js` 的命令行同样含
|
|
108
|
+
// `@deepseek-ai\dsh`;旧逻辑「取最小 pid」在 pid 复用时会报出 runner 的 pid 与启动时间
|
|
109
|
+
// ⇒ 据此判成「刚重启过」(2026-09-23 实测复现:旧版报 PID 6256「已运行 0s」而真身是 52272)。
|
|
110
|
+
// **监听 3080 的进程才是主进程**(runner 不持有 3080)。
|
|
111
|
+
const mainPid = report.net.listenPids[0] ?? null;
|
|
112
|
+
report.dshSource = mainPid ? 'port-owner' : null;
|
|
113
|
+
|
|
114
|
+
// ③ node 进程(CIM 拿启动时间 + 命令行;`--procs-file` 时改读离线夹具 —— 只读,用于排障与自测)
|
|
115
|
+
const procs = opt.procsFile ? readProcsFile(opt.procsFile) : nodeProcesses();
|
|
116
|
+
if (procs === null) {
|
|
117
|
+
report.notes.push(opt.procsFile
|
|
118
|
+
? `--procs-file 读取失败或格式非法:${opt.procsFile}`
|
|
119
|
+
: '进程查询失败(pwsh/powershell 不可用或 CIM 被拦)');
|
|
120
|
+
}
|
|
121
|
+
if (opt.procsFile) report.notes.push(`进程表来自 --procs-file(离线夹具,**非**实时 CIM 查询):${opt.procsFile}`);
|
|
122
|
+
|
|
123
|
+
for (const p of procs || []) {
|
|
124
|
+
if (p.pid === process.pid) continue; // 别把自己列进去
|
|
125
|
+
const cmd = String(p.cmd || '');
|
|
126
|
+
const rec = {
|
|
127
|
+
pid: p.pid, ppid: p.ppid ?? null, start: p.start, startLocal: fmtStamp(p.start),
|
|
128
|
+
uptimeSec: uptimeSec(p.start, now), script: scriptName(cmd), cmd: shorten(cmd),
|
|
129
|
+
};
|
|
130
|
+
if (isSubprocessRunner(cmd)) { report.dshRunners.push(rec); continue; } // 命令行像 dsh ⇒ 单独归类,绝不当主进程
|
|
131
|
+
// 守护鱼:本体(再分「生产 / 隔离实例」)与 launcher 分开归桶(判据见 guardRoleOf 注释)。
|
|
132
|
+
// ⚠ 绝不「取最小 pid」混选 —— 那正是 2026-10-08 误报成 launcher 的根因。
|
|
133
|
+
// ⚠ 2026-10-08 实测追加:隔离/测试实例也跑 `guard.mjs`(`--port 3115 --state-dir …\Temp\h1-iso\…`,
|
|
134
|
+
// 父进程是测试脚本 `test-guard-http-alive.mjs`)⇒ 只按文件名判会把**测试实例**当成生产守护鱼
|
|
135
|
+
// (当时它 pid 7324 < 生产本体 21456,正好会被「取最小 pid」选中)。生产本体还要过 state-dir 这一关。
|
|
136
|
+
const guardRole = guardRoleOf(cmd);
|
|
137
|
+
if (guardRole === 'body') {
|
|
138
|
+
if (isProductionGuardBody(cmd)) report.guardBodies.push(rec);
|
|
139
|
+
else report.guardIsoBodies.push(rec);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (guardRole === 'launcher') {
|
|
143
|
+
// R-067(2026-10-16):launcher 也分「服务哪个实例」—— 测试实例(3090/3092)同样有 launcher,
|
|
144
|
+
// 混在一起数会误报「发现 2 个 launcher」。判据 = launcher 命令行里给 dsh 的 `--port`(缺省 PORT)。
|
|
145
|
+
rec.targetPort = launcherTargetPort(cmd);
|
|
146
|
+
if (rec.targetPort === PORT) report.guardLaunchers.push(rec);
|
|
147
|
+
else report.guardIsoLaunchers.push(rec);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (p.pid === mainPid) { report.dsh = rec; continue; } // 判据:持有 3080 的 pid
|
|
151
|
+
report.otherDshNodes.push(rec); // 其余 node(含 3090/3092 等其它实例;含 guard 字样但两者都不是的也落这里)
|
|
152
|
+
}
|
|
153
|
+
// 守护鱼定序:按 pid 升序取第一只作代表;**report.guard 永远是「生产本体」**
|
|
154
|
+
const byPid = (a, b) => a.pid - b.pid;
|
|
155
|
+
report.guardBodies.sort(byPid);
|
|
156
|
+
report.guardLaunchers.sort(byPid);
|
|
157
|
+
report.guardIsoLaunchers.sort(byPid);
|
|
158
|
+
report.guardIsoBodies.sort(byPid);
|
|
159
|
+
report.guard = report.guardBodies[0] ?? null;
|
|
160
|
+
report.guardLauncher = report.guardLaunchers[0] ?? null;
|
|
161
|
+
if (report.guard) report.guardSource = 'cmdline:guard.mjs+state-dir';
|
|
162
|
+
if (report.guardIsoBodies.length) {
|
|
163
|
+
report.notes.push(`另有 ${report.guardIsoBodies.length} 个 guard.mjs **非生产实例**(pid ${report.guardIsoBodies.map((b) => b.pid).join(',')}`
|
|
164
|
+
+ `,--state-dir ≠ 生产 ${GUARD_DIR})—— 已排除,绝不计入生产守护鱼(测试/隔离实例的 pid 可能更小,旧版按「文件名 + 最小 pid」会选错它)`);
|
|
165
|
+
}
|
|
166
|
+
if (report.guardBodies.length > 1) {
|
|
167
|
+
report.notes.push(`发现 ${report.guardBodies.length} 个 guard.mjs 本体(pid ${report.guardBodies.map((b) => b.pid).join(',')})`
|
|
168
|
+
+ '—— 疑似第二只守护鱼,需人工核(判「守护鱼换没换」以本行列出的本体为准)');
|
|
169
|
+
}
|
|
170
|
+
if (!report.guard && report.guardLauncher) {
|
|
171
|
+
report.notes.push(`只见到 launcher(pid ${report.guardLauncher.pid},父 ${report.guardLauncher.ppid})而无 guard.mjs 本体`
|
|
172
|
+
+ ' —— 守护鱼本体可能已退出/被换(dsh 仍可由父链带着跑);判守护鱼存活**不能**只看 launcher');
|
|
173
|
+
}
|
|
174
|
+
if (report.guardLaunchers.length > 1) {
|
|
175
|
+
report.notes.push(`发现 ${report.guardLaunchers.length} 个 launcher(pid ${report.guardLaunchers.map((l) => l.pid).join(',')})`
|
|
176
|
+
+ ' —— 正常应只有 1 个(dsh 退出时旧 launcher 会跟着退)');
|
|
177
|
+
}
|
|
178
|
+
// R-067:**非生产** launcher 如实单列 —— 旧版把它们并进上面的计数,报出「发现 2 个 launcher」假阳性
|
|
179
|
+
// (2026-10-16 实测:测试实例 3090 的 launcher 被当成第二只)。这里不猜、不隐藏,但**不混进生产计数**。
|
|
180
|
+
if (report.guardIsoLaunchers.length) {
|
|
181
|
+
report.notes.push(`另有 ${report.guardIsoLaunchers.length} 个 launcher 服务**非生产实例**`
|
|
182
|
+
+ `(pid ${report.guardIsoLaunchers.map((l) => `${l.pid}·端口${l.targetPort}`).join(',')})`
|
|
183
|
+
+ ` —— 已排除,**不计入**生产 launcher 个数(判据:launcher 命令行里给 dsh 的 --port ≠ ${PORT})`);
|
|
184
|
+
}
|
|
185
|
+
if (report.dshRunners.length) {
|
|
186
|
+
report.notes.push(`dsh 主进程按 3080 owner 判定;已排除 ${report.dshRunners.length} 个 subprocess runner`
|
|
187
|
+
+ `(pid ${report.dshRunners.map((r) => r.pid).join(',')},命令行含 @deepseek-ai\\dsh 但不持有 3080)`);
|
|
188
|
+
}
|
|
189
|
+
if (mainPid && procs !== null && !report.dsh) {
|
|
190
|
+
report.notes.push(`3080 owner pid ${mainPid} 不在 node 进程列表里(restart 竞态 / CIM 未及)——启动时间未知`);
|
|
191
|
+
}
|
|
192
|
+
// 端口 PID 兜底:CIM 查询失败或该 pid 未列出时,也要标出监听者
|
|
193
|
+
if (!report.dsh && mainPid) {
|
|
194
|
+
report.dsh = { pid: mainPid, start: null, startLocal: '未知', uptimeSec: null, script: '', cmd: '(CIM 无该进程信息)' };
|
|
195
|
+
report.notes.push('dsh 由端口 PID 兜底识别,启动时间可能缺失');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ③ 守护鱼状态文件
|
|
199
|
+
report.guardState = guardState();
|
|
200
|
+
|
|
201
|
+
// ④ 日志尾
|
|
202
|
+
if (opt.logLines > 0) report.logTail = tailLines(path.join(GUARD_DIR, 'guard2.log'), opt.logLines);
|
|
203
|
+
|
|
204
|
+
// ⑤ 时间线判断(只据启动时间顺序,不臆测)
|
|
205
|
+
// ⚠ 判「守护鱼换没换」用**本体**启动时间:launcher 每次 dsh 启动都会换新,拿它当守护鱼必错(2026-10-08 卡)。
|
|
206
|
+
if (report.guardLauncher?.start && report.dsh?.start) {
|
|
207
|
+
const lag = Math.abs(Math.round((new Date(report.dsh.start) - new Date(report.guardLauncher.start)) / 1000));
|
|
208
|
+
if (lag <= 180) {
|
|
209
|
+
report.notes.push(`launcher pid ${report.guardLauncher.pid} 与 dsh 同刻启动(差 ${lag}s)`
|
|
210
|
+
+ ' ⇒ 本次 dsh 启动/重启是它拉起的;守护鱼本体未变(判「守护鱼重不重启」只看本体 pid)');
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
if (report.dsh?.start && report.guard?.start) {
|
|
214
|
+
const gap = Math.round((new Date(report.dsh.start) - new Date(report.guard.start)) / 1000);
|
|
215
|
+
if (gap >= 0 && gap <= 180) report.timeline = `守护鱼先起 ${gap}s → dsh 随后启动(这 ${gap}s 内发生过一次 dsh 启动/重启)`;
|
|
216
|
+
else if (gap < 0) report.timeline = `dsh 比守护鱼早 ${-gap}s(守护鱼是后来接手的)`;
|
|
217
|
+
else report.timeline = `守护鱼早于 dsh ${gap}s(超过 3 分钟,未必是同一次启动)`;
|
|
218
|
+
} else if (report.dsh && !report.dsh.start) {
|
|
219
|
+
report.timeline = '(缺 dsh 启动时间,无法判断是否刚重启)';
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ---------- 输出 ----------
|
|
223
|
+
if (opt.json) {
|
|
224
|
+
process.stdout.write(JSON.stringify(report, null, 2) + '\n');
|
|
225
|
+
process.exitCode = 0;
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const L = [];
|
|
230
|
+
L.push(`DSH 现状 · ${report.at}`);
|
|
231
|
+
L.push(`机器 ${report.host}${report.device ? `(${report.device})` : ''}${report.user ? ` 用户 ${report.user}` : ''}`);
|
|
232
|
+
const n = report.net;
|
|
233
|
+
if (report.dsh) {
|
|
234
|
+
L.push(`dsh(3080) PID ${report.dsh.pid} 起于 ${report.dsh.startLocal || '未知'}${fmtUp(report.dsh.uptimeSec)} 监听 ${n.listenPids.includes(report.dsh.pid) ? '✓' : '✗'} ESTABLISHED ${n.established}`);
|
|
235
|
+
} else {
|
|
236
|
+
L.push(`dsh(3080) 未识别到进程(LISTENING PID:${n.listenPids.length ? n.listenPids.join(',') : '无'}) ESTABLISHED ${n.established}`);
|
|
237
|
+
}
|
|
238
|
+
if (report.guard) {
|
|
239
|
+
// 只说**本体**:dsh 重启只换 launcher,本体不变(口径见文件头「守护鱼口径」)
|
|
240
|
+
L.push(`守护鱼 PID ${report.guard.pid} 起于 ${report.guard.startLocal || '未知'}${fmtUp(report.guard.uptimeSec)} guard.mjs(本体 · dsh 重启/换 launcher 不动它)`);
|
|
241
|
+
} else if (report.guardLauncher) {
|
|
242
|
+
L.push('守护鱼 未识别到 guard.mjs 本体(⚠ 只见到 launcher,见下一行)');
|
|
243
|
+
} else {
|
|
244
|
+
L.push('守护鱼 未识别到 guard 进程(⚠ 守护鱼不在)');
|
|
245
|
+
}
|
|
246
|
+
if (report.guardLauncher) {
|
|
247
|
+
const g = report.guardLauncher;
|
|
248
|
+
const rel = describeLauncherParent(g, report.guard, (procs || []).map((p) => p.pid));
|
|
249
|
+
L.push(`launcher PID ${g.pid} 起于 ${g.startLocal || '未知'}${fmtUp(g.uptimeSec)} ${rel} · node -e 内联脚本(起 dsh 的子进程:每次 dsh 启动换新,非守护鱼本体)· 服务端口 ${g.targetPort ?? PORT}`);
|
|
250
|
+
}
|
|
251
|
+
// R-067:非生产 launcher 单独一行(不让它冒充生产 launcher,也不把它藏起来)
|
|
252
|
+
for (const g of report.guardIsoLaunchers) {
|
|
253
|
+
L.push(`launcher* PID ${g.pid} 起于 ${g.startLocal || '未知'}${fmtUp(g.uptimeSec)} **非生产实例**(服务端口 ${g.targetPort},本机生产是 ${PORT})· 不计入上面的 launcher 个数`);
|
|
254
|
+
}
|
|
255
|
+
if (report.guardIsoBodies.length) {
|
|
256
|
+
const iso = report.guardIsoBodies[0];
|
|
257
|
+
const more = report.guardIsoBodies.length > 1 ? `(共 ${report.guardIsoBodies.length} 个)` : '';
|
|
258
|
+
L.push(`隔离守护鱼 PID ${iso.pid}${more} 起于 ${iso.startLocal || '未知'}${fmtUp(iso.uptimeSec)} guard.mjs(--state-dir ≠ 生产 ⇒ 测试/隔离实例,**非**生产本体)`);
|
|
259
|
+
}
|
|
260
|
+
if (report.timeline) L.push(`时间线 ${report.timeline}`);
|
|
261
|
+
if (report.guardState) {
|
|
262
|
+
const s = report.guardState;
|
|
263
|
+
const kv = Object.entries(s).filter(([k]) => /mode|crash|repair|last|state|pid/i.test(k)).slice(0, 8)
|
|
264
|
+
.map(([k, v]) => `${k}=${typeof v === 'object' ? JSON.stringify(v) : v}`);
|
|
265
|
+
L.push(`guard 态 ${kv.join(' ') || '(无关键字段)'}`);
|
|
266
|
+
}
|
|
267
|
+
if (report.otherDshNodes.length) {
|
|
268
|
+
L.push(`其他 node ${report.otherDshNodes.map((p) => `${p.pid}(${p.script})`).join(' ')}`);
|
|
269
|
+
}
|
|
270
|
+
if (report.logTail.length) {
|
|
271
|
+
L.push(`日志尾 ${path.join(GUARD_DIR, 'guard2.log')}`);
|
|
272
|
+
for (const line of report.logTail) L.push(` ${line}`);
|
|
273
|
+
}
|
|
274
|
+
for (const note of report.notes) L.push(`注意 ${note}`);
|
|
275
|
+
console.log(redactOut(L.join('\n')));
|
|
276
|
+
process.exitCode = 0;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ---------- 取数 ----------
|
|
280
|
+
function netInfo(port) {
|
|
281
|
+
const out = { listenPids: [], established: 0 };
|
|
282
|
+
let text = '';
|
|
283
|
+
try {
|
|
284
|
+
text = execFileSync('netstat', ['-ano'], { encoding: 'utf8', windowsHide: true, timeout: 20000 });
|
|
285
|
+
} catch {
|
|
286
|
+
return out;
|
|
287
|
+
}
|
|
288
|
+
for (const line of text.split(/\r?\n/)) {
|
|
289
|
+
const m = line.match(/^\s*TCP\s+(\S+):(\d+)\s+(\S+)\s+(\S+)\s+(\d+)\s*$/);
|
|
290
|
+
if (!m || Number(m[2]) !== port) continue;
|
|
291
|
+
if (m[4] === 'LISTENING') out.listenPids.push(Number(m[5]));
|
|
292
|
+
else if (m[4] === 'ESTABLISHED') out.established++;
|
|
293
|
+
}
|
|
294
|
+
return out;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function pickShell() {
|
|
298
|
+
// 【2026-10-09 · 卡「360弹窗治本-pwsh7」】原来写死 `%ProgramFiles%\PowerShell\7\pwsh.exe` + fs.accessSync:
|
|
299
|
+
// 本机 pwsh 是 **WindowsApps 别名**(existsSync/accessSync 返回 false,但能跑)⇒ 原判据必然漏判 → 回落 5.1 弹窗。
|
|
300
|
+
try { return powerShellExe(); } catch { return 'powershell.exe'; }
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* dsh 的 subprocess runner 判据:命令行指向 `<...>/dsh-subprocess-<变体>/lib/runner.js` 的 node 进程。
|
|
305
|
+
* 这类进程命令行含 `@deepseek-ai\dsh`,会被朴素的命令行匹配误当成主进程(本文件 2026-09-23 修的就是这个)。
|
|
306
|
+
*/
|
|
307
|
+
export function isSubprocessRunner(cmd) {
|
|
308
|
+
return /[\\/]dsh-subprocess-[^\\/\s]+[\\/]lib[\\/]runner\.js/i.test(String(cmd || ''));
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* `--procs-file`:从 JSON 夹具读进程表(`[{pid, ppid, start, cmd}]` 或 `{ procs: [...] }`),
|
|
313
|
+
* 替代实时 CIM 查询 —— **只读**,不写任何东西。用途 = 自测 / 负样例 / 异机复现(输出会显式标注来源,防被当实时读数)。
|
|
314
|
+
*/
|
|
315
|
+
function readProcsFile(file) {
|
|
316
|
+
try {
|
|
317
|
+
const j = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
318
|
+
const arr = Array.isArray(j) ? j : (Array.isArray(j?.procs) ? j.procs : null);
|
|
319
|
+
if (!arr) return null;
|
|
320
|
+
return arr.map((p) => ({
|
|
321
|
+
pid: Number(p?.pid),
|
|
322
|
+
ppid: p?.ppid === undefined || p?.ppid === null ? null : Number(p.ppid),
|
|
323
|
+
start: p?.start ?? null,
|
|
324
|
+
cmd: String(p?.cmd ?? ''),
|
|
325
|
+
})).filter((p) => Number.isFinite(p.pid));
|
|
326
|
+
} catch {
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function nodeProcesses() {
|
|
332
|
+
const ps = [
|
|
333
|
+
"$ProgressPreference='SilentlyContinue'",
|
|
334
|
+
"$ErrorActionPreference='SilentlyContinue'",
|
|
335
|
+
"$r = @(Get-CimInstance Win32_Process -Filter \"Name='node.exe'\" | ForEach-Object {",
|
|
336
|
+
" [pscustomobject]@{ pid = [int]$_.ProcessId; ppid = [int]$_.ParentProcessId; start = if ($_.CreationDate) { $_.CreationDate.ToString('o') } else { $null }; cmd = [string]$_.CommandLine }",
|
|
337
|
+
"})",
|
|
338
|
+
"ConvertTo-Json -InputObject $r -Compress -Depth 3",
|
|
339
|
+
].join('\n');
|
|
340
|
+
try {
|
|
341
|
+
const enc = Buffer.from(ps, 'utf16le').toString('base64');
|
|
342
|
+
// stdio 全 pipe:别把 pwsh 的 CLIXML / 进度噪声漏到本脚本 stderr(--json 时 stdout 必须干净)
|
|
343
|
+
const out = execFileSync(pickShell(), ['-NoProfile', '-NonInteractive', '-EncodedCommand', enc], {
|
|
344
|
+
encoding: 'utf8', windowsHide: true, timeout: 25000, stdio: ['ignore', 'pipe', 'pipe'],
|
|
345
|
+
});
|
|
346
|
+
const parsed = JSON.parse(out.trim() || '[]');
|
|
347
|
+
return Array.isArray(parsed) ? parsed : [parsed];
|
|
348
|
+
} catch {
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function guardState() {
|
|
354
|
+
for (const name of ['guard-state.json', 'guard2-state.json']) {
|
|
355
|
+
const f = path.join(GUARD_DIR, name);
|
|
356
|
+
try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { /* next */ }
|
|
357
|
+
}
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function tailLines(file, count) {
|
|
362
|
+
try {
|
|
363
|
+
const st = fs.statSync(file);
|
|
364
|
+
const from = Math.max(0, st.size - 64 * 1024);
|
|
365
|
+
const fd = fs.openSync(file, 'r');
|
|
366
|
+
const buf = Buffer.alloc(st.size - from);
|
|
367
|
+
fs.readSync(fd, buf, 0, buf.length, from);
|
|
368
|
+
fs.closeSync(fd);
|
|
369
|
+
const lines = buf.toString('utf8').split(/\r?\n/).filter((l) => l.trim());
|
|
370
|
+
return lines.slice(-count).map((l) => shorten(l, 160));
|
|
371
|
+
} catch {
|
|
372
|
+
return [];
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// ---------- 小工具 ----------
|
|
377
|
+
function stamp(d) {
|
|
378
|
+
const p = (x) => String(x).padStart(2, '0');
|
|
379
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
|
380
|
+
}
|
|
381
|
+
function fmtStamp(iso) {
|
|
382
|
+
if (!iso) return '未知';
|
|
383
|
+
const d = new Date(iso);
|
|
384
|
+
return Number.isNaN(d.getTime()) ? String(iso) : stamp(d);
|
|
385
|
+
}
|
|
386
|
+
function scriptName(cmd) {
|
|
387
|
+
const m = String(cmd || '').match(/[^\\/\s"]+\.m?js/gi);
|
|
388
|
+
return m && m.length ? m[m.length - 1] : 'node';
|
|
389
|
+
}
|
|
390
|
+
function uptimeSec(start, now) {
|
|
391
|
+
if (!start) return null;
|
|
392
|
+
const t = new Date(start);
|
|
393
|
+
if (Number.isNaN(t.getTime())) return null;
|
|
394
|
+
return Math.max(0, Math.round((now - t) / 1000));
|
|
395
|
+
}
|
|
396
|
+
function fmtUp(sec) {
|
|
397
|
+
if (sec === null || sec === undefined) return '';
|
|
398
|
+
const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60;
|
|
399
|
+
const body = h > 0 ? `${h}h${m}m` : m > 0 ? `${m}m${s}s` : `${s}s`;
|
|
400
|
+
return `(已运行 ${body})`;
|
|
401
|
+
}
|
|
402
|
+
function shorten(s, max = 110) {
|
|
403
|
+
const t = String(s ?? '').replace(/\s+/g, ' ').trim();
|
|
404
|
+
return t.length > max ? t.slice(0, max) + '…' : t;
|
|
405
|
+
}
|
|
406
|
+
function shortCode(host) {
|
|
407
|
+
const h = String(host || '').toUpperCase();
|
|
408
|
+
if (!h) return '';
|
|
409
|
+
// 设备短码:默认把主机名 slug 化(不锁具体机器);需要自定义展示名时设 DSH_DEVICE_LABEL
|
|
410
|
+
const custom = String(process.env.DSH_DEVICE_LABEL || '').trim();
|
|
411
|
+
if (custom) return custom;
|
|
412
|
+
return h.replace(/[^A-Z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// ---------- 守护鱼判定(导出:单测同源引用,勿复制逻辑到别处) ----------
|
|
416
|
+
/** 入口守卫(realpath 双方比对,见调用点注释)。 */
|
|
417
|
+
export function isMainModule() {
|
|
418
|
+
const entry = process.argv[1];
|
|
419
|
+
if (!entry) return false;
|
|
420
|
+
try {
|
|
421
|
+
return fs.realpathSync(entry).toLowerCase() === fs.realpathSync(fileURLToPath(import.meta.url)).toLowerCase();
|
|
422
|
+
} catch {
|
|
423
|
+
return false;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* 守护鱼进程角色判定(三态 · 纯函数):
|
|
429
|
+
* 'body' = 守护鱼**本体**:命令行把 `\guard.mjs` 作为脚本入口(`node <DSH_ROOT>\guard.mjs --state-dir …`)
|
|
430
|
+
* 'launcher' = 本体(或 repair 分身)spawn 的 launcher:`node -e "(async function launcherMain …)"`,
|
|
431
|
+
* 命令行里带 `--pid-file <…>\Temp\dsh-guard\dsh-launcher-pid-<pid>.json` ⇒ 含 "guard" 字样
|
|
432
|
+
* null = 与守护鱼无关(含 guard 字样但两者都不是 ⇒ 归 other,**绝不冒充守护鱼**)
|
|
433
|
+
*
|
|
434
|
+
* 为什么需要它(2026-10-08 · 卡 b0cb242b):旧逻辑 `if (/guard/i.test(cmd))` 把 launcher 也算候选、再「取最小 pid」,
|
|
435
|
+
* 报谁的 pid 全看 pid 大小(10:33 实测 launcher 36580 < 本体 38996 ⇒ 报成 launcher)。
|
|
436
|
+
* launcher 随每次 dsh 启动换新、**本体不变** ⇒ 误读会得出「守护鱼重启了」的错误结论。
|
|
437
|
+
*/
|
|
438
|
+
export function guardRoleOf(cmd) {
|
|
439
|
+
const c = String(cmd || '');
|
|
440
|
+
if (!/guard/i.test(c)) return null;
|
|
441
|
+
if (/launcherMain|dsh-launcher-pid/i.test(c)) return 'launcher';
|
|
442
|
+
if (/[\\/]guard\.mjs(?=["'\s]|$)/i.test(c)) return 'body';
|
|
443
|
+
return null;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* launcher 服务的 **dsh 实例端口**(纯函数):从 launcher 命令行里读出它给 dsh 的 `--port`。
|
|
448
|
+
* 为什么需要它(R-067 · 2026-10-16 实测):测试实例(3090)也有 launcher,且用的是**同一个** `launcherMain`
|
|
449
|
+
* 内联脚本 ⇒ 旧版把两个 launcher 一起数,报出「发现 2 个 launcher」**假阳性**(与 2026-09-24 `guard-unhealthy`
|
|
450
|
+
* 把启动器数成第二只守护鱼**同坑复发**)。判据必须是"它服务哪个实例",而不是"有几个 launcher 进程"。
|
|
451
|
+
* 解析口径:launcher 命令行形如
|
|
452
|
+
* `node -e "…launcherMain…" -- --log … --cwd … --pid-file … -- <node.exe> …\bin.js web --port 3090 --no-open`
|
|
453
|
+
* ⇒ 取**最后一个 ` -- ` 之后**那一段(= 真正传给 dsh 的参数)里找 `--port`;取最后一段是为了不误读
|
|
454
|
+
* launcher 自身的 `--log` / `--cwd` / `--pid-file`。不带 `--port` ⇒ dsh 默认端口(= fallback,本机生产 PORT)。
|
|
455
|
+
*/
|
|
456
|
+
export function launcherTargetPort(cmd, fallback = PORT) {
|
|
457
|
+
const c = String(cmd || '');
|
|
458
|
+
const i = c.lastIndexOf(' -- ');
|
|
459
|
+
const tail = i >= 0 ? c.slice(i) : c;
|
|
460
|
+
const m = tail.match(/--port(?:=|\s+)(\d{2,5})/i);
|
|
461
|
+
return m ? Number(m[1]) : fallback;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* 「生产 launcher」判定:角色是 launcher **且**服务的是生产端口。
|
|
466
|
+
* R-067 起新增(2026-10-16):非生产实例的 launcher 归 `guardIsoLaunchers`,**不计入**生产 launcher 个数。
|
|
467
|
+
*/
|
|
468
|
+
export function isProductionLauncher(cmd, port = PORT) {
|
|
469
|
+
return guardRoleOf(cmd) === 'launcher' && launcherTargetPort(cmd, port) === port;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* 「生产本体」判定:guard.mjs 本体还必须**用生产 state-dir**(默认 = `%TEMP%\dsh-guard`)。
|
|
474
|
+
* 为什么(2026-10-08 11:59 实测撞上):隔离/测试实例也跑 `guard.mjs`,只是换了端口与 state-dir
|
|
475
|
+
* (`--port 3115 --state-dir %TEMP%\h1-iso\run-…\C3`,父进程 = 测试脚本 `test-guard-http-alive.mjs`);
|
|
476
|
+
* 当时它 pid 7324 < 生产本体 21456 ⇒ 只看文件名 + 「取最小 pid」会把**测试实例**报成生产守护鱼。
|
|
477
|
+
* 容错:不带 `--state-dir` ⇒ 视为生产(走默认值);比较时去引号/尾斜杠并忽略大小写。
|
|
478
|
+
*/
|
|
479
|
+
export function isProductionGuardBody(cmd, guardDir = GUARD_DIR) {
|
|
480
|
+
const m = String(cmd || '').match(/--state-dir(?:=|\s+)(?:"([^"]+)"|'([^']+)'|(\S+))/i);
|
|
481
|
+
if (!m) return true;
|
|
482
|
+
const val = String(m[1] ?? m[2] ?? m[3] ?? '').replace(/[\\/]+$/, '');
|
|
483
|
+
const prod = String(guardDir).replace(/[\\/]+$/, '');
|
|
484
|
+
return val.toLowerCase() === prod.toLowerCase();
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* launcher 父进程描述。
|
|
489
|
+
* ⚠ 实测(2026-10-08 11:52)launcher 的父**不一定是本体**:pid 38244 的父是 14524(repair 分身,已退出),
|
|
490
|
+
* 本体是 21456 ⇒ 别硬写「父=本体」,按实际 ppid 标注。
|
|
491
|
+
*/
|
|
492
|
+
export function describeLauncherParent(launcher, body, pids) {
|
|
493
|
+
if (!launcher) return '';
|
|
494
|
+
const ppid = launcher.ppid;
|
|
495
|
+
const alive = (pids || []).includes(ppid);
|
|
496
|
+
if (body && ppid === body.pid) return `父 ${ppid}(=本体)`;
|
|
497
|
+
if (body) return `父 ${ppid}(≠本体 ${body.pid}${alive ? '' : '·已退出'} ⇒ 由 repair 分身等代起)`;
|
|
498
|
+
return `父 ${ppid}${alive ? '' : '(已退出)'}`;
|
|
499
|
+
}
|