@mrrisega/dsh-remote 0.7.7 → 0.7.9
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/clients/dsh-remote/dsh-bridge.mjs +32 -3
- package/clients/dsh-remote/test/wechat-channel.test.mjs +452 -0
- package/clients/dsh-remote/test/wechat-runtime.test.mjs +568 -29
- package/clients/dsh-remote/wechat-channel.mjs +378 -6
- package/clients/dsh-remote/wechat-runtime.mjs +408 -48
- package/dsh-setup.mjs +200 -48
- package/package.json +1 -1
- package/packages/dsh-remote-web/lib/client.js +37 -2
- package/packages/dsh-remote-web/lib/index.js +347 -53
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +32 -3
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-channel.mjs +378 -6
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-runtime.mjs +408 -48
- package/packages/dsh-remote-web/runtime/dsh-setup.mjs +200 -48
- package/packages/dsh-remote-web/test/setup-win-scan.test.mjs +455 -0
- package/packages/dsh-remote-web/test/wechat-bot-ui.test.mjs +50 -0
- package/packages/dsh-remote-web/test/windows-process-discovery.test.mjs +427 -0
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Windows 进程发现:不再漏判活着的 bridge、不再误杀无关进程(2026-10-29 反馈取证)
|
|
3
|
+
*
|
|
4
|
+
* 【为什么单开一个文件】Windows 分支在 macOS/Linux 上跑不了,但它是**唯一**能解释
|
|
5
|
+
* 「面板说 bridge 未运行、而 bridge 明明在跑」的那段代码(详见
|
|
6
|
+
* dsh-relay-internal/2026-10-29-反馈bridge_wedged-取证/code-analysis.md)。本文件用
|
|
7
|
+
* 「`DSH_RELAY_PLATFORM=win32` + 假 powershell.exe + 真 pid 替身 + pid/锁文件」在 macOS 上
|
|
8
|
+
* **行为级**跑一遍 win32 分支,锁死五条不变量:
|
|
9
|
+
*
|
|
10
|
+
* ① watcher pid 文件有效时,**bridge 缺失仍必须扫进程**(旧实现 `if (!watcher.length && !bridge.length)`
|
|
11
|
+
* 直接跳过 → bridge 恒为空 → 90s 后误判「守护卡死」→ 误重启 → 3 次后 bridge_wedged);
|
|
12
|
+
* ② 扫描不得认死镜像名 `node.exe`:Electron 宿主用 `process.execPath` 拉起 watcher,
|
|
13
|
+
* 镜像名是桌面端 exe;bridge 的解释器是 NODE_BIN,也可能是同一个 exe。旧实现两者全漏;
|
|
14
|
+
* ③ bridge 自写的单实例锁 `.dsh-bridge-instance.lock`({pid,at,script})必须能当权威存活判据
|
|
15
|
+
* (插件以前完全不读它)——但**不许**假阳性(script 不对/pid 是无关进程时不认);
|
|
16
|
+
* ④ PID 复用:pid 文件里的 pid 活着**不代表**它是我们的进程。校验不过 → 不判 wedged、
|
|
17
|
+
* **不 kill**(旧实现 taskkill /T /F 会杀掉一棵与产品无关的进程树);
|
|
18
|
+
* ⑤ 扫描失败(PowerShell 不可用)必须与「扫到 0 个」区分开(scanDegraded),
|
|
19
|
+
* 并随 /bridge-status 下发,用户「复制诊断信息」里能看出是「扫描不可用」还是「真没进程」。
|
|
20
|
+
*
|
|
21
|
+
* 隔离纪律(与 windows-compat.test.mjs 同一套):
|
|
22
|
+
* · 平台分支用 `DSH_RELAY_PLATFORM=win32` 强制,绝不碰真实 Windows;
|
|
23
|
+
* · `HOME` 指向临时目录、`relayDir` 指向 mkdtemp —— 绝不写真实 `~/.dsh-remote`;
|
|
24
|
+
* · `powershell.exe` / `taskkill` / `pgrep` / `ps` / `launchctl` 全部是假 PATH 上的替身;
|
|
25
|
+
* · `DSH_RELAY_WIN_SCAN_TTL_MS=0` 关掉扫描缓存,否则同一进程内多条用例会互相串味(TTL 5s);
|
|
26
|
+
* · 默认置位 `DSH_RELAY_SKIP_SERVICE=1`;只有「停止不得误杀」那条需要真走分支,它同时伪造 HOME。
|
|
27
|
+
*/
|
|
28
|
+
import assert from "node:assert/strict";
|
|
29
|
+
import http from "node:http";
|
|
30
|
+
import { spawn } from "node:child_process";
|
|
31
|
+
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, utimesSync, writeFileSync } from "node:fs";
|
|
32
|
+
import os from "node:os";
|
|
33
|
+
import path from "node:path";
|
|
34
|
+
import { fileURLToPath } from "node:url";
|
|
35
|
+
import test from "node:test";
|
|
36
|
+
import { apply, __manualStatus, __composeConnect } from "../lib/index.js";
|
|
37
|
+
|
|
38
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
39
|
+
const ROOT = path.join(HERE, "..", "..", "..");
|
|
40
|
+
|
|
41
|
+
/** 假 powershell.exe:把 $DSH_TEST_CIM_JSON 原样当 ConvertTo-Json 的输出吐出来。
|
|
42
|
+
*
|
|
43
|
+
* 关键(让旧实现真的红、而不是"假脚本不认 Name 过滤所以蒙对"):
|
|
44
|
+
* 它会**解码 -EncodedCommand 并检查脚本里有没有 `Name='node.exe'`**,有则模拟 CIM 的
|
|
45
|
+
* 服务端过滤(只留 Name === node.exe 的条目)。于是:
|
|
46
|
+
* · 旧脚本(Name 过滤)→ Electron 镜像名的 watcher 被滤掉 → 用例红;
|
|
47
|
+
* · 新脚本(无 Name 过滤)→ 全量返回 → 用例绿。
|
|
48
|
+
* `$DSH_TEST_CIM_FAIL=1` 时故意非 0 退出,模拟「没有 PowerShell / 调用失败」。
|
|
49
|
+
* 每次调用往 `$DSH_TEST_CIM_LOG` 追加一行(证明"扫描到底有没有执行")。
|
|
50
|
+
*/
|
|
51
|
+
const FAKE_POWERSHELL = `#!/usr/bin/env node
|
|
52
|
+
const fs = require("node:fs");
|
|
53
|
+
const args = process.argv.slice(2);
|
|
54
|
+
const i = args.indexOf("-EncodedCommand");
|
|
55
|
+
let script = "";
|
|
56
|
+
try { if (i >= 0 && args[i + 1]) script = Buffer.from(args[i + 1], "base64").toString("utf16le"); } catch {}
|
|
57
|
+
const log = process.env.DSH_TEST_CIM_LOG;
|
|
58
|
+
if (log) {
|
|
59
|
+
try { fs.appendFileSync(log, JSON.stringify({ hasNameFilter: /Name\\s*=\\s*'node\\.exe'/.test(script), script: script.slice(0, 240) }) + "\\n"); } catch {}
|
|
60
|
+
}
|
|
61
|
+
if (process.env.DSH_TEST_CIM_FAIL === "1") { try { process.stderr.write("fake powershell: denied\\n"); } catch {}; process.exit(1); }
|
|
62
|
+
let procs = [];
|
|
63
|
+
try { procs = JSON.parse(process.env.DSH_TEST_CIM_JSON || "[]"); } catch { procs = []; }
|
|
64
|
+
if (!Array.isArray(procs)) procs = procs ? [procs] : [];
|
|
65
|
+
if (/Name\\s*=\\s*'node\\.exe'/.test(script)) procs = procs.filter((p) => String((p && p.Name) || "").toLowerCase() === "node.exe");
|
|
66
|
+
if (!procs.length) process.exit(0); // ConvertTo-Json 对空结果不输出任何东西
|
|
67
|
+
process.stdout.write(JSON.stringify(procs.length === 1 ? procs[0] : procs));
|
|
68
|
+
`;
|
|
69
|
+
|
|
70
|
+
/** 假 taskkill:先记一行,再真的把 /PID 指到的进程杀掉(模拟 Windows 的 /T /F 至少杀掉目标本身)。 */
|
|
71
|
+
const FAKE_TASKKILL = `#!/bin/sh
|
|
72
|
+
printf '%s\\n' "$*" >> "$DSH_TEST_KILL_LOG"
|
|
73
|
+
pid=""; prev=""
|
|
74
|
+
for a in "$@"; do if [ "$prev" = "/PID" ]; then pid="$a"; fi; prev="$a"; done
|
|
75
|
+
if [ -n "$pid" ]; then kill -9 "$pid" 2>/dev/null; fi
|
|
76
|
+
exit 0
|
|
77
|
+
`;
|
|
78
|
+
|
|
79
|
+
/** win32 分支不该调到 launchctl/pgrep/ps;放上替身是为了「万一调了也不会碰真机」。 */
|
|
80
|
+
const NOOP_BIN = "#!/bin/sh\nexit 0\n";
|
|
81
|
+
|
|
82
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
83
|
+
|
|
84
|
+
/** 起一个真实存活的长命进程当「pid 替身」(判活必须用真 pid)。 */
|
|
85
|
+
function startFakeProcess() {
|
|
86
|
+
const child = spawn(process.execPath, ["-e", "setTimeout(() => {}, 120000)"], { stdio: "ignore" });
|
|
87
|
+
return {
|
|
88
|
+
pid: child.pid,
|
|
89
|
+
alive() { try { process.kill(child.pid, 0); return true; } catch { return false; } },
|
|
90
|
+
stop() { try { child.kill("SIGKILL"); } catch { /* 已退出 */ } },
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const writeJson = (p, v) => writeFileSync(p, JSON.stringify(v));
|
|
95
|
+
const readOrEmpty = (p) => { try { return readFileSync(p, "utf8"); } catch { return ""; } };
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 造一套隔离环境:假 HOME / 假 PATH(含 powershell.exe 与 taskkill)/ 临时 relayDir。
|
|
99
|
+
* @param {{ cim?: object[], failScan?: boolean, skipService?: boolean }} opts
|
|
100
|
+
*/
|
|
101
|
+
async function setup({ cim = [], failScan = false, skipService = true } = {}) {
|
|
102
|
+
const root = mkdtempSync(path.join(os.tmpdir(), "dsh-winproc-"));
|
|
103
|
+
const fakeBin = path.join(root, "bin");
|
|
104
|
+
const fakeHome = path.join(root, "home");
|
|
105
|
+
const relayDir = path.join(root, "relay");
|
|
106
|
+
mkdirSync(fakeBin, { recursive: true });
|
|
107
|
+
mkdirSync(fakeHome, { recursive: true });
|
|
108
|
+
mkdirSync(relayDir, { recursive: true });
|
|
109
|
+
for (const [name, body] of [
|
|
110
|
+
["powershell.exe", FAKE_POWERSHELL],
|
|
111
|
+
["taskkill", FAKE_TASKKILL],
|
|
112
|
+
["pgrep", NOOP_BIN],
|
|
113
|
+
["ps", NOOP_BIN],
|
|
114
|
+
["launchctl", NOOP_BIN],
|
|
115
|
+
]) {
|
|
116
|
+
const p = path.join(fakeBin, name);
|
|
117
|
+
writeFileSync(p, body);
|
|
118
|
+
chmodSync(p, 0o755);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const saved = {};
|
|
122
|
+
const keys = ["HOME", "PATH", "DSH_RELAY_PLATFORM", "DSH_RELAY_SKIP_SERVICE", "DSH_RELAY_WIN_SCAN_TTL_MS",
|
|
123
|
+
"DSH_TEST_CIM_JSON", "DSH_TEST_CIM_LOG", "DSH_TEST_CIM_FAIL", "DSH_TEST_KILL_LOG",
|
|
124
|
+
"DSH_REMOTE_TELEMETRY", "DSH_RELAY_DEFAULT_API", "DSH_SETUP_NPX_DIR", "DSH_BRIDGE_UPSTREAM",
|
|
125
|
+
"DSH_WEB_URL", "DSH_TEST_PGREP", "DSH_RELAY_WEDGE_MS", "DSH_RELAY_DIR"];
|
|
126
|
+
for (const k of keys) saved[k] = process.env[k];
|
|
127
|
+
|
|
128
|
+
// PATH 首位放假 bin,同时把 node 自己所在目录带上(假 powershell.exe 的 `#!/usr/bin/env node` 要用)
|
|
129
|
+
const nodeDir = path.dirname(process.execPath);
|
|
130
|
+
process.env.HOME = fakeHome;
|
|
131
|
+
process.env.PATH = [fakeBin, nodeDir, saved.PATH || ""].filter(Boolean).join(path.delimiter);
|
|
132
|
+
process.env.DSH_RELAY_PLATFORM = "win32";
|
|
133
|
+
process.env.DSH_RELAY_WIN_SCAN_TTL_MS = "0"; // 关掉 5s 扫描缓存,用例之间不串味
|
|
134
|
+
process.env.DSH_TEST_CIM_JSON = JSON.stringify(cim);
|
|
135
|
+
process.env.DSH_TEST_CIM_LOG = path.join(root, "cim.log");
|
|
136
|
+
process.env.DSH_TEST_KILL_LOG = path.join(root, "taskkill.log");
|
|
137
|
+
if (failScan) process.env.DSH_TEST_CIM_FAIL = "1"; else delete process.env.DSH_TEST_CIM_FAIL;
|
|
138
|
+
process.env.DSH_REMOTE_TELEMETRY = "0";
|
|
139
|
+
process.env.DSH_RELAY_DEFAULT_API = "http://127.0.0.1:1"; // 后台请求一律打到死端口
|
|
140
|
+
process.env.DSH_SETUP_NPX_DIR = fakeBin;
|
|
141
|
+
if (skipService) process.env.DSH_RELAY_SKIP_SERVICE = "1"; else delete process.env.DSH_RELAY_SKIP_SERVICE;
|
|
142
|
+
delete process.env.DSH_BRIDGE_UPSTREAM;
|
|
143
|
+
delete process.env.DSH_WEB_URL;
|
|
144
|
+
delete process.env.DSH_TEST_PGREP;
|
|
145
|
+
delete process.env.DSH_RELAY_WEDGE_MS;
|
|
146
|
+
delete process.env.DSH_RELAY_DIR;
|
|
147
|
+
|
|
148
|
+
// 运行环境入口:存在 + 没有额外 import ⇒ runtimeReady()=true(不触发补装)
|
|
149
|
+
writeFileSync(path.join(relayDir, "dsh-setup.mjs"), "// runtime stub(用例专用)\n");
|
|
150
|
+
writeJson(path.join(relayDir, ".dsh-config.json"), {
|
|
151
|
+
phone: "13800000000", password: "pw", device_id: "dev-winproc", api_url: "http://127.0.0.1:1",
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
root, fakeBin, fakeHome, relayDir,
|
|
156
|
+
cimLog: process.env.DSH_TEST_CIM_LOG,
|
|
157
|
+
killLog: process.env.DSH_TEST_KILL_LOG,
|
|
158
|
+
/** 造一条「我们自己的进程」的 CIM 记录(命令行带 relayDir,够 pidRunsFrom 认)。 */
|
|
159
|
+
procOf(pid, which = "bridge", extra = {}) {
|
|
160
|
+
const script = which === "bridge"
|
|
161
|
+
? path.join(relayDir, "clients", "dsh-remote", "dsh-bridge.mjs")
|
|
162
|
+
: path.join(relayDir, "dsh-setup.mjs");
|
|
163
|
+
return {
|
|
164
|
+
ProcessId: pid,
|
|
165
|
+
ParentProcessId: 1,
|
|
166
|
+
Name: "node.exe",
|
|
167
|
+
CommandLine: `"${process.execPath}" "${script}"${which === "watcher" ? " run" : ""}`,
|
|
168
|
+
...extra,
|
|
169
|
+
};
|
|
170
|
+
},
|
|
171
|
+
writePid(name, pid, ageMs = 0) {
|
|
172
|
+
const p = path.join(relayDir, name);
|
|
173
|
+
writeFileSync(p, String(pid));
|
|
174
|
+
if (ageMs > 0) {
|
|
175
|
+
const t = (Date.now() - ageMs) / 1000;
|
|
176
|
+
utimesSync(p, t, t);
|
|
177
|
+
}
|
|
178
|
+
return p;
|
|
179
|
+
},
|
|
180
|
+
async restore() {
|
|
181
|
+
for (const [k, v] of Object.entries(saved)) { if (v === undefined) delete process.env[k]; else process.env[k] = v; }
|
|
182
|
+
rmSync(root, { recursive: true, force: true });
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** 装载插件路由(与 windows-compat.test.mjs 同一套最小 ctx)。 */
|
|
188
|
+
function boot(relayDir) {
|
|
189
|
+
const routes = new Map();
|
|
190
|
+
const disposers = [];
|
|
191
|
+
apply({
|
|
192
|
+
webServer: { register(route) { routes.set(route.path, route.handler); return () => {}; } },
|
|
193
|
+
effect(register) { const d = register(); if (typeof d === "function") disposers.push(d); return d; },
|
|
194
|
+
logger: { info() {}, warn() {} },
|
|
195
|
+
}, { relayDir });
|
|
196
|
+
routes.dispose = () => { for (const d of disposers) { try { d(); } catch { /* 忽略 */ } } };
|
|
197
|
+
return routes;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function serve(routes) {
|
|
201
|
+
const host = http.createServer((req, res) => {
|
|
202
|
+
const handler = routes.get(new URL(req.url, "http://x").pathname);
|
|
203
|
+
(handler || ((_r, rs) => { rs.writeHead(404); rs.end(); }))(req, res);
|
|
204
|
+
});
|
|
205
|
+
await new Promise((resolve) => host.listen(0, "127.0.0.1", resolve));
|
|
206
|
+
return { base: `http://127.0.0.1:${host.address().port}`, close: () => new Promise((r) => host.close(r)) };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const getJson = async (base, p) => {
|
|
210
|
+
const res = await fetch(base + p);
|
|
211
|
+
return { status: res.status, body: await res.json().catch(() => null) };
|
|
212
|
+
};
|
|
213
|
+
const postJson = async (base, p) => {
|
|
214
|
+
const res = await fetch(base + p, { method: "POST" });
|
|
215
|
+
return { status: res.status, body: await res.json().catch(() => null) };
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
// ─────────────── ① watcher 在跑、bridge pid 文件缺失 → 必须靠扫描补出 bridge ───────────────
|
|
219
|
+
|
|
220
|
+
test("T1 win32:watcher pid 有效 + bridge pid 文件缺失 → 兜底扫描必须把活着的 bridge 找出来", async () => {
|
|
221
|
+
const watcher = startFakeProcess();
|
|
222
|
+
const bridge = startFakeProcess();
|
|
223
|
+
const env = await setup({ cim: [] });
|
|
224
|
+
try {
|
|
225
|
+
env.writePid(".dsh-watcher.pid", watcher.pid);
|
|
226
|
+
// 注意:**不写** .dsh-bridge.pid —— 这正是事故现场(bridge 秒退时 watcher 会删掉它)
|
|
227
|
+
process.env.DSH_TEST_CIM_JSON = JSON.stringify([env.procOf(bridge.pid, "bridge")]);
|
|
228
|
+
|
|
229
|
+
const st = __manualStatus(env.relayDir);
|
|
230
|
+
assert.deepEqual(st.bridge, [bridge.pid],
|
|
231
|
+
"watcher pid 文件有效时也必须扫描 bridge(旧实现因 `!watcher.length && !bridge.length` 直接跳过 ⇒ 这里恒为空 ⇒ 面板「bridge 未运行」→ 误重启 → bridge_wedged)");
|
|
232
|
+
assert.deepEqual(st.allBridge, [bridge.pid], "allBridge 也要带上(连接阶段判定用它)");
|
|
233
|
+
assert.deepEqual(st.watcher, [watcher.pid], "watcher 仍来自 pid 文件");
|
|
234
|
+
assert.deepEqual(st.bridgeEvidence, ["scan"], `bridge 的存活证据必须可解释,实际 ${JSON.stringify(st.bridgeEvidence)}`);
|
|
235
|
+
assert.equal(st.scanDegraded, false, "扫描成功不得标成 degraded");
|
|
236
|
+
} finally {
|
|
237
|
+
watcher.stop(); bridge.stop(); await env.restore();
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// ─────────────── ② 扫描失败 ≠ 扫到 0 个 ───────────────
|
|
242
|
+
|
|
243
|
+
test("T2 win32:bridge 真没有时也要扫(0 个是结论,不是没扫),且扫描失败要标 degraded", async () => {
|
|
244
|
+
const watcher = startFakeProcess();
|
|
245
|
+
const env = await setup({ cim: [] });
|
|
246
|
+
try {
|
|
247
|
+
env.writePid(".dsh-watcher.pid", watcher.pid);
|
|
248
|
+
const ok = __manualStatus(env.relayDir);
|
|
249
|
+
assert.deepEqual(ok.bridge, [], "扫描返回空 ⇒ bridge 为空(真没有)");
|
|
250
|
+
assert.equal(ok.scanDegraded, false, "扫描能跑通(即使 0 个)不得标 degraded");
|
|
251
|
+
const ran = readOrEmpty(env.cimLog);
|
|
252
|
+
assert.match(ran, /hasNameFilter/, `旧实现这里**一次都不会扫**(watcher 非空就跳过);修复后必须真的执行过扫描:${ran}`);
|
|
253
|
+
|
|
254
|
+
// 扫描失败(PowerShell 不可用)→ 必须与「0 个」区分开
|
|
255
|
+
process.env.DSH_TEST_CIM_FAIL = "1";
|
|
256
|
+
const degraded = __manualStatus(env.relayDir);
|
|
257
|
+
assert.equal(degraded.scanDegraded, true, "PowerShell 调用失败必须标 scanDegraded(否则诊断里会谎报「0 个进程」)");
|
|
258
|
+
assert.deepEqual(degraded.watcher, [watcher.pid], "扫描不可用时,新鲜的 watcher pid 文件仍应被信任");
|
|
259
|
+
} finally {
|
|
260
|
+
watcher.stop(); await env.restore();
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// ─────────────── ③ 不得认死镜像名 node.exe ───────────────
|
|
265
|
+
|
|
266
|
+
test("T3 win32:Electron 宿主拉起的 watcher(镜像名不是 node.exe)必须能识别", async () => {
|
|
267
|
+
const watcher = startFakeProcess();
|
|
268
|
+
const env = await setup({ cim: [] });
|
|
269
|
+
try {
|
|
270
|
+
// 没有 pid 文件(例如 pid 文件被删/旧版运行时),只能靠扫描。
|
|
271
|
+
// 镜像名 = 桌面端 exe、命令行里是 `"…\DeepSeek Harness.exe" "…\dsh-setup.mjs" run`
|
|
272
|
+
const electronWatcher = env.procOf(watcher.pid, "watcher", {
|
|
273
|
+
Name: "DeepSeek Harness.exe",
|
|
274
|
+
CommandLine: `"C:\\Users\\u\\AppData\\Local\\Programs\\DeepSeek Harness\\DeepSeek Harness.exe" "${path.join(env.relayDir, "dsh-setup.mjs")}" run`,
|
|
275
|
+
});
|
|
276
|
+
process.env.DSH_TEST_CIM_JSON = JSON.stringify([electronWatcher]);
|
|
277
|
+
|
|
278
|
+
const st = __manualStatus(env.relayDir);
|
|
279
|
+
assert.deepEqual(st.watcher, [watcher.pid],
|
|
280
|
+
"插件用 process.execPath(Electron 宿主 = 桌面端 exe)拉起 watcher,镜像名一定不是 node.exe;`Name='node.exe'` 过滤会整体漏掉它");
|
|
281
|
+
} finally {
|
|
282
|
+
watcher.stop(); await env.restore();
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// ─────────────── ④ PID 复用:不得判 wedged、不得误杀 ───────────────
|
|
287
|
+
|
|
288
|
+
test("T4 win32:pid 文件指向被复用的无关进程(陈旧)→ 不得判成 watcher,也不得判 wedged", async () => {
|
|
289
|
+
const unrelated = startFakeProcess(); // 与产品无关,只是"占着那个 pid"
|
|
290
|
+
const env = await setup({ cim: [] }); // 扫描里没有它 ⇒ 取不到命令行 ⇒ 只能靠新鲜度
|
|
291
|
+
const routes = boot(env.relayDir);
|
|
292
|
+
const srv = await serve(routes);
|
|
293
|
+
try {
|
|
294
|
+
env.writePid(".dsh-watcher.pid", unrelated.pid, 30 * 60 * 1000); // 30 分钟前的 pid 文件
|
|
295
|
+
const st = __manualStatus(env.relayDir);
|
|
296
|
+
assert.deepEqual(st.watcher, [], "pid 活着 + 取不到命令行 + pid 文件陈旧 ⇒ 不能当 watcher(否则会去 taskkill 一棵无关进程树)");
|
|
297
|
+
|
|
298
|
+
const conn = await __composeConnect(env.relayDir);
|
|
299
|
+
assert.equal(conn.watcherRunning, false, "不得把被复用的 pid 报成「后台守护在运行」");
|
|
300
|
+
assert.equal(conn.watcherWedged, false, "更不得据此判「守护卡死」——这正是「误重启 → bridge_wedged」的起点");
|
|
301
|
+
assert.equal(conn.scanDegraded, false);
|
|
302
|
+
|
|
303
|
+
const retry = await postJson(srv.base, "/dsh-remote/connect/retry");
|
|
304
|
+
assert.ok(retry.body, "重试接口必须 200");
|
|
305
|
+
assert.notEqual(retry.body.action, "restart-wedged-watcher",
|
|
306
|
+
`不得因 PID 复用触发「重启卡死守护」,实际 ${retry.body.action}`);
|
|
307
|
+
} finally {
|
|
308
|
+
unrelated.stop(); await srv.close(); routes.dispose(); await env.restore();
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test("T5 win32:停止 bridge 时,无法确认归属的 pid 绝不能被 kill(PID 复用误杀)", async () => {
|
|
313
|
+
const unrelated = startFakeProcess();
|
|
314
|
+
const env = await setup({ cim: [], skipService: false }); // 真走 stop 分支(系统命令全是假的)
|
|
315
|
+
const routes = boot(env.relayDir);
|
|
316
|
+
const srv = await serve(routes);
|
|
317
|
+
try {
|
|
318
|
+
env.writePid(".dsh-watcher.pid", unrelated.pid, 30 * 60 * 1000);
|
|
319
|
+
const r = await postJson(srv.base, "/dsh-remote/stop");
|
|
320
|
+
assert.ok(r.status === 200 || r.status === 500, `停止接口不得崩溃:${r.status}`);
|
|
321
|
+
assert.equal(unrelated.alive(), true,
|
|
322
|
+
"无法确认 pid 归属(命令行取不到 + pid 文件陈旧)时必须跳过结束,旧实现会 taskkill /T /F 杀掉这棵无关进程树");
|
|
323
|
+
assert.ok(!readOrEmpty(env.killLog).includes(String(unrelated.pid)),
|
|
324
|
+
`不得把该 pid 交给 taskkill,实际调用:${readOrEmpty(env.killLog)}`);
|
|
325
|
+
assert.match(readOrEmpty(path.join(env.relayDir, ".dsh-setup-install.log")),
|
|
326
|
+
/无法确认 pid 归属/,
|
|
327
|
+
"跳过结束必须留一行可回查的日志(否则用户/客服无法解释「为什么点了停止还在跑」)");
|
|
328
|
+
} finally {
|
|
329
|
+
unrelated.stop(); await srv.close(); routes.dispose(); await env.restore();
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
// ─────────────── ⑤ bridge 自写证据(单实例锁 / 状态文件) ───────────────
|
|
334
|
+
|
|
335
|
+
test("T6 win32:只有 bridge 自写的单实例锁 .dsh-bridge-instance.lock 也要认出 bridge", async () => {
|
|
336
|
+
const bridge = startFakeProcess();
|
|
337
|
+
const env = await setup({ cim: [] }); // 扫描也看不到(模拟 PowerShell 不可用/命令行取不到)
|
|
338
|
+
try {
|
|
339
|
+
writeJson(path.join(env.relayDir, ".dsh-bridge-instance.lock"),
|
|
340
|
+
{ pid: bridge.pid, at: Date.now(), script: "dsh-bridge.mjs" });
|
|
341
|
+
|
|
342
|
+
const st = __manualStatus(env.relayDir);
|
|
343
|
+
assert.deepEqual(st.bridge, [bridge.pid],
|
|
344
|
+
"锁文件是 bridge 进程**自己**写的({pid,at,script}),是比 pid 文件更权威的存活判据(插件旧实现完全不读它)");
|
|
345
|
+
assert.ok(st.bridgeEvidence.includes("instance-lock"), `证据里要能看出是锁文件认出来的,实际 ${JSON.stringify(st.bridgeEvidence)}`);
|
|
346
|
+
} finally {
|
|
347
|
+
bridge.stop(); await env.restore();
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test("T6b win32:锁文件 script 不对 / pid 是无关进程 → 不得假阳性", async () => {
|
|
352
|
+
const unrelated = startFakeProcess();
|
|
353
|
+
const env = await setup({ cim: [] });
|
|
354
|
+
try {
|
|
355
|
+
writeJson(path.join(env.relayDir, ".dsh-bridge-instance.lock"),
|
|
356
|
+
{ pid: unrelated.pid, at: Date.now(), script: "something-else.mjs" });
|
|
357
|
+
const st = __manualStatus(env.relayDir);
|
|
358
|
+
assert.deepEqual(st.bridge, [], "script 不是 dsh-bridge.mjs 的自写证据不可信(否则 PID 复用会让面板谎报运行中)");
|
|
359
|
+
} finally {
|
|
360
|
+
unrelated.stop(); await env.restore();
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test("T6c win32:状态文件的 pid + 新鲜心跳(bridge 自证)→ 认 bridge,且不必起 PowerShell", async () => {
|
|
365
|
+
const bridge = startFakeProcess();
|
|
366
|
+
const env = await setup({ cim: [] });
|
|
367
|
+
try {
|
|
368
|
+
// 新版 bridge 在心跳拍里节流刷新 {pid, heartbeat_at}(dsh-bridge.mjs 的 stampBridgeHeartbeat)
|
|
369
|
+
writeJson(path.join(env.relayDir, ".dsh-bridge-state.json"),
|
|
370
|
+
{ pid: bridge.pid, heartbeat_at: Date.now(), device_id: "dev-winproc", phase: "online" });
|
|
371
|
+
const st = __manualStatus(env.relayDir);
|
|
372
|
+
assert.deepEqual(st.bridge, [bridge.pid], "pid 活着 + 心跳新鲜 ⇒ bridge 在跑(自证优先于任何扫描)");
|
|
373
|
+
assert.ok(st.bridgeEvidence.includes("state-file"), `证据应含 state-file,实际 ${JSON.stringify(st.bridgeEvidence)}`);
|
|
374
|
+
assert.equal(readOrEmpty(env.cimLog), "", "自写证据已确认时不得再白起一次 PowerShell 扫描");
|
|
375
|
+
} finally {
|
|
376
|
+
bridge.stop(); await env.restore();
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
test("T6d win32:状态文件心跳已停 → 不得只凭「pid 活着」认 bridge(PID 复用假阳性)", async () => {
|
|
381
|
+
const unrelated = startFakeProcess();
|
|
382
|
+
const env = await setup({ cim: [] });
|
|
383
|
+
try {
|
|
384
|
+
writeJson(path.join(env.relayDir, ".dsh-bridge-state.json"),
|
|
385
|
+
{ pid: unrelated.pid, heartbeat_at: Date.now() - 10 * 60 * 1000, device_id: "dev-winproc", phase: "online" });
|
|
386
|
+
const st = __manualStatus(env.relayDir);
|
|
387
|
+
assert.deepEqual(st.bridge, [],
|
|
388
|
+
"心跳已超 120s 且没有锁文件自证 ⇒ 这个 pid 可能已被回收给无关进程,不能谎报「bridge 在跑」(否则自愈会被永久挡住)");
|
|
389
|
+
} finally {
|
|
390
|
+
unrelated.stop(); await env.restore();
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
// ─────────────── ⑥ 端到端:面板不再误报、不再误重启 ───────────────
|
|
395
|
+
|
|
396
|
+
test("T8 win32:/bridge-status 必须报出活着的 bridge(不再判卡死/不再自动重启守护)", async () => {
|
|
397
|
+
const watcher = startFakeProcess();
|
|
398
|
+
const bridge = startFakeProcess();
|
|
399
|
+
const env = await setup({ cim: [] });
|
|
400
|
+
const routes = boot(env.relayDir);
|
|
401
|
+
const srv = await serve(routes);
|
|
402
|
+
try {
|
|
403
|
+
// 守护已经跑了 3 分钟(>90s 的 wedger 阈值),但 bridge **确实活着**、只是 pid 文件没了
|
|
404
|
+
env.writePid(".dsh-watcher.pid", watcher.pid, 180_000);
|
|
405
|
+
process.env.DSH_TEST_CIM_JSON = JSON.stringify([env.procOf(bridge.pid, "bridge")]);
|
|
406
|
+
writeJson(path.join(env.relayDir, ".dsh-bridge-state.json"), {
|
|
407
|
+
device_id: "dev-winproc", phase: "online", tunnel_registered_at: Date.now(),
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
const r = await getJson(srv.base, "/dsh-remote/bridge-status");
|
|
411
|
+
assert.equal(r.status, 200);
|
|
412
|
+
const c = r.body.connect;
|
|
413
|
+
assert.equal(c.bridgeRunning, true, "bridge 明明在跑(扫描/锁文件任一证据)⇒ bridgeRunning 必须为 true(旧实现恒 false)");
|
|
414
|
+
assert.deepEqual(c.bridgePids, [bridge.pid]);
|
|
415
|
+
assert.ok(Array.isArray(c.bridgeEvidence) && c.bridgeEvidence.includes("scan"), `证据要下发到面板:${JSON.stringify(c.bridgeEvidence)}`);
|
|
416
|
+
assert.equal(c.scanDegraded, false);
|
|
417
|
+
assert.equal(c.watcherWedged, false, "bridge 在跑就谈不上「守护拉不起 bridge」");
|
|
418
|
+
assert.notEqual(c.phase, "starting", `不得再显示「正在启动 Bridge…」(实际 ${c.phase}:${c.detail})`);
|
|
419
|
+
assert.equal(c.phase, "online", "状态文件证明已在中继注册 ⇒ 应直接报 online");
|
|
420
|
+
assert.match(String(c.diagnostics), /进程发现: /, "「复制诊断信息」里必须能看出进程发现的结论来源(扫描/锁文件/pid 文件/degraded)");
|
|
421
|
+
|
|
422
|
+
const retry = await postJson(srv.base, "/dsh-remote/connect/retry");
|
|
423
|
+
assert.notEqual(retry.body.action, "restart-wedged-watcher", `不得重启守护,实际 ${retry.body.action}`);
|
|
424
|
+
} finally {
|
|
425
|
+
watcher.stop(); bridge.stop(); await srv.close(); routes.dispose(); await env.restore();
|
|
426
|
+
}
|
|
427
|
+
});
|