@mrrisega/dsh-remote 0.7.0 → 0.7.1-beta.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/clients/dsh-remote/mobile-adapter.mjs +273 -22
- package/clients/dsh-remote/test/mobile-adapter-settings.test.mjs +352 -0
- package/clients/dsh-remote/test/mobile-adapter.test.mjs +35 -0
- package/package.json +2 -2
- package/packages/dsh-remote-web/lib/index.js +337 -16
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +273 -22
- package/packages/dsh-remote-web/test/selfheal-restart-guard.test.mjs +420 -0
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ★★ 回归锁:自愈 watcher **不许**把"探测失败"翻译成"每 12 秒杀掉一次隧道"。
|
|
3
|
+
*
|
|
4
|
+
* 现场(线上反馈 `fb_3c4c5a13bc47`,用户 842,Linux fyf-MS-7D42,npx 安装,插件 0.6.18):
|
|
5
|
+
* 用户原话「是给用户列出几个选项选择的那种窗口,总是一会出现、一会儿消失,持续不停。」
|
|
6
|
+
*
|
|
7
|
+
* 只读诊断(`dsh-relay-internal/2026-10-13-反馈fb_3c4c5a13bc47-诊断.md`)给出的证据链:
|
|
8
|
+
* · `audit_log` 里该设备当天 `device.claim` **3567 次**,相邻间隔 p50 **12001ms**、p90 12281ms,
|
|
9
|
+
* 连续 10 小时整点 300 次/小时(300 × 12s = 3600s)。而 `device.claim` 是 **bridge 进程启动**
|
|
10
|
+
* 的必然产物(一个进程只登记一次)→ 3567 次 = 3567 次进程启动;
|
|
11
|
+
* · `relay_sessions` 该设备 236 条 ws 会话里 235 条挤在 2h20m 内,单条时长均值 5.3s、最大 7.4s,
|
|
12
|
+
* 相邻间隔 p50 7.05s(+ 隧道建立 ≈12.3s 一个完整周期);
|
|
13
|
+
* · 每次新 bridge 连接顶掉旧隧道 → 中继 `close(1012 "bridge offline")` 掉该设备**所有**手机镜像
|
|
14
|
+
* WS → 镜像页每 12 秒断线重连并**全量重放**界面(同一份 bytes 反复出现)→ 交互弹窗的临时
|
|
15
|
+
* 界面状态被反复销毁重建 = 用户看到的"一会儿显示、一会儿消失";
|
|
16
|
+
* · 全站性:上百台设备每天 1.1~1.5 万次重登记,多台设备间隔 p50 精确 ≈12.0s
|
|
17
|
+
* (11996/11998/12000/12001/12002ms)—— 只可能来自客户端同一个 12s 常量;
|
|
18
|
+
* · 中继侧对这类 churn 只做了日志抑制,所以**运维侧一直没发现**。
|
|
19
|
+
*
|
|
20
|
+
* 代码落点:`lib/index.js` 的 `scheduleRuntime()`(12s 定时器)→ 就绪判据只有
|
|
21
|
+
* `launchdStatus().running` 与 `manualStatus().watcher.length > 0`(后者靠 `pgrep` 名称匹配,
|
|
22
|
+
* 极易拿到空结果)→ 判据为假就 `startBridge()` → `startBridgeLinux()` → **无条件**
|
|
23
|
+
* `systemctl --user restart dsh-bridge`(restart 会杀掉正在健康运行的 bridge)。
|
|
24
|
+
*
|
|
25
|
+
* 本文件锁死的四件事(对应用户给的四条修复要求):
|
|
26
|
+
* ① **权威判据**:`systemctl --user is-active` 说 active ⇒ 就是"在跑",pgrep 说什么都不算;
|
|
27
|
+
* ② **幂等**:已 active 时自愈**绝不得**调用 `systemctl restart`(假 runner 断言调用次数为 0);
|
|
28
|
+
* ③ **成功即止**:一旦判定就绪,同一进程生命周期内定时器真的停摆(后续 systemctl 调用数冻结);
|
|
29
|
+
* ④ **熔断 + 退避 + 可观测**:最小重启间隔内不重复重启;同一进程内最多 3 次;触顶后停手
|
|
30
|
+
* 并把原因写进**可见**的安装日志(面板「复制诊断信息」与用户主动上报都会带它的尾部)。
|
|
31
|
+
*
|
|
32
|
+
* ⚠️ 测试隔离(与 `linux-bridge.test.mjs` 同一套手法):
|
|
33
|
+
* · 走**真实分支**(删掉 DSH_RELAY_SKIP_SERVICE),所以必须伪造 HOME 与 PATH;
|
|
34
|
+
* · 所有系统命令都是假 PATH 上的假脚本(`systemctl` 只写调用日志 + 读写状态文件),
|
|
35
|
+
* **不会**碰开发者本机上真实的 systemd/launchd;
|
|
36
|
+
* · 预置一份 `.dsh-watcher.pid` 指向**本测试进程**(必然存活)→ `startBridgeDetached`
|
|
37
|
+
* 的"已在跑"分支立刻返回,因此**不会真的派生**任何后台进程。
|
|
38
|
+
*/
|
|
39
|
+
import assert from "node:assert/strict";
|
|
40
|
+
import http from "node:http";
|
|
41
|
+
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from "node:fs";
|
|
42
|
+
import os from "node:os";
|
|
43
|
+
import path from "node:path";
|
|
44
|
+
import test from "node:test";
|
|
45
|
+
|
|
46
|
+
import { apply } from "../lib/index.js";
|
|
47
|
+
import { __scheduleRuntime, __startBridge } from "../lib/index.js";
|
|
48
|
+
import {
|
|
49
|
+
__linuxSystemdUnitState,
|
|
50
|
+
__selfhealRestartGate,
|
|
51
|
+
__noteSelfhealRestart,
|
|
52
|
+
__noteSelfhealBlocked,
|
|
53
|
+
__resetSelfhealGuard,
|
|
54
|
+
__selfhealGuardOf,
|
|
55
|
+
__SELFHEAL_MAX_RESTARTS,
|
|
56
|
+
} from "../lib/index.js";
|
|
57
|
+
|
|
58
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
59
|
+
|
|
60
|
+
/** 本轮上游地址(显式给死,免得依赖 boundWebPort / 端口发现 → 用例结果与开发机状态耦合)。 */
|
|
61
|
+
const UPSTREAM = "http://127.0.0.1:12345";
|
|
62
|
+
/** 模拟"上游地址在抖"时被写回来的旧值。 */
|
|
63
|
+
const UPSTREAM_OLD = "http://127.0.0.1:3080";
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 假 systemctl:**每一次调用都记进日志**(用例据此断言 `restart` 被调用了几次),
|
|
67
|
+
* 状态机放在一个文件里,于是"restart 之后 bridge 真的起来了没有"由用例决定:
|
|
68
|
+
* · DSH_TEST_RESTART_HEALS=1 → restart 把状态置为 active(模拟重启后真的恢复);
|
|
69
|
+
* · =0 → restart 不改变状态(模拟"重启了但起不来")。
|
|
70
|
+
*/
|
|
71
|
+
const FAKE_SYSTEMCTL = `#!/bin/sh
|
|
72
|
+
printf '%s\\n' "$*" >> "$DSH_TEST_SYSTEMCTL_LOG"
|
|
73
|
+
case "$*" in
|
|
74
|
+
*show-environment*) exit 0 ;;
|
|
75
|
+
*is-active*)
|
|
76
|
+
s="$(cat "$DSH_TEST_SYSTEMCTL_STATE" 2>/dev/null)"
|
|
77
|
+
[ -z "$s" ] && s=inactive
|
|
78
|
+
echo "$s"
|
|
79
|
+
[ "$s" = "active" ] && exit 0 || exit 3
|
|
80
|
+
;;
|
|
81
|
+
*daemon-reload*) exit 0 ;;
|
|
82
|
+
*enable*) exit 0 ;;
|
|
83
|
+
*restart*)
|
|
84
|
+
if [ "$DSH_TEST_RESTART_HEALS" = "1" ]; then printf 'active\\n' > "$DSH_TEST_SYSTEMCTL_STATE"; fi
|
|
85
|
+
exit 0
|
|
86
|
+
;;
|
|
87
|
+
esac
|
|
88
|
+
exit 0
|
|
89
|
+
`;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 隔离环境:假 HOME(systemd unit 落这里)+ 假 systemctl/pgrep/ps + 临时 relayDir。
|
|
93
|
+
* @param {{state?:string, heals?:boolean, minRestartMs?:number, selfhealMs?:number}} opts
|
|
94
|
+
* state = 假 systemd 的初始状态(active/inactive/failed);heals = restart 之后是否变成 active。
|
|
95
|
+
*/
|
|
96
|
+
function makeEnv({ state = "inactive", heals = false, minRestartMs = 60_000, selfhealMs = 25 } = {}) {
|
|
97
|
+
const root = mkdtempSync(path.join(os.tmpdir(), "dsh-selfheal-"));
|
|
98
|
+
const fakeBin = path.join(root, "bin");
|
|
99
|
+
const fakeHome = path.join(root, "home");
|
|
100
|
+
const relayDir = path.join(root, "relay");
|
|
101
|
+
mkdirSync(fakeBin, { recursive: true });
|
|
102
|
+
mkdirSync(fakeHome, { recursive: true });
|
|
103
|
+
mkdirSync(relayDir, { recursive: true });
|
|
104
|
+
|
|
105
|
+
const sysctlLog = path.join(root, "systemctl.log");
|
|
106
|
+
const sysctlState = path.join(root, "systemctl.state");
|
|
107
|
+
writeFileSync(sysctlLog, "");
|
|
108
|
+
writeFileSync(sysctlState, `${state}\n`);
|
|
109
|
+
writeFileSync(path.join(fakeBin, "systemctl"), FAKE_SYSTEMCTL);
|
|
110
|
+
chmodSync(path.join(fakeBin, "systemctl"), 0o755);
|
|
111
|
+
// 假 pgrep:**一个进程都不匹配**(= 本次故障里那个"极易拿到空结果"的探测)。
|
|
112
|
+
// 用例要证明的正是:即使它一无所获,只要 systemd 说 active,就绝不许 restart。
|
|
113
|
+
for (const n of ["pgrep", "ps"]) {
|
|
114
|
+
writeFileSync(path.join(fakeBin, n), "#!/bin/sh\nexit 1\n");
|
|
115
|
+
chmodSync(path.join(fakeBin, n), 0o755);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// 运行环境入口:无相对 import → runtimeReady 判定为就绪(不会去跑 npx 补装)。
|
|
119
|
+
writeFileSync(path.join(relayDir, "dsh-setup.mjs"), "// 测试用运行环境入口(无相对 import)\n");
|
|
120
|
+
// 已登录(local_key 即视为有账号)→ 自愈 watcher 才会走到"服务在不在跑"这一步。
|
|
121
|
+
writeFileSync(path.join(relayDir, ".dsh-config.json"), JSON.stringify({
|
|
122
|
+
api_url: "http://127.0.0.1:1", local_key: "test-local-key", device_id: "dev-test-selfheal",
|
|
123
|
+
}));
|
|
124
|
+
// 上游文件预先写成与 DSH_BRIDGE_UPSTREAM 一致 → publishUpstream() 回报 changed=false
|
|
125
|
+
// (否则"上游刚变过"本身就是一个合法的重启理由,会把用例的意图搅浑)。
|
|
126
|
+
writeFileSync(path.join(relayDir, ".dsh-upstream"), `${UPSTREAM}\n`);
|
|
127
|
+
// ⚠️ 关键隔离:pid 文件指向**本测试进程**(必然存活)→ startBridgeDetached 的
|
|
128
|
+
// "已在跑,别再拉第二个"分支立刻返回 → 用例永远不会真的派生后台 bridge 进程。
|
|
129
|
+
writeFileSync(path.join(relayDir, ".dsh-watcher.pid"), `${process.pid}\n`);
|
|
130
|
+
|
|
131
|
+
const saved = {};
|
|
132
|
+
const keys = ["HOME", "PATH", "DSH_RELAY_PLATFORM", "DSH_RELAY_SKIP_SERVICE", "DSH_REMOTE_TELEMETRY",
|
|
133
|
+
"DSH_RELAY_SELFHEAL_MS", "DSH_RELAY_SELFHEAL_MIN_RESTART_MS", "DSH_RELAY_DEFAULT_API",
|
|
134
|
+
"DSH_BRIDGE_UPSTREAM", "DSH_WEB_URL", "XDG_RUNTIME_DIR", "DSH_BRIDGE_INSTALL_SOURCE",
|
|
135
|
+
"DSH_TEST_SYSTEMCTL_LOG", "DSH_TEST_SYSTEMCTL_STATE", "DSH_TEST_RESTART_HEALS"];
|
|
136
|
+
for (const k of keys) saved[k] = process.env[k];
|
|
137
|
+
|
|
138
|
+
process.env.HOME = fakeHome;
|
|
139
|
+
process.env.PATH = `${fakeBin}${path.delimiter}${saved.PATH || ""}`;
|
|
140
|
+
process.env.DSH_RELAY_PLATFORM = "linux";
|
|
141
|
+
process.env.DSH_REMOTE_TELEMETRY = "0";
|
|
142
|
+
process.env.DSH_RELAY_DEFAULT_API = "http://127.0.0.1:1";
|
|
143
|
+
process.env.DSH_BRIDGE_UPSTREAM = UPSTREAM;
|
|
144
|
+
process.env.XDG_RUNTIME_DIR = path.join(root, "run");
|
|
145
|
+
mkdirSync(process.env.XDG_RUNTIME_DIR, { recursive: true });
|
|
146
|
+
process.env.DSH_RELAY_SELFHEAL_MS = String(selfhealMs);
|
|
147
|
+
process.env.DSH_RELAY_SELFHEAL_MIN_RESTART_MS = String(minRestartMs);
|
|
148
|
+
process.env.DSH_TEST_SYSTEMCTL_LOG = sysctlLog;
|
|
149
|
+
process.env.DSH_TEST_SYSTEMCTL_STATE = sysctlState;
|
|
150
|
+
process.env.DSH_TEST_RESTART_HEALS = heals ? "1" : "0";
|
|
151
|
+
delete process.env.DSH_RELAY_SKIP_SERVICE; // 走真实分支,但系统命令全是假脚本
|
|
152
|
+
delete process.env.DSH_WEB_URL;
|
|
153
|
+
delete process.env.DSH_BRIDGE_INSTALL_SOURCE;
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
root, relayDir, fakeHome, sysctlLog, sysctlState,
|
|
157
|
+
/** systemctl 被调用过的**所有**命令行(一行一次)。 */
|
|
158
|
+
calls: () => readFileSync(sysctlLog, "utf8").split("\n").filter(Boolean),
|
|
159
|
+
/** 断言用:某个子命令被调用了几次。 */
|
|
160
|
+
count: (needle) => readFileSync(sysctlLog, "utf8").split("\n").filter((l) => l.includes(needle)).length,
|
|
161
|
+
installLog: () => {
|
|
162
|
+
const p = path.join(relayDir, ".dsh-setup-install.log");
|
|
163
|
+
return existsSync(p) ? readFileSync(p, "utf8") : "";
|
|
164
|
+
},
|
|
165
|
+
/** 上游文件"抖"回旧值(见护栏用例)。 */
|
|
166
|
+
rewindUpstream: () => writeFileSync(path.join(relayDir, ".dsh-upstream"), `${UPSTREAM_OLD}\n`),
|
|
167
|
+
setState: (s) => writeFileSync(sysctlState, `${s}\n`),
|
|
168
|
+
restore() {
|
|
169
|
+
__resetSelfhealGuard(relayDir);
|
|
170
|
+
for (const [k, v] of Object.entries(saved)) { if (v === undefined) delete process.env[k]; else process.env[k] = v; }
|
|
171
|
+
rmSync(root, { recursive: true, force: true });
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** 起一个只跑插件路由的假宿主(与 `linux-bridge.test.mjs` 同一手法):只为驱动真实的面板端点。 */
|
|
177
|
+
function bootRoutes(relayDir) {
|
|
178
|
+
const routes = new Map();
|
|
179
|
+
const disposers = [];
|
|
180
|
+
apply({
|
|
181
|
+
webServer: { port: 0, register(route) { routes.set(route.path, route.handler); return () => {}; } },
|
|
182
|
+
get() { return null; },
|
|
183
|
+
effect(register) { const d = register(); if (typeof d === "function") disposers.push(d); return d; },
|
|
184
|
+
logger: { info() {}, warn() {} },
|
|
185
|
+
}, { relayDir });
|
|
186
|
+
routes.dispose = () => { for (const d of disposers) { try { d(); } catch { /* ignore */ } } };
|
|
187
|
+
return routes;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function serveRoutes(routes) {
|
|
191
|
+
const host = http.createServer((req, res) => {
|
|
192
|
+
const handler = routes.get(new URL(req.url, "http://x").pathname);
|
|
193
|
+
(handler || ((_r, rs) => { rs.writeHead(404); rs.end(); }))(req, res);
|
|
194
|
+
});
|
|
195
|
+
await new Promise((resolve) => host.listen(0, "127.0.0.1", resolve));
|
|
196
|
+
return {
|
|
197
|
+
base: `http://127.0.0.1:${host.address().port}`,
|
|
198
|
+
async close() { await new Promise((r) => host.close(r)); },
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ─────────────── ① 权威判据 + ② 幂等:已 active 时自愈绝不得 restart ───────────────
|
|
203
|
+
test("★ ①:systemctl 说 active(而 pgrep 一无所获)→ 自愈 watcher 必须判定「就绪」,restart 调用 0 次", async () => {
|
|
204
|
+
const env = makeEnv({ state: "active" });
|
|
205
|
+
let stop = null;
|
|
206
|
+
try {
|
|
207
|
+
// 前置:权威判据本身要能读出 active(三态里的"明确 active")
|
|
208
|
+
assert.deepEqual(__linuxSystemdUnitState(), { known: true, active: true, raw: "active" });
|
|
209
|
+
|
|
210
|
+
stop = __scheduleRuntime(env.relayDir);
|
|
211
|
+
await sleep(400); // 自愈间隔 25ms → 这段窗口内本会跑十几拍
|
|
212
|
+
|
|
213
|
+
const calls = env.calls();
|
|
214
|
+
assert.equal(env.count("restart"), 0,
|
|
215
|
+
`★ 已 active 就绝不许 restart(那是这台机器上唯一会造成"手机镜像每 12 秒断一次"的动作)。`
|
|
216
|
+
+ `实际调用:\n${calls.join("\n")}`);
|
|
217
|
+
assert.equal(env.count("stop"), 0, "更不许 stop:停掉正在跑的 bridge 会直接断掉用户的远程访问");
|
|
218
|
+
assert.ok(env.calls().some((l) => l.includes("is-active")), "应当用 is-active 做权威判据,而不是猜进程名");
|
|
219
|
+
|
|
220
|
+
// 判定就绪 → 循环停摆:调用数必须冻结(不只是"被限流",是真的不再探了)
|
|
221
|
+
const frozen = env.calls().length;
|
|
222
|
+
await sleep(300);
|
|
223
|
+
assert.equal(env.calls().length, frozen, "★ 判定就绪后定时器必须真的停摆(调用数冻结)");
|
|
224
|
+
|
|
225
|
+
// 可观测:留下"判定就绪、已停止自愈循环"的一行(同一条结论只写一次)
|
|
226
|
+
const log = env.installLog();
|
|
227
|
+
assert.match(log, /自愈判定 bridge 已就绪,已停止自愈循环/, `安装日志必须留下判据与依据,实际:\n${log}`);
|
|
228
|
+
assert.match(log, /systemd --user is-active=active/, "日志要写清依据是 systemd 的权威回答");
|
|
229
|
+
assert.doesNotMatch(log, /自愈重启 bridge/, "既然没重启,就不该有重启记录");
|
|
230
|
+
} finally {
|
|
231
|
+
stop?.();
|
|
232
|
+
env.restore();
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("★ ②:已 active 时反复调用 startBridge(面板重试/自愈轮次)也必须幂等 —— restart 恒为 0 次", () => {
|
|
237
|
+
const env = makeEnv({ state: "active" });
|
|
238
|
+
try {
|
|
239
|
+
for (let i = 0; i < 6; i += 1) {
|
|
240
|
+
const r = __startBridge(env.relayDir);
|
|
241
|
+
assert.equal(r.ok, true, `已 active 时必须如实回报 ok(第 ${i + 1} 次),实际 ${JSON.stringify(r)}`);
|
|
242
|
+
assert.equal(r.reused, true, `已 active 时必须标记 reused(第 ${i + 1} 次)`);
|
|
243
|
+
assert.equal(r.status, "running");
|
|
244
|
+
}
|
|
245
|
+
assert.equal(env.count("restart"), 0,
|
|
246
|
+
`★ 幂等闸门失效:已 active 却调了 restart。实际调用:\n${env.calls().join("\n")}`);
|
|
247
|
+
assert.equal(env.count("daemon-reload"), 0, "既然没必要重启,就不该连 daemon-reload 一起做(避免无谓的 systemd 抖动)");
|
|
248
|
+
assert.equal(env.count("enable"), 0);
|
|
249
|
+
} finally {
|
|
250
|
+
env.restore();
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// ─────────────── ② + ③:确实没在跑才重启,且最小间隔内不重复、成功即止 ───────────────
|
|
255
|
+
|
|
256
|
+
test("★ ②③:真的不 active 时才重启**一次**;成功即止(同一进程生命周期内不再有 restart)", async () => {
|
|
257
|
+
// 模拟"systemd 里它确实没在跑,但 restart 之后真的起来了"
|
|
258
|
+
const env = makeEnv({ state: "inactive", heals: true, minRestartMs: 60_000, selfhealMs: 25 });
|
|
259
|
+
let stop = null;
|
|
260
|
+
try {
|
|
261
|
+
stop = __scheduleRuntime(env.relayDir);
|
|
262
|
+
await sleep(250);
|
|
263
|
+
assert.equal(env.count("restart"), 1,
|
|
264
|
+
`★ 只该重启一次,实际 ${env.count("restart")} 次:\n${env.calls().join("\n")}`);
|
|
265
|
+
|
|
266
|
+
// 成功即止:定时器必须停摆,后续调用数冻结(不是"每 25ms 被限流一次")
|
|
267
|
+
const frozen = env.calls().length;
|
|
268
|
+
await sleep(300);
|
|
269
|
+
assert.equal(env.count("restart"), 1, "★ 判定成功后再也不许有 restart 调用");
|
|
270
|
+
assert.equal(env.calls().length, frozen, "★ 判定成功之后定时器必须停摆(调用数冻结)");
|
|
271
|
+
|
|
272
|
+
const log = env.installLog();
|
|
273
|
+
assert.match(log, /自愈重启 bridge(第 1\/3 次/, `重启必须留可见日志,实际:\n${log}`);
|
|
274
|
+
assert.match(log, /→ running/, "日志要带上重启后的结果");
|
|
275
|
+
assert.match(log, /自愈判定 bridge 已就绪,已停止自愈循环/, "就绪后要写「循环已停」的一行");
|
|
276
|
+
} finally {
|
|
277
|
+
stop?.();
|
|
278
|
+
env.restore();
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("★ ④:最小重启间隔内**不重复**重启,并且写下可见的「已暂缓」日志", () => {
|
|
283
|
+
// 起不来(heals=false)+ 最小间隔 60 秒:真实重启只许发生 1 次,后面的尝试全被拦下。
|
|
284
|
+
const env = makeEnv({ state: "inactive", heals: false, minRestartMs: 60_000 });
|
|
285
|
+
try {
|
|
286
|
+
for (let i = 0; i < 5; i += 1) __startBridge(env.relayDir);
|
|
287
|
+
assert.equal(env.count("restart"), 1,
|
|
288
|
+
`★ 最小间隔(60s)内不得重复 restart,实际 ${env.count("restart")} 次:\n${env.calls().join("\n")}`);
|
|
289
|
+
|
|
290
|
+
const log = env.installLog();
|
|
291
|
+
assert.match(log, /已暂缓自愈重启 bridge:距上次重启不足 60 秒/, `实际:\n${log}`);
|
|
292
|
+
// 去重:同一条结论只说一次(否则每 12 秒一行,用户复制过来的诊断里全是重复行)
|
|
293
|
+
const hits = log.split("\n").filter((l) => l.includes("已暂缓自愈重启 bridge")).length;
|
|
294
|
+
assert.equal(hits, 1, `同一条"已暂缓"结论不得刷屏,实际 ${hits} 行`);
|
|
295
|
+
} finally {
|
|
296
|
+
env.restore();
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
// ─────────────── ④ 熔断触顶:停手 + 明确报错 ───────────────
|
|
301
|
+
|
|
302
|
+
test("★ ④:破坏性重启触顶(本进程最多 3 次)后**停手**,并写下明确的熔断日志", () => {
|
|
303
|
+
// 场景:上游地址在抖(上游文件被另一个组件写回旧值)→ 每轮都会认为"需要重启才能生效"。
|
|
304
|
+
// 这正是护栏存在的意义:哪怕上游真的在抖,也绝不能退化成"每 12 秒杀一次隧道"。
|
|
305
|
+
const env = makeEnv({ state: "active", minRestartMs: 0 });
|
|
306
|
+
try {
|
|
307
|
+
for (let i = 0; i < 6; i += 1) {
|
|
308
|
+
env.rewindUpstream(); // 上游地址又变了 → 又一个"合法的重启理由"
|
|
309
|
+
__startBridge(env.relayDir);
|
|
310
|
+
}
|
|
311
|
+
assert.equal(env.count("restart"), __SELFHEAL_MAX_RESTARTS,
|
|
312
|
+
`★ 同一进程内最多 ${__SELFHEAL_MAX_RESTARTS} 次破坏性重启,实际 ${env.count("restart")} 次:\n${env.calls().join("\n")}`);
|
|
313
|
+
assert.equal(__selfhealGuardOf(env.relayDir).count, __SELFHEAL_MAX_RESTARTS);
|
|
314
|
+
|
|
315
|
+
const log = env.installLog();
|
|
316
|
+
assert.match(log, /已停止自愈重启 bridge:本进程生命周期内已重启 3 次仍未就绪/, `实际:\n${log}`);
|
|
317
|
+
assert.match(log, /已熔断/, "要把「熔断」这个结论明确写给用户/运维");
|
|
318
|
+
assert.match(log, /一键更新/, "要给出下一步动作,而不是只报一个错误");
|
|
319
|
+
const hits = log.split("\n").filter((l) => l.includes("已停止自愈重启 bridge")).length;
|
|
320
|
+
assert.equal(hits, 1, `熔断结论只说一次,实际 ${hits} 行`);
|
|
321
|
+
|
|
322
|
+
// 触顶之后再怎么抖,也不许再动 bridge
|
|
323
|
+
for (let i = 0; i < 3; i += 1) { env.rewindUpstream(); __startBridge(env.relayDir); }
|
|
324
|
+
assert.equal(env.count("restart"), __SELFHEAL_MAX_RESTARTS, "★ 触顶后必须彻底停手");
|
|
325
|
+
} finally {
|
|
326
|
+
env.restore();
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test("★ ④ 护栏本身:纯查询语义(不记账)+ 三态边界", () => {
|
|
331
|
+
const env = makeEnv({ state: "inactive", minRestartMs: 1000 });
|
|
332
|
+
try {
|
|
333
|
+
// 纯查询:反复问不许改变状态(否则"只查不做"的路径会凭空吃掉额度)
|
|
334
|
+
for (let i = 0; i < 5; i += 1) assert.equal(__selfhealRestartGate(env.relayDir).allowed, true);
|
|
335
|
+
assert.equal(__selfhealGuardOf(env.relayDir).count, 0, "查询不得记账");
|
|
336
|
+
|
|
337
|
+
__noteSelfhealRestart(env.relayDir, "测试", "running");
|
|
338
|
+
assert.equal(__selfhealGuardOf(env.relayDir).count, 1);
|
|
339
|
+
const tooSoon = __selfhealRestartGate(env.relayDir);
|
|
340
|
+
assert.equal(tooSoon.allowed, false);
|
|
341
|
+
assert.equal(tooSoon.reason, "too-soon");
|
|
342
|
+
assert.ok(tooSoon.waitMs > 0 && tooSoon.waitMs <= 1000, `waitMs 应落在剩余窗口内,实际 ${tooSoon.waitMs}`);
|
|
343
|
+
|
|
344
|
+
__noteSelfhealRestart(env.relayDir, "测试", "running");
|
|
345
|
+
__noteSelfhealRestart(env.relayDir, "测试", "running");
|
|
346
|
+
const exhausted = __selfhealRestartGate(env.relayDir);
|
|
347
|
+
assert.equal(exhausted.allowed, false);
|
|
348
|
+
assert.equal(exhausted.reason, "exhausted");
|
|
349
|
+
// 记一次重启会清掉"被拦下"的去重状态(结论变了要重新说)
|
|
350
|
+
assert.equal(__selfhealGuardOf(env.relayDir).blockedLine, "");
|
|
351
|
+
|
|
352
|
+
__resetSelfhealGuard(env.relayDir);
|
|
353
|
+
assert.equal(__selfhealGuardOf(env.relayDir).count, 0, "桥恢复后要能重新自愈");
|
|
354
|
+
assert.equal(__selfhealRestartGate(env.relayDir).allowed, true);
|
|
355
|
+
} finally {
|
|
356
|
+
env.restore();
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
test("★ ④ 护栏文案:被拦下时写进**可见**日志(面板诊断/用户上报会带它)", () => {
|
|
361
|
+
const env = makeEnv({ state: "inactive", minRestartMs: 5000, selfhealMs: 12_000 });
|
|
362
|
+
try {
|
|
363
|
+
assert.equal(__noteSelfhealBlocked(env.relayDir, { reason: "too-soon", count: 1, waitMs: 4000 }), true);
|
|
364
|
+
assert.equal(__noteSelfhealBlocked(env.relayDir, { reason: "too-soon", count: 1, waitMs: 1234 }), false,
|
|
365
|
+
"★ 文案里不得带倒计时秒数,否则每拍文本都不同、去重失效 → 又变成刷屏");
|
|
366
|
+
assert.equal(__noteSelfhealBlocked(env.relayDir, { reason: "exhausted", count: 3, waitMs: 0 }), true);
|
|
367
|
+
const log = env.installLog();
|
|
368
|
+
assert.match(log, /已暂缓自愈重启 bridge/);
|
|
369
|
+
assert.match(log, /已停止自愈重启 bridge/);
|
|
370
|
+
// 文案必须点出"那个 12 秒"本身:它就是这个故障的名字,也是用户能对上号的信息
|
|
371
|
+
assert.match(log, /每 12 秒重启一次/);
|
|
372
|
+
assert.match(log, /每 12 秒杀掉一次正在跑的隧道/);
|
|
373
|
+
} finally {
|
|
374
|
+
env.restore();
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
// ─────────────── 契约锁:护栏只约束"自动",绝不约束"人" ───────────────
|
|
379
|
+
|
|
380
|
+
test("★ 契约:面板「重试」(ensureConnection force=true)必须无视已熔断的护栏,仍然把 bridge 拉起来", async () => {
|
|
381
|
+
// 自愈间隔设成 10 分钟 → 定时器在本用例里绝不会自己跑,行为只来自面板这一次显式请求。
|
|
382
|
+
const env = makeEnv({ state: "inactive", heals: true, minRestartMs: 60_000, selfhealMs: 600_000 });
|
|
383
|
+
const routes = bootRoutes(env.relayDir);
|
|
384
|
+
const srv = await serveRoutes(routes);
|
|
385
|
+
try {
|
|
386
|
+
// 前置:模拟"自动自愈已经把 3 次额度用完并熔断"
|
|
387
|
+
for (let i = 0; i < __SELFHEAL_MAX_RESTARTS; i += 1) __noteSelfhealRestart(env.relayDir, "前置", "running");
|
|
388
|
+
assert.equal(__selfhealRestartGate(env.relayDir).reason, "exhausted", "前置条件:护栏必须已熔断");
|
|
389
|
+
assert.equal(__startBridge(env.relayDir).status, "selfheal-throttled", "前置条件:自动路径此刻确实被拦下");
|
|
390
|
+
|
|
391
|
+
// 用户点面板「重试」→ POST /dsh-remote/connect/retry(内部 force=true)
|
|
392
|
+
const res = await fetch(`${srv.base}/dsh-remote/connect/retry`, { method: "POST" });
|
|
393
|
+
assert.equal(res.status, 200);
|
|
394
|
+
assert.equal(env.count("restart"), 1,
|
|
395
|
+
`★ 人工操作必须永远立即放行(与 resetProvisionRetry 同一原则)——`
|
|
396
|
+
+ `否则熔断会变成"用户被自己的插件锁在门外"。实际调用:\n${env.calls().join("\n")}`);
|
|
397
|
+
// 账号切换同理(resetBridgeForAccountChange 里也清了额度),这里只锁住"重试"这一条即可
|
|
398
|
+
assert.equal(__linuxSystemdUnitState().active, true, "重试之后 bridge 应当真的起来了");
|
|
399
|
+
} finally {
|
|
400
|
+
await srv.close();
|
|
401
|
+
routes.dispose();
|
|
402
|
+
env.restore();
|
|
403
|
+
}
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
// ─────────────── 隔离自检:本文件不得碰真实系统 ───────────────
|
|
407
|
+
|
|
408
|
+
test("★ 隔离自检:本文件走的都是假 systemctl(真实系统命令一个都不碰)", () => {
|
|
409
|
+
const env = makeEnv({ state: "inactive" });
|
|
410
|
+
try {
|
|
411
|
+
__startBridge(env.relayDir);
|
|
412
|
+
const calls = env.calls();
|
|
413
|
+
assert.ok(calls.length > 0, "前置:假 systemctl 必须真的被调用过(否则下面的断言恒真)");
|
|
414
|
+
assert.ok(calls.every((l) => l.startsWith("--user ")), `假 runner 收到的是 systemctl --user 系列命令:${calls.join(" | ")}`);
|
|
415
|
+
// process.execPath 是本机 node,但 pid 文件指向本测试进程 → 绝不会派生真的 watcher
|
|
416
|
+
assert.equal(env.installLog().includes("脱离进程启动 bridge 失败"), false);
|
|
417
|
+
} finally {
|
|
418
|
+
env.restore();
|
|
419
|
+
}
|
|
420
|
+
});
|