@mrrisega/dsh-remote 0.6.8 → 0.6.9-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/README.md +1 -1
- package/docs/telemetry.md +5 -1
- package/dsh-setup.mjs +18 -18
- package/package.json +1 -1
- package/packages/dsh-remote-web/lib/client.js +754 -148
- package/packages/dsh-remote-web/lib/index.js +329 -29
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/test/update-stall.test.mjs +315 -0
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 「更新/补装 卡死」回归(0.6.9 新增)—— 直接对应用户侧 0.6.8 诊断报告。
|
|
3
|
+
*
|
|
4
|
+
* 报告的核心结论:旧的三套卡死治理(sweepStaleMarkers / runOnlineUpdate 的陈旧判定 / 镜像回退)
|
|
5
|
+
* **判据全选错了** —— 它们问的是"标记还在不在 / 进程还活不活",没有一套在问"它还在不在往前推进"。
|
|
6
|
+
* 而中国网络访问 `registry.npmjs.org` 的**典型**失败形态恰恰是**挂起**:
|
|
7
|
+
* 进程活着、标记是新的、却永远不产出任何事件(没有 exit、没有新日志)→
|
|
8
|
+
* 回退永不触发、标记永不清除、用户永久停在「更新中」,而装机数就是不涨。
|
|
9
|
+
*
|
|
10
|
+
* 本文件用**真的会卡住的子进程**(假 npx:只 sleep、什么都不写)把链路压到亚秒级验证:
|
|
11
|
+
* ① 卡住会被判定出来(stalled)→ ② 进程被结束 → ③ 自动换源重试 → ④ 仍失败则如实记 `update_stalled`
|
|
12
|
+
* → ⑤ 标记被清掉(用户可以重试,不再永久转圈);另有孤儿回收、轮询驱动自愈、取消接口、通道自洽。
|
|
13
|
+
*/
|
|
14
|
+
import assert from "node:assert/strict";
|
|
15
|
+
import http from "node:http";
|
|
16
|
+
import { chmod, mkdtemp, mkdir, readFile, rm, utimes, writeFile } from "node:fs/promises";
|
|
17
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
18
|
+
import os from "node:os";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import test from "node:test";
|
|
21
|
+
import { fileURLToPath } from "node:url";
|
|
22
|
+
import { apply } from "../lib/index.js";
|
|
23
|
+
|
|
24
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
25
|
+
const INDEX_SRC = readFileSync(path.join(HERE, "..", "lib", "index.js"), "utf8");
|
|
26
|
+
|
|
27
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
28
|
+
async function waitFor(fn, { timeout = 15000, step = 50 } = {}) {
|
|
29
|
+
const deadline = Date.now() + timeout;
|
|
30
|
+
for (;;) {
|
|
31
|
+
if (await fn()) return true;
|
|
32
|
+
if (Date.now() > deadline) return false;
|
|
33
|
+
await sleep(step);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 造一个"会卡住"的假 npx:只睡、不写任何输出(= 挂起的形态)。 */
|
|
38
|
+
const HANGING_NPX = `#!/bin/sh
|
|
39
|
+
sleep 600
|
|
40
|
+
`;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 隔离环境:假 HOME、假 npx 目录、极短的看门狗阈值。
|
|
44
|
+
* 注意 DSH_UPDATE_IDLE_MS / DSH_STALL_POLL_MS 只用于把链路压到亚秒级,生产不设。
|
|
45
|
+
*/
|
|
46
|
+
async function makeEnv({ npxBody = HANGING_NPX } = {}) {
|
|
47
|
+
const root = await mkdtemp(path.join(os.tmpdir(), "dsh-stall-"));
|
|
48
|
+
const fakeBin = path.join(root, "bin");
|
|
49
|
+
const relayDir = path.join(root, "relay");
|
|
50
|
+
const home = path.join(root, "home");
|
|
51
|
+
await mkdir(fakeBin, { recursive: true });
|
|
52
|
+
await mkdir(relayDir, { recursive: true });
|
|
53
|
+
await mkdir(home, { recursive: true });
|
|
54
|
+
await writeFile(path.join(fakeBin, "npx"), npxBody);
|
|
55
|
+
await chmod(path.join(fakeBin, "npx"), 0o755);
|
|
56
|
+
|
|
57
|
+
const saved = {};
|
|
58
|
+
for (const k of ["HOME", "DSH_SETUP_NPX_DIR", "DSH_RELAY_SKIP_SERVICE", "DSH_UPDATE_IDLE_MS",
|
|
59
|
+
"DSH_INSTALL_IDLE_MS", "DSH_STALL_POLL_MS", "DSH_REMOTE_TELEMETRY", "DSH_UPDATE_TAG"]) saved[k] = process.env[k];
|
|
60
|
+
process.env.HOME = home;
|
|
61
|
+
process.env.DSH_SETUP_NPX_DIR = fakeBin;
|
|
62
|
+
process.env.DSH_REMOTE_TELEMETRY = "0";
|
|
63
|
+
process.env.DSH_UPDATE_IDLE_MS = "400"; // 400ms 无输出即判卡住
|
|
64
|
+
process.env.DSH_STALL_POLL_MS = "80";
|
|
65
|
+
delete process.env.DSH_RELAY_SKIP_SERVICE; // 要 spawn 真子进程(隔离只靠假 HOME/npx 目录)
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
root, relayDir, home, fakeBin,
|
|
69
|
+
marker: path.join(relayDir, ".dsh-update-running"),
|
|
70
|
+
log: path.join(relayDir, ".dsh-update.log"),
|
|
71
|
+
async restore() {
|
|
72
|
+
for (const [k, v] of Object.entries(saved)) { if (v === undefined) delete process.env[k]; else process.env[k] = v; }
|
|
73
|
+
await rm(root, { recursive: true, force: true });
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function boot(relayDir) {
|
|
79
|
+
const routes = new Map();
|
|
80
|
+
apply({
|
|
81
|
+
webServer: { register(route) { routes.set(route.path, route.handler); return () => {}; } },
|
|
82
|
+
effect(register) { return register(); },
|
|
83
|
+
logger: { info() {}, warn() {} },
|
|
84
|
+
}, { relayDir });
|
|
85
|
+
return routes;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function serve(routes) {
|
|
89
|
+
const host = http.createServer((req, res) => {
|
|
90
|
+
const handler = routes.get(new URL(req.url, "http://x").pathname);
|
|
91
|
+
(handler || ((_r, rs) => { rs.writeHead(404); rs.end(); }))(req, res);
|
|
92
|
+
});
|
|
93
|
+
await new Promise((resolve) => host.listen(0, "127.0.0.1", resolve));
|
|
94
|
+
return { host, base: `http://127.0.0.1:${host.address().port}` };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const post = async (base, p, body) => (await fetch(base + p, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body || {}) })).json();
|
|
98
|
+
const get = async (base, p) => (await fetch(base + p)).json();
|
|
99
|
+
|
|
100
|
+
// ─────────────── ① 卡住 → 判定 → 结束 → 换源重试 → 如实记失败 ───────────────
|
|
101
|
+
|
|
102
|
+
test("更新卡住(进程活着但零输出):看门狗结束进程、换源重试、最终如实记 update_stalled", async () => {
|
|
103
|
+
const env = await makeEnv();
|
|
104
|
+
const routes = boot(env.relayDir);
|
|
105
|
+
const { host, base } = await serve(routes);
|
|
106
|
+
try {
|
|
107
|
+
const started = await post(base, "/dsh-remote/self/update");
|
|
108
|
+
assert.equal(started.ok, true, `更新应能启动:${JSON.stringify(started)}`);
|
|
109
|
+
assert.ok(started.pid > 0);
|
|
110
|
+
|
|
111
|
+
// ① 进行中就要能看到"距离上次输出多久"(面板据此显示"疑似卡住")
|
|
112
|
+
const during = await get(base, "/dsh-remote/self/update-log");
|
|
113
|
+
assert.equal(during.running, true);
|
|
114
|
+
assert.equal(typeof during.elapsedMs, "number");
|
|
115
|
+
assert.equal(typeof during.idleMs, "number");
|
|
116
|
+
assert.equal(during.idleThresholdMs, 400);
|
|
117
|
+
|
|
118
|
+
// ② 卡住后必须被判出来(stalled)并被结束 → 标记最终清掉、失败如实记录
|
|
119
|
+
const cleared = await waitFor(() => !existsSync(env.marker), { timeout: 12000 });
|
|
120
|
+
assert.equal(cleared, true, "★卡住后标记必须被清掉(否则用户永久停在「更新中」,且无法重试)");
|
|
121
|
+
|
|
122
|
+
const log = readFileSync(env.log, "utf8");
|
|
123
|
+
assert.match(log, /无输出/, "日志要写明卡住判据" + `\n实际日志:\n${log.slice(-800)}`);
|
|
124
|
+
assert.match(log, /回退默认源/, "★卡住必须触发与 exit≠0 相同的换源回退(旧实现只认退出码)");
|
|
125
|
+
|
|
126
|
+
const after = await get(base, "/dsh-remote/self/update-log");
|
|
127
|
+
assert.equal(after.running, false);
|
|
128
|
+
assert.ok(after.failure, "必须给用户一个可查询的失败原因,而不是静默");
|
|
129
|
+
assert.equal(after.failure.failCode, "update_stalled", "★归因码必须是 update_stalled(否则统计里看不到「卡死」这一类)");
|
|
130
|
+
assert.match(String(after.failure.detail), /没有响应|无输出|卡住/);
|
|
131
|
+
} finally {
|
|
132
|
+
host.close();
|
|
133
|
+
await env.restore();
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("看门狗不会误杀在推进的进程(有输出就持续放行)", async () => {
|
|
138
|
+
// 假 npx:每 200ms 往日志写一行 —— 一直在推进,绝不能被杀
|
|
139
|
+
const env = await makeEnv({ npxBody: `#!/bin/sh
|
|
140
|
+
for i in 1 2 3 4 5 6 7 8 9 10 11 12; do
|
|
141
|
+
echo "[fake-npx] progress $i" >> "\${DSH_TEST_UPDATE_LOG:-/dev/null}"
|
|
142
|
+
sleep 0.2
|
|
143
|
+
done
|
|
144
|
+
sleep 600
|
|
145
|
+
` });
|
|
146
|
+
// 让假 npx 把进度写进真正的更新日志
|
|
147
|
+
const realLog = env.log;
|
|
148
|
+
const prevEnvLog = process.env.DSH_TEST_UPDATE_LOG;
|
|
149
|
+
process.env.DSH_TEST_UPDATE_LOG = realLog;
|
|
150
|
+
const routes = boot(env.relayDir);
|
|
151
|
+
const { host, base } = await serve(routes);
|
|
152
|
+
try {
|
|
153
|
+
const r = await post(base, "/dsh-remote/self/update");
|
|
154
|
+
assert.equal(r.ok, true);
|
|
155
|
+
await sleep(2400); // 远超 400ms 的卡死阈值,但进程一直在写
|
|
156
|
+
assert.equal(existsSync(env.marker), true, "★在推进的进程不得被判定卡住");
|
|
157
|
+
const st = await get(base, "/dsh-remote/self/update-log");
|
|
158
|
+
assert.equal(st.running, true);
|
|
159
|
+
assert.equal(st.stalled, false, "有持续输出时 stalled 必须为 false");
|
|
160
|
+
// 收尾:取消它
|
|
161
|
+
const c = await post(base, "/dsh-remote/self/update/cancel");
|
|
162
|
+
assert.equal(c.ok, true);
|
|
163
|
+
} finally {
|
|
164
|
+
if (prevEnvLog === undefined) delete process.env.DSH_TEST_UPDATE_LOG; else process.env.DSH_TEST_UPDATE_LOG = prevEnvLog;
|
|
165
|
+
host.close();
|
|
166
|
+
await env.restore();
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// ─────────────── ② 孤儿回收(P2) ───────────────
|
|
171
|
+
|
|
172
|
+
test("孤儿回收:宿主启动时,把「活着但早已不推进」的更新进程结束掉并清标记", async () => {
|
|
173
|
+
const env = await makeEnv();
|
|
174
|
+
// 造一个真实的挂起子进程,并把它的 pid 写进 marker(模拟宿主被重启后的残留)
|
|
175
|
+
const { spawn } = await import("node:child_process");
|
|
176
|
+
const sleeper = spawn(process.execPath, ["-e", "setTimeout(() => {}, 600000)"], { stdio: "ignore" });
|
|
177
|
+
await writeFile(env.marker, JSON.stringify({ pid: sleeper.pid, at: Date.now() - 60 * 60 * 1000 }));
|
|
178
|
+
await writeFile(env.log, "[update] 开始在线更新(旧的、早就卡住的一轮)\n");
|
|
179
|
+
// 把日志 mtime 推到很久以前:等价于"一小时没有任何输出"
|
|
180
|
+
const old = new Date(Date.now() - 60 * 60 * 1000);
|
|
181
|
+
await utimes(env.log, old, old);
|
|
182
|
+
try {
|
|
183
|
+
boot(env.relayDir); // apply() 内部会跑 sweepStaleMarkers
|
|
184
|
+
const dead = await waitFor(() => {
|
|
185
|
+
try { process.kill(sleeper.pid, 0); return false; } catch { return true; }
|
|
186
|
+
}, { timeout: 5000 });
|
|
187
|
+
assert.equal(dead, true, "★挂死的子进程必须被结束(旧实现只「判活」、从不「结束」 → 孤儿累积)");
|
|
188
|
+
assert.equal(existsSync(env.marker), false, "标记也要清掉,用户才能重试");
|
|
189
|
+
} finally {
|
|
190
|
+
try { sleeper.kill("SIGKILL"); } catch { /* 已退出 */ }
|
|
191
|
+
await env.restore();
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// ─────────────── ③ 复用面板轮询驱动自愈(F5) ───────────────
|
|
196
|
+
|
|
197
|
+
test("面板轮询驱动自愈:/bridge-status 顺手结束卡住的更新并给出可重试的失败原因", async () => {
|
|
198
|
+
const env = await makeEnv();
|
|
199
|
+
const { spawn } = await import("node:child_process");
|
|
200
|
+
const sleeper = spawn(process.execPath, ["-e", "setTimeout(() => {}, 600000)"], { stdio: "ignore" });
|
|
201
|
+
await writeFile(env.marker, JSON.stringify({ pid: sleeper.pid, at: Date.now() - 30 * 60 * 1000 }));
|
|
202
|
+
await writeFile(env.log, "[update] 开始在线更新(卡住的一轮)\n");
|
|
203
|
+
const old = new Date(Date.now() - 30 * 60 * 1000);
|
|
204
|
+
await utimes(env.log, old, old);
|
|
205
|
+
const routes = boot(env.relayDir);
|
|
206
|
+
const { host, base } = await serve(routes);
|
|
207
|
+
// ⚠️ 标记必须在 boot **之后**重新写一遍:boot 时的 sweepStaleMarkers 会先把残留清掉,
|
|
208
|
+
// 那验证的就不是"轮询驱动"了(那是上一个用例的职责)。
|
|
209
|
+
await writeFile(env.marker, JSON.stringify({ pid: sleeper.pid, at: Date.now() - 30 * 60 * 1000 }));
|
|
210
|
+
await utimes(env.log, old, old);
|
|
211
|
+
try {
|
|
212
|
+
assert.equal(existsSync(env.marker), true, "前置:标记应存在");
|
|
213
|
+
await get(base, "/dsh-remote/bridge-status"); // 面板每 2~3s 就在打这个接口
|
|
214
|
+
assert.equal(existsSync(env.marker), false, "★轮询必须顺手清掉卡死的更新(补装能自愈靠的就是「每次轮询再试一次」)");
|
|
215
|
+
const st = await get(base, "/dsh-remote/self/update-log");
|
|
216
|
+
assert.equal(st.failure && st.failure.failCode, "update_stalled");
|
|
217
|
+
let alive = true;
|
|
218
|
+
try { process.kill(sleeper.pid, 0); } catch { alive = false; }
|
|
219
|
+
assert.equal(alive, false, "卡住的进程要被结束");
|
|
220
|
+
} finally {
|
|
221
|
+
try { sleeper.kill("SIGKILL"); } catch { /* 已退出 */ }
|
|
222
|
+
host.close();
|
|
223
|
+
await env.restore();
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// ─────────────── ④ 取消接口 ───────────────
|
|
228
|
+
|
|
229
|
+
test("取消更新:结束子进程 + 清标记 + 不把「用户主动取消」算成失败遥测", async () => {
|
|
230
|
+
const env = await makeEnv();
|
|
231
|
+
const routes = boot(env.relayDir);
|
|
232
|
+
const { host, base } = await serve(routes);
|
|
233
|
+
try {
|
|
234
|
+
const r = await post(base, "/dsh-remote/self/update");
|
|
235
|
+
assert.equal(r.ok, true);
|
|
236
|
+
const c = await post(base, "/dsh-remote/self/update/cancel");
|
|
237
|
+
assert.equal(c.ok, true);
|
|
238
|
+
assert.equal(c.cleared, true);
|
|
239
|
+
assert.equal(existsSync(env.marker), false);
|
|
240
|
+
const st = await get(base, "/dsh-remote/self/update-log");
|
|
241
|
+
assert.equal(st.running, false);
|
|
242
|
+
assert.equal(st.failure && st.failure.failCode, "user_cancelled", "取消要留本地记录(面板据此说「已取消」而不是「已完成」)");
|
|
243
|
+
const log = readFileSync(env.log, "utf8");
|
|
244
|
+
assert.match(log, /用户取消/);
|
|
245
|
+
} finally {
|
|
246
|
+
host.close();
|
|
247
|
+
await env.restore();
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
// ─────────────── ⑤ 通道自洽(F4) ───────────────
|
|
252
|
+
|
|
253
|
+
test("通道自洽:面板能看到当前通道;预发版装机不再被默认拉到稳定版", () => {
|
|
254
|
+
const src = INDEX_SRC.slice(INDEX_SRC.indexOf("function updateChannel()"), INDEX_SRC.indexOf("const UPDATE_TAG = updateChannel();"));
|
|
255
|
+
const mk = (envTag, version) => new Function("process", "PLUGIN_VERSION",
|
|
256
|
+
`${src}; return updateChannel();`)({ env: envTag ? { DSH_UPDATE_TAG: envTag } : {} }, version);
|
|
257
|
+
assert.equal(mk("", "0.6.8"), "latest", "正式版装机 → latest");
|
|
258
|
+
assert.equal(mk("", "0.6.9-beta.1"), "beta", "★预发版装机必须沿用 beta(否则面板点更新 = 降级)");
|
|
259
|
+
assert.equal(mk("alpha", "0.6.8"), "alpha", "显式指定优先(作者主动切通道)");
|
|
260
|
+
assert.equal(mk("@beta", "0.6.8"), "beta", "允许带 @ 前缀");
|
|
261
|
+
// 接口要把它下发出去
|
|
262
|
+
assert.match(INDEX_SRC, /channel: UPDATE_TAG/, "/self 与 /self/update-log 都要下发 channel");
|
|
263
|
+
const logStart = INDEX_SRC.indexOf('path: "/dsh-remote/self/update-log"');
|
|
264
|
+
const logRoute = INDEX_SRC.slice(logStart, logStart + 1600); // 取该路由自身的一段,不依赖相邻路由顺序
|
|
265
|
+
for (const f of ["channel", "elapsedMs", "idleMs", "idleThresholdMs", "stalled"]) {
|
|
266
|
+
assert.ok(new RegExp(`${f}[:,]`).test(logRoute), `update-log 必须下发 ${f}`);
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
// ─────────────── ⑥ 旧格式标记(潜伏缺陷) ───────────────
|
|
271
|
+
|
|
272
|
+
test("旧格式标记(纯时间戳数字)不再抛错、且能被超时清理", async () => {
|
|
273
|
+
const env = await makeEnv();
|
|
274
|
+
try {
|
|
275
|
+
// 旧格式:文件内容就是一个数字时间戳(历史版本写的形态)
|
|
276
|
+
await writeFile(env.marker, String(Date.now() - 40 * 60 * 1000));
|
|
277
|
+
boot(env.relayDir); // apply() → sweepStaleMarkers
|
|
278
|
+
assert.equal(existsSync(env.marker), false,
|
|
279
|
+
"★旧格式标记必须能被清理(readMarkerInfo 曾因 raw 作用域写错而对旧格式抛 ReferenceError,导致它们永远清不掉)");
|
|
280
|
+
// 未超时的旧格式标记则不该被清
|
|
281
|
+
await writeFile(env.marker, String(Date.now()));
|
|
282
|
+
boot(env.relayDir);
|
|
283
|
+
assert.equal(existsSync(env.marker), true, "没过期的标记不动它");
|
|
284
|
+
} finally {
|
|
285
|
+
await env.restore();
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// ─────────────── ⑦ 补装路径同样有看门狗 ───────────────
|
|
290
|
+
|
|
291
|
+
test("补装(运行环境安装)同样受看门狗保护:卡住即结束并按 runtime_install_timeout 归因", async () => {
|
|
292
|
+
const env = await makeEnv();
|
|
293
|
+
process.env.DSH_INSTALL_IDLE_MS = "400";
|
|
294
|
+
const routes = boot(env.relayDir);
|
|
295
|
+
const { host, base } = await serve(routes);
|
|
296
|
+
try {
|
|
297
|
+
// 触发补装:环境缺失 + 已登录账号
|
|
298
|
+
await writeFile(path.join(env.relayDir, ".dsh-config.json"), JSON.stringify({
|
|
299
|
+
phone: "13800000000", password: "pw", api_url: "http://127.0.0.1:1",
|
|
300
|
+
}));
|
|
301
|
+
await post(base, "/dsh-remote/start"); // 缺运行环境 → ensureRuntime 后台补装
|
|
302
|
+
const installMarker = path.join(env.relayDir, ".dsh-setup-installing");
|
|
303
|
+
const started = await waitFor(() => existsSync(installMarker), { timeout: 5000 });
|
|
304
|
+
assert.equal(started, true, "应已开始后台补装");
|
|
305
|
+
|
|
306
|
+
// 卡住 400ms 后:标记必须被清掉(否则会一直显示"正在安装")
|
|
307
|
+
const cleared = await waitFor(() => !existsSync(installMarker), { timeout: 12000 });
|
|
308
|
+
assert.equal(cleared, true, "★补装卡住后标记必须清理,否则「正在安装…」永远不动(装机流失的直接原因)");
|
|
309
|
+
const log = readFileSync(path.join(env.relayDir, ".dsh-setup-install.log"), "utf8");
|
|
310
|
+
assert.match(log, /无输出/, "补装日志要写明卡住判据:" + log.slice(-500));
|
|
311
|
+
} finally {
|
|
312
|
+
host.close();
|
|
313
|
+
await env.restore();
|
|
314
|
+
}
|
|
315
|
+
});
|