@mrrisega/dsh-remote 0.6.8 → 0.6.9-beta.2
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/clients/dsh-remote/mobile-adapter.mjs +274 -21
- package/clients/dsh-remote/test/mobile-adapter-guards.test.mjs +682 -0
- 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 +1496 -232
- 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
|
@@ -276,8 +276,12 @@ function spawnAndConfirm(command, args, opts = {}) {
|
|
|
276
276
|
// 兼容旧格式(纯时间戳数字 → 只按超时清理)。
|
|
277
277
|
|
|
278
278
|
function readMarkerInfo(filePath) {
|
|
279
|
+
// ⚠️ `raw` 必须声明在 try **之外**:它下面还要用。此前写成 `const raw = …` 在 try 块内,
|
|
280
|
+
// 于是"旧格式(纯时间戳数字)标记"会在这里抛 ReferenceError ——
|
|
281
|
+
// 所有调用点都被 try/catch 吞掉,表现是**旧格式标记永远不会被清理**(潜伏缺陷,0.6.9 修)。
|
|
282
|
+
let raw = "";
|
|
279
283
|
try {
|
|
280
|
-
|
|
284
|
+
raw = readFileSync(filePath, "utf8").trim();
|
|
281
285
|
const j = JSON.parse(raw);
|
|
282
286
|
if (Number.isInteger(j?.pid) || Number.isInteger(j?.at)) return j;
|
|
283
287
|
} catch { /* 非 JSON → 数字时间戳或空 */ }
|
|
@@ -308,13 +312,27 @@ function pidAlive(pid) {
|
|
|
308
312
|
*/
|
|
309
313
|
function sweepStaleMarkers(relayDir) {
|
|
310
314
|
const now = Date.now();
|
|
311
|
-
for (const name
|
|
315
|
+
for (const [name, logName, kind] of [
|
|
316
|
+
[PROVISION_MARKER, AUTO_INSTALL_LOG, "install"],
|
|
317
|
+
[UPDATE_MARKER, UPDATE_LOG, "update"],
|
|
318
|
+
]) {
|
|
312
319
|
const p = join(relayDir, name);
|
|
313
320
|
let info;
|
|
314
321
|
try { info = readMarkerInfo(p); } catch { continue; }
|
|
315
322
|
if (!info) continue;
|
|
316
323
|
const dead = pidAlive(info.pid);
|
|
317
324
|
const expired = now - info.at > STALE_MARKER_MS;
|
|
325
|
+
// 【0.6.9 新增】进程**活着但长时间没有推进** → 先结束它再清标记。
|
|
326
|
+
// 诊断报告 P2:旧实现只看「进程还在不在」,挂死的进程因此永远不被结束,用户每次重试都堆一个。
|
|
327
|
+
if (dead !== false) {
|
|
328
|
+
const idle = progressIdleMs(relayDir, logName, info.at);
|
|
329
|
+
if (idle > stallIdleMs(kind)
|
|
330
|
+
&& killStalledChild(relayDir, logName, info, kind, "宿主启动清理")) {
|
|
331
|
+
stopProgressWatch(kind === "update" ? UPDATE_WATCH_KEY : `install:${relayDir}`);
|
|
332
|
+
try { rmSync(p, { force: true }); } catch { /* ignore */ }
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
318
336
|
if (dead === false || (dead === null && expired)) {
|
|
319
337
|
try { rmSync(p, { force: true }); } catch { /* ignore */ }
|
|
320
338
|
appendLogLine(relayDir, AUTO_INSTALL_LOG,
|
|
@@ -323,6 +341,31 @@ function sweepStaleMarkers(relayDir) {
|
|
|
323
341
|
}
|
|
324
342
|
}
|
|
325
343
|
|
|
344
|
+
/**
|
|
345
|
+
* 复用面板已有的 2~3s 轮询,顺手驱动「卡住的更新」自愈(诊断报告 F5)。
|
|
346
|
+
*
|
|
347
|
+
* 补装之所以能自愈,靠的是「每次轮询都再试一次」(ensureConnection → ensureRuntime);
|
|
348
|
+
* 而更新路径原先只有一次性 fire-and-forget(全文只有一个调用点),**一旦第一次卡住就再没有任何后续**。
|
|
349
|
+
* 这里把同一套引擎平移到更新上:轮询时若发现「标记在、但日志很久没变」,
|
|
350
|
+
* 就结束进程 + 清标记 + 如实记一条失败(面板据此给「重试」,而不是永久转圈)。
|
|
351
|
+
*/
|
|
352
|
+
function healStalledJobs(relayDir) {
|
|
353
|
+
try {
|
|
354
|
+
const marker = join(relayDir, UPDATE_MARKER);
|
|
355
|
+
if (!existsSync(marker)) return false;
|
|
356
|
+
const info = readMarkerInfo(marker);
|
|
357
|
+
const idle = progressIdleMs(relayDir, UPDATE_LOG, info && info.at);
|
|
358
|
+
if (idle <= stallIdleMs("update")) return false; // 还在推进:不打扰
|
|
359
|
+
const killed = killStalledChild(relayDir, UPDATE_LOG, info, "update", "面板轮询发现长时间无输出");
|
|
360
|
+
stopProgressWatch(UPDATE_WATCH_KEY);
|
|
361
|
+
try { rmSync(marker, { force: true }); } catch { /* ignore */ }
|
|
362
|
+
noteUpdateFailure(relayDir, `更新进程长时间没有响应(已 ${Math.round(idle / 1000)} 秒无输出),已结束并清理,可以重试`, "update_stalled");
|
|
363
|
+
telemetryRecord(relayDir, "update_failed", { fail_code: "update_stalled" });
|
|
364
|
+
appendLogLine(relayDir, UPDATE_LOG, `[update] 看门狗:已 ${Math.round(idle / 1000)}s 无输出 → 结束进程(${killed ? "已结束" : "进程已不在"})并清标记`);
|
|
365
|
+
return true;
|
|
366
|
+
} catch { return false; }
|
|
367
|
+
}
|
|
368
|
+
|
|
326
369
|
/** 读取 JSON body。 */
|
|
327
370
|
async function readJsonBody(req) {
|
|
328
371
|
let raw = "";
|
|
@@ -861,7 +904,11 @@ function ensureRuntime(relayDir) {
|
|
|
861
904
|
try {
|
|
862
905
|
mkdirSync(relayDir, { recursive: true });
|
|
863
906
|
const log = join(relayDir, AUTO_INSTALL_LOG);
|
|
864
|
-
const
|
|
907
|
+
const watchKey = `install:${relayDir}`;
|
|
908
|
+
const clear = () => {
|
|
909
|
+
stopProgressWatch(watchKey);
|
|
910
|
+
try { rmSync(marker, { force: true }); } catch { /* ignore */ }
|
|
911
|
+
};
|
|
865
912
|
// 补装失败(spawn 都起不来)时统一收尾:清安装标记 + 记日志 + 进退避 + 匿名遥测。
|
|
866
913
|
// 没有这条兜底,spawn 的异步 'error' 会变成未捕获异常打挂宿主(旧实现在 Windows 上正是如此)。
|
|
867
914
|
const failSpawn = (e, kind) => {
|
|
@@ -870,6 +917,7 @@ function ensureRuntime(relayDir) {
|
|
|
870
917
|
noteProvisionFailure(relayDir);
|
|
871
918
|
telemetryRecord(relayDir, "install_failed", { fail_code: telemetryFailCodeFromError(e) });
|
|
872
919
|
};
|
|
920
|
+
let stalledInstall = false; // 本轮是否因"长时间无输出"被判卡住(归因用)
|
|
873
921
|
const npx = npxInvocation();
|
|
874
922
|
const { child, error } = safeSpawn(npx.command, [...npx.args, "--yes", UPDATE_SPEC], {
|
|
875
923
|
detached: true,
|
|
@@ -888,6 +936,19 @@ function ensureRuntime(relayDir) {
|
|
|
888
936
|
return false;
|
|
889
937
|
}
|
|
890
938
|
writeMarker(marker, child.pid); // 记 pid:宿主重启后可立即清理死进程残留
|
|
939
|
+
// 【0.6.9 关键修复】补装同样要有无输出看门狗。
|
|
940
|
+
// 这一条直接对应"面板注册完成、设备却没有上报"的装机流失:npx 拉取在"连得上但传不动"的
|
|
941
|
+
// 链路上会**挂起**(进程活着、永远不产出 exit),旧实现只在"pid 死 或 标记超 10 分钟"时才清理,
|
|
942
|
+
// 于是卡住的安装能吃满 10 分钟退避窗口、反复失败;用户看到的是"正在安装…"永远不动。
|
|
943
|
+
startProgressWatch(watchKey, {
|
|
944
|
+
relayDir, logName: AUTO_INSTALL_LOG, kind: "install",
|
|
945
|
+
getChild: () => child,
|
|
946
|
+
onStall: (idle) => {
|
|
947
|
+
stalledInstall = true;
|
|
948
|
+
appendLogLine(relayDir, AUTO_INSTALL_LOG,
|
|
949
|
+
`[auto-install] 已 ${Math.round(idle / 1000)}s 无输出 → 判定卡住,结束该进程并按失败处理(下一轮会重试)`);
|
|
950
|
+
},
|
|
951
|
+
});
|
|
891
952
|
// 记下「本机运行环境是插件自愈补的」:登录后的 /api/install-report 与 bridge 设备登记
|
|
892
953
|
// 都据此上报 install_source=plugin_market(区分用户自己跑 npx 安装器的那条路径)。
|
|
893
954
|
try {
|
|
@@ -906,8 +967,9 @@ function ensureRuntime(relayDir) {
|
|
|
906
967
|
noteProvisionSuccess(relayDir);
|
|
907
968
|
telemetryRuntimeReady(relayDir); // 退出码 0 ≠ 装好了
|
|
908
969
|
} else {
|
|
909
|
-
// 退出码非 0
|
|
910
|
-
const code2 =
|
|
970
|
+
// 退出码非 0、卡住超时、或退出码 0 但安装脚本没落盘(装到一半/装错包)→ 都算失败并进入退避
|
|
971
|
+
const code2 = stalledInstall ? "runtime_install_timeout"
|
|
972
|
+
: (code === 0 ? "install_script_missing" : telemetryFailCodeFromText(readTail(log)));
|
|
911
973
|
const back = noteProvisionFailure(relayDir);
|
|
912
974
|
appendLogLine(relayDir, AUTO_INSTALL_LOG,
|
|
913
975
|
`[auto-install] 第 ${back.fails} 次失败(归因 ${code2}),${Math.round(back.waitMs / 1000)}s 后才会重试`);
|
|
@@ -2379,7 +2441,7 @@ const PLUGIN_ID = "dsh-remote-web";
|
|
|
2379
2441
|
const PLUGIN_LEGACY_IDS = ["dsh-remote-ui"];
|
|
2380
2442
|
const PLUGIN_ALL_IDS = [PLUGIN_ID, ...PLUGIN_LEGACY_IDS];
|
|
2381
2443
|
/** 插件自身发布版本(与 dsh-remote 根包同步递增)。 */
|
|
2382
|
-
const PLUGIN_VERSION = "0.6.
|
|
2444
|
+
const PLUGIN_VERSION = "0.6.9-beta.2";
|
|
2383
2445
|
const UPDATE_LOG = ".dsh-update.log";
|
|
2384
2446
|
const UPDATE_MARKER = ".dsh-update-running";
|
|
2385
2447
|
|
|
@@ -2387,21 +2449,183 @@ const UPDATE_MARKER = ".dsh-update-running";
|
|
|
2387
2449
|
* 更新通道(发布策略):普通用户只拉稳定 dist-tag `latest`;预发(alpha/beta)由作者/内测
|
|
2388
2450
|
* 通过 `DSH_UPDATE_TAG=beta`(或显式版本号)拉取。迭代一律先发 beta/alpha,稳定后才升 latest。
|
|
2389
2451
|
*/
|
|
2390
|
-
|
|
2452
|
+
/**
|
|
2453
|
+
* 「无输出看门狗」——更新与补装共用(0.6.9)。
|
|
2454
|
+
*
|
|
2455
|
+
* 0.6.8 诊断报告的核心结论:旧的三套卡死治理(sweepStaleMarkers / runOnlineUpdate 的陈旧判定 /
|
|
2456
|
+
* 镜像回退)**判据全选错了** —— 它们问的是"标记还在不在 / 进程还活不活",没有一套在问
|
|
2457
|
+
* "它还在不在往前推进"。而中国网络访问 registry.npmjs.org 的**典型**失败形态恰恰是**挂起**
|
|
2458
|
+
* (TCP 连得上、传输不动):进程活着、标记是新的、却永远不产出任何事件(没有 exit、没有新日志)。
|
|
2459
|
+
* 于是镜像回退只在"快速失败"这一种形态下生效,最常见的那种零覆盖 ——
|
|
2460
|
+
* 用户看到的是永久「更新中 / 正在安装」,而装机数就是不涨。
|
|
2461
|
+
*
|
|
2462
|
+
* 判据换成**日志文件多久没有新增字节**:进度信号是现成的(两条路径本来就把子进程输出写进日志),
|
|
2463
|
+
* 而且用 mtime 判定在**宿主重启后依然有效**(内存里的看门狗做不到这点)。
|
|
2464
|
+
* 超时 → 判定卡住 → 结束该子进程 → 走与 exit≠0 **完全相同**的回退路径。
|
|
2465
|
+
* 阈值可用 DSH_UPDATE_IDLE_MS / DSH_INSTALL_IDLE_MS 覆盖(仅供测试把链路压到亚秒级)。
|
|
2466
|
+
*/
|
|
2467
|
+
function stallPollMs() {
|
|
2468
|
+
const n = Number(process.env.DSH_STALL_POLL_MS);
|
|
2469
|
+
return Number.isFinite(n) && n > 0 ? n : 2000;
|
|
2470
|
+
}
|
|
2471
|
+
/** 无输出多久判定卡住;默认 120s(给慢链路留余量,又不让用户干等到天荒地老)。 */
|
|
2472
|
+
function stallIdleMs(kind) {
|
|
2473
|
+
const env = kind === "update" ? process.env.DSH_UPDATE_IDLE_MS : process.env.DSH_INSTALL_IDLE_MS;
|
|
2474
|
+
const n = Number(env);
|
|
2475
|
+
return Number.isFinite(n) && n > 0 ? n : 120_000;
|
|
2476
|
+
}
|
|
2477
|
+
|
|
2478
|
+
/** 日志文件自 `since` 以来多久没变化(毫秒)。取不到文件时退化为"距 since 的时长"。 */
|
|
2479
|
+
function progressIdleMs(relayDir, logName, since) {
|
|
2480
|
+
try {
|
|
2481
|
+
return Date.now() - statSync(join(relayDir, logName)).mtimeMs;
|
|
2482
|
+
} catch {
|
|
2483
|
+
return Date.now() - (Number(since) || Date.now());
|
|
2484
|
+
}
|
|
2485
|
+
}
|
|
2486
|
+
|
|
2487
|
+
/** 进度看板:key → { startedAt, lastProgressAt, lastSize, stalled, kind, timer }(供面板读"已用时/是否卡住")。 */
|
|
2488
|
+
const progressWatch = new Map();
|
|
2489
|
+
|
|
2490
|
+
/**
|
|
2491
|
+
* 给一个后台子进程装上无输出看门狗。
|
|
2492
|
+
* @param {string} key 唯一键('update' / 'install:<relayDir>')
|
|
2493
|
+
* @param {{relayDir:string, logName:string, getChild:()=>any, onStall?:(idleMs:number)=>void, kind:"update"|"install"}} opts
|
|
2494
|
+
*/
|
|
2495
|
+
function startProgressWatch(key, opts) {
|
|
2496
|
+
stopProgressWatch(key);
|
|
2497
|
+
const { relayDir, logName, getChild, onStall, kind } = opts;
|
|
2498
|
+
const logFile = join(relayDir, logName);
|
|
2499
|
+
const st = { startedAt: Date.now(), lastProgressAt: Date.now(), lastSize: -1, stalled: false, kind, timer: null };
|
|
2500
|
+
progressWatch.set(key, st);
|
|
2501
|
+
const timer = setInterval(() => {
|
|
2502
|
+
let size = -1;
|
|
2503
|
+
try { size = statSync(logFile).size; } catch { size = -1; } // 文件还没有 = 也没有任何输出
|
|
2504
|
+
if (size !== st.lastSize) { st.lastSize = size; st.lastProgressAt = Date.now(); return; } // 有新增输出 = 在推进
|
|
2505
|
+
const idle = Date.now() - st.lastProgressAt;
|
|
2506
|
+
if (idle < stallIdleMs(kind)) return;
|
|
2507
|
+
st.stalled = true;
|
|
2508
|
+
clearInterval(timer);
|
|
2509
|
+
timer.unref?.();
|
|
2510
|
+
// ① 先结束子进程:它会触发 'exit',从而走与"退出码非 0"完全相同的回退/报错路径
|
|
2511
|
+
try { getChild()?.kill(); } catch { /* 已退出 */ }
|
|
2512
|
+
// ② 再通知调用方记日志(顺序固定:kill 是必须发生的动作,日志是告知)
|
|
2513
|
+
try { onStall?.(idle); } catch { /* 回调出错不得影响关闭 */ }
|
|
2514
|
+
}, stallPollMs());
|
|
2515
|
+
timer.unref?.();
|
|
2516
|
+
st.timer = timer;
|
|
2517
|
+
return st;
|
|
2518
|
+
}
|
|
2519
|
+
|
|
2520
|
+
function stopProgressWatch(key) {
|
|
2521
|
+
const st = progressWatch.get(key);
|
|
2522
|
+
if (st?.timer) clearInterval(st.timer);
|
|
2523
|
+
progressWatch.delete(key);
|
|
2524
|
+
}
|
|
2525
|
+
|
|
2526
|
+
/** 供面板/诊断读取的进度信息(没有进行中的任务时返回 null)。 */
|
|
2527
|
+
function progressInfoOf(key) {
|
|
2528
|
+
const st = progressWatch.get(key);
|
|
2529
|
+
if (!st) return null;
|
|
2530
|
+
const now = Date.now();
|
|
2531
|
+
return {
|
|
2532
|
+
elapsedMs: now - st.startedAt,
|
|
2533
|
+
idleMs: now - st.lastProgressAt,
|
|
2534
|
+
stalled: Boolean(st.stalled),
|
|
2535
|
+
idleThresholdMs: stallIdleMs(st.kind),
|
|
2536
|
+
};
|
|
2537
|
+
}
|
|
2538
|
+
|
|
2539
|
+
/**
|
|
2540
|
+
* 结束"标记里记着、但已经不推进"的子进程。
|
|
2541
|
+
* 诊断报告 P2:挂死的子进程**从不被结束**(`child.kill()` 在插件半全文无匹配,标记里的 pid
|
|
2542
|
+
* 只被用来"判活"、从未被用来"结束")→ 用户重试几次就在机器上堆几个挂死的 node 进程,无人回收。
|
|
2543
|
+
* 判据同样不能用 pidAlive(挂起的进程是"活"的),要结合"日志多久没变化"。
|
|
2544
|
+
*/
|
|
2545
|
+
function killStalledChild(relayDir, logName, info, kind, why) {
|
|
2546
|
+
const pid = Number(info && info.pid);
|
|
2547
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
2548
|
+
if (pidAlive(pid) !== true) return false; // 已经死了:交给标记清理逻辑
|
|
2549
|
+
const idle = progressIdleMs(relayDir, logName, info && info.at);
|
|
2550
|
+
if (idle < stallIdleMs(kind)) return false; // 还在推进:不碰它
|
|
2551
|
+
const ok = killPid(pid, "SIGKILL"); // Windows 上走 taskkill /T /F
|
|
2552
|
+
appendLogLine(relayDir, AUTO_INSTALL_LOG,
|
|
2553
|
+
`[dsh-remote-web] 结束卡住的${kind === "update" ? "更新" : "运行环境安装"}进程 pid=${pid}(${why};已 ${Math.round(idle / 1000)}s 无输出)`);
|
|
2554
|
+
return ok;
|
|
2555
|
+
}
|
|
2556
|
+
|
|
2557
|
+
/**
|
|
2558
|
+
* 更新通道(0.6.9 修:诊断报告 P3「通道默认值不自洽」)。
|
|
2559
|
+
* 原实现无条件拉 `latest`:**预发版装机的用户在面板点「一键更新」= 降级或什么都不做**,
|
|
2560
|
+
* 而面板既不显示当前通道、也不沿用当前通道 —— 作者/内测用户点出一个方向相反的操作且无从判断。
|
|
2561
|
+
* 现在:显式设置优先(DSH_UPDATE_TAG,用于主动切通道),否则**沿用装机通道**
|
|
2562
|
+
* (当前是预发版 → beta;正式版 → latest)。
|
|
2563
|
+
*/
|
|
2564
|
+
function updateChannel() {
|
|
2565
|
+
const explicit = String(process.env.DSH_UPDATE_TAG || "").trim().replace(/^@/, "");
|
|
2566
|
+
if (explicit) return explicit;
|
|
2567
|
+
return /-/.test(String(PLUGIN_VERSION || "")) ? "beta" : "latest";
|
|
2568
|
+
}
|
|
2569
|
+
const UPDATE_TAG = updateChannel();
|
|
2391
2570
|
const UPDATE_SPEC = `@mrrisega/dsh-remote@${UPDATE_TAG}`;
|
|
2571
|
+
/** 看门狗在 progressWatch 里的键(更新只有一个,用常量;补装按 relayDir 区分)。 */
|
|
2572
|
+
const UPDATE_WATCH_KEY = "update";
|
|
2573
|
+
/**
|
|
2574
|
+
* 被用户取消的更新(按 relayDir)。
|
|
2575
|
+
* 为什么需要:取消会 kill 子进程 → 触发 'exit' → 那里的"换源重试"逻辑会**立刻重新 spawn**,
|
|
2576
|
+
* 把标记写回来,用户看到的是"点了取消还在更新"。这个集合就是给重试链装的总闸。
|
|
2577
|
+
*/
|
|
2578
|
+
const updateCancelledDirs = new Set();
|
|
2392
2579
|
|
|
2393
|
-
/**
|
|
2394
|
-
async function
|
|
2580
|
+
/** 抓一次 npm dist-tags(官方源优先,失败回退 npmmirror;纯服务端无 CORS 限制)。 */
|
|
2581
|
+
async function fetchDistTags() {
|
|
2395
2582
|
for (const reg of ["https://registry.npmjs.org/@mrrisega/dsh-remote", "https://registry.npmmirror.com/@mrrisega/dsh-remote"]) {
|
|
2396
2583
|
try {
|
|
2397
2584
|
const res = await fetch(reg, { signal: AbortSignal.timeout(8000) });
|
|
2398
2585
|
if (!res.ok) continue;
|
|
2399
2586
|
const j = await res.json();
|
|
2400
2587
|
const tags = j && j["dist-tags"] ? j["dist-tags"] : {};
|
|
2401
|
-
if (
|
|
2588
|
+
if (Object.keys(tags).length) return tags;
|
|
2402
2589
|
} catch { /* 试下一个源 */ }
|
|
2403
2590
|
}
|
|
2404
|
-
return
|
|
2591
|
+
return {};
|
|
2592
|
+
}
|
|
2593
|
+
|
|
2594
|
+
/** 在「当前通道」与 latest 之间取更高者(**更新与"检查更新"必须用同一口径**,否则面板会说"已是最新"而更新却装新版)。 */
|
|
2595
|
+
function pickBestTag(tags) {
|
|
2596
|
+
const t = tags || {};
|
|
2597
|
+
const channelVer = typeof t[UPDATE_TAG] === "string" ? t[UPDATE_TAG] : "";
|
|
2598
|
+
const latestVer = typeof t.latest === "string" ? t.latest : "";
|
|
2599
|
+
if (!latestVer) return { tag: UPDATE_TAG, version: channelVer, why: "线上没有 latest 通道信息" };
|
|
2600
|
+
if (!channelVer) return { tag: "latest", version: latestVer, why: "当前通道无对应版本,改用 latest" };
|
|
2601
|
+
const cmp = compareVersions(latestVer, channelVer);
|
|
2602
|
+
if (cmp !== null && cmp > 0) {
|
|
2603
|
+
return { tag: "latest", version: latestVer, why: `latest(${latestVer}) 高于当前通道 ${UPDATE_TAG}(${channelVer})` };
|
|
2604
|
+
}
|
|
2605
|
+
return { tag: UPDATE_TAG, version: channelVer, why: `当前通道 ${UPDATE_TAG}(${channelVer}) 不低于 latest(${latestVer})` };
|
|
2606
|
+
}
|
|
2607
|
+
|
|
2608
|
+
/** 查询"对我而言"的最新版本号(口径同 pickBestTag)。 */
|
|
2609
|
+
async function npmLatestVersion() {
|
|
2610
|
+
return pickBestTag(await fetchDistTags()).version;
|
|
2611
|
+
}
|
|
2612
|
+
|
|
2613
|
+
/**
|
|
2614
|
+
* 决定本次更新**真正该装的 tag**:在「当前通道」与 `latest` 之间**取更高者**。
|
|
2615
|
+
*
|
|
2616
|
+
* 为什么不无条件跟随通道(诊断报告 P3 的另一半):开发期内 beta 领先(跟随 beta 是对的),
|
|
2617
|
+
* 但**正式版一旦发布,latest 就反超**;此时死跟 beta 会让内测用户卡在一个旧 beta 上,
|
|
2618
|
+
* 点「一键更新」什么都不发生 —— 那正是报告里说的"方向相反的操作"。
|
|
2619
|
+
* 取更高者同时满足两种情况:既不会把 beta 装机降级到稳定版,也不会让它错过已发布的稳定版。
|
|
2620
|
+
* 取不到 tag(断网/超时)时保守回退到当前通道,绝不因此阻断更新。
|
|
2621
|
+
* @returns {{tag:string, version:string, why:string}}
|
|
2622
|
+
*/
|
|
2623
|
+
async function resolveUpdateTarget() {
|
|
2624
|
+
try {
|
|
2625
|
+
return pickBestTag(await fetchDistTags());
|
|
2626
|
+
} catch {
|
|
2627
|
+
return { tag: UPDATE_TAG, version: "", why: "无法读取 npm 通道信息,按当前通道执行" };
|
|
2628
|
+
}
|
|
2405
2629
|
}
|
|
2406
2630
|
|
|
2407
2631
|
/**
|
|
@@ -2468,10 +2692,10 @@ function recentUpdateFailure(relayDir) {
|
|
|
2468
2692
|
/** 以 detached 子进程执行 `npx --yes <UPDATE_SPEC>`(env 可覆盖 npm 源/更新通道)。
|
|
2469
2693
|
* @returns {{child, error}} 由 safeSpawn 返回——永远挂了 'error' 监听,启动失败不会打挂宿主。
|
|
2470
2694
|
*/
|
|
2471
|
-
function spawnUpdater(relayDir, extraEnv, onError) {
|
|
2695
|
+
function spawnUpdater(relayDir, extraEnv, onError, spec = UPDATE_SPEC) {
|
|
2472
2696
|
const log = join(relayDir, UPDATE_LOG);
|
|
2473
2697
|
const npx = npxInvocation();
|
|
2474
|
-
return safeSpawn(npx.command, [...npx.args, "--yes",
|
|
2698
|
+
return safeSpawn(npx.command, [...npx.args, "--yes", spec], {
|
|
2475
2699
|
detached: true,
|
|
2476
2700
|
cwd: homedir(),
|
|
2477
2701
|
// Windows 上跑 npx.cmd 必须经 shell(Node ≥20.12 起否则 EINVAL);走 node+npx-cli.js 时不需要。
|
|
@@ -2494,7 +2718,7 @@ function spawnUpdater(relayDir, extraEnv, onError) {
|
|
|
2494
2718
|
* 失败(国内网络)才回退用户默认镜像源;
|
|
2495
2719
|
* - marker 记录 pid,子进程退出/出错即清理;宿主重启后由 sweepStaleMarkers 立即清掉死进程残留。
|
|
2496
2720
|
*/
|
|
2497
|
-
function runOnlineUpdate(relayDir) {
|
|
2721
|
+
async function runOnlineUpdate(relayDir) {
|
|
2498
2722
|
try {
|
|
2499
2723
|
mkdirSync(relayDir, { recursive: true });
|
|
2500
2724
|
const marker = join(relayDir, UPDATE_MARKER);
|
|
@@ -2510,11 +2734,21 @@ function runOnlineUpdate(relayDir) {
|
|
|
2510
2734
|
return { ok: false, detail: "已有更新在进行中,请稍候(若长时间无进展,最多 10 分钟后可重试)" };
|
|
2511
2735
|
}
|
|
2512
2736
|
}
|
|
2513
|
-
|
|
2737
|
+
updateCancelledDirs.delete(relayDir); // 新一轮开始:清掉上次的取消闸
|
|
2738
|
+
// 【0.6.9】目标 tag 在「当前通道」与 latest 之间取更高者(见 resolveUpdateTarget 注释):
|
|
2739
|
+
// 既不把内测装机降级到稳定版,也不让它错过已发布的正式版。
|
|
2740
|
+
const target = await resolveUpdateTarget();
|
|
2741
|
+
const spec = `@mrrisega/dsh-remote@${target.tag}`;
|
|
2742
|
+
appendLogLine(relayDir, UPDATE_LOG,
|
|
2743
|
+
`[update] 开始在线更新 ${spec}(通道 ${UPDATE_TAG};${target.why})(${new Date().toISOString()})`);
|
|
2514
2744
|
telemetryRecord(relayDir, "update_started"); // 匿名遥测:在线更新开始
|
|
2515
2745
|
|
|
2516
2746
|
let retried = false;
|
|
2517
|
-
|
|
2747
|
+
let stalled = false; // 本轮是否因"长时间无输出"被判定卡住(决定失败归因与是否回退)
|
|
2748
|
+
const clear = () => {
|
|
2749
|
+
stopProgressWatch(UPDATE_WATCH_KEY);
|
|
2750
|
+
try { rmSync(marker, { force: true }); } catch { /* ignore */ }
|
|
2751
|
+
};
|
|
2518
2752
|
// 启动失败(ENOENT/EINVAL…)的收尾:清 marker + 记日志 + 记失败原因(面板可查询)+ 匿名遥测。
|
|
2519
2753
|
// 抽出来给「同步失败」与「异步 'error' 事件」共用,保证任何一种失败都不会变成未捕获异常。
|
|
2520
2754
|
const failUpdaterSpawn = (e) => {
|
|
@@ -2525,18 +2759,43 @@ function runOnlineUpdate(relayDir) {
|
|
|
2525
2759
|
telemetryRecord(relayDir, "update_failed", { fail_code: fc });
|
|
2526
2760
|
};
|
|
2527
2761
|
const run = () => {
|
|
2528
|
-
|
|
2762
|
+
if (updateCancelledDirs.has(relayDir)) { updateCancelledDirs.delete(relayDir); return null; } // 已取消:不再重试
|
|
2763
|
+
// 第一次:官方 npm 源;失败(exit≠0 **或长时间无输出**)才回退用户默认源(通常为国内镜像)
|
|
2764
|
+
stalled = false;
|
|
2765
|
+
const holder = { child: null };
|
|
2529
2766
|
const { child, error } = spawnUpdater(
|
|
2530
|
-
relayDir, retried ? {} : { npm_config_registry: "https://registry.npmjs.org" }, failUpdaterSpawn);
|
|
2767
|
+
relayDir, retried ? {} : { npm_config_registry: "https://registry.npmjs.org" }, failUpdaterSpawn, spec);
|
|
2531
2768
|
if (error || !child) {
|
|
2532
2769
|
if (error) failUpdaterSpawn(error);
|
|
2533
2770
|
return null;
|
|
2534
2771
|
}
|
|
2772
|
+
holder.child = child;
|
|
2535
2773
|
writeMarker(marker, child.pid);
|
|
2774
|
+
// 【0.6.9 关键修复】无输出看门狗:中国网络访问 npm 官方源的典型失败形态是**挂起**
|
|
2775
|
+
// (进程活着、永远不产出 exit),旧实现只看"退出码非 0",于是回退永不触发、标记永不清除、
|
|
2776
|
+
// 用户永久停在「更新中」。这里以"日志多久没新增字节"为判据,卡住即结束进程 →
|
|
2777
|
+
// 自然而然地走下面 exit≠0 的同一条回退路径。
|
|
2778
|
+
startProgressWatch(UPDATE_WATCH_KEY, {
|
|
2779
|
+
relayDir, logName: UPDATE_LOG, kind: "update",
|
|
2780
|
+
getChild: () => holder.child,
|
|
2781
|
+
onStall: (idle) => {
|
|
2782
|
+
stalled = true;
|
|
2783
|
+
appendLogLine(relayDir, UPDATE_LOG,
|
|
2784
|
+
`[update] 已 ${Math.round(idle / 1000)}s 无输出 → 判定卡住,结束该进程${retried ? "(已重试过,不再回退)" : ",回退默认源(npmmirror 等)重试"}…`);
|
|
2785
|
+
},
|
|
2786
|
+
});
|
|
2536
2787
|
child.on("exit", (code) => {
|
|
2537
|
-
|
|
2788
|
+
stopProgressWatch(UPDATE_WATCH_KEY);
|
|
2789
|
+
// 用户取消导致的退出:既不重试、也不记失败(取消不是失败)
|
|
2790
|
+
if (updateCancelledDirs.has(relayDir)) {
|
|
2791
|
+
updateCancelledDirs.delete(relayDir);
|
|
2792
|
+
appendLogLine(relayDir, UPDATE_LOG, "[update] 已取消:不再换源重试");
|
|
2793
|
+
return;
|
|
2794
|
+
}
|
|
2795
|
+
if (!retried && (code !== 0 || stalled)) {
|
|
2538
2796
|
retried = true;
|
|
2539
|
-
appendLogLine(relayDir, UPDATE_LOG,
|
|
2797
|
+
appendLogLine(relayDir, UPDATE_LOG,
|
|
2798
|
+
`[update] ${stalled ? "官方源长时间无输出" : `官方源安装失败(exit=${code})`},回退默认源(npmmirror 等)重试…`);
|
|
2540
2799
|
run();
|
|
2541
2800
|
return;
|
|
2542
2801
|
}
|
|
@@ -2547,12 +2806,16 @@ function runOnlineUpdate(relayDir) {
|
|
|
2547
2806
|
// 插件走 patch 热加载、bridge 是独立进程,重启 harness 已无必要(用户实测反馈)。
|
|
2548
2807
|
// 成功不发事件(update_started 已发过,失败才发 update_failed;白名单里没有 update_done,
|
|
2549
2808
|
// 硬发只会被服务端静默丢弃)。
|
|
2550
|
-
if (code !== 0) {
|
|
2809
|
+
if (code !== 0 || stalled) {
|
|
2551
2810
|
const tail = readTail(join(relayDir, UPDATE_LOG));
|
|
2552
|
-
|
|
2811
|
+
// 卡住是一种**独立的失败形态**,必须有自己的归因码 —— 旧实现只在 exit≠0 时记失败,
|
|
2812
|
+
// 所以遥测里永远看不到"卡死"这一类,规模被持续低估(诊断报告 F2)。
|
|
2813
|
+
const fc = stalled ? "update_stalled" : telemetryFailCodeFromText(tail);
|
|
2553
2814
|
// 只把最后的可读片段给用户看(不含完整路径/用户名);分类码仍按白名单上报
|
|
2554
2815
|
const lastLine = String(tail || "").split("\n").map((l) => l.trim()).filter(Boolean).slice(-1)[0] || "";
|
|
2555
|
-
noteUpdateFailure(relayDir,
|
|
2816
|
+
noteUpdateFailure(relayDir, stalled
|
|
2817
|
+
? "更新进程长时间没有任何输出(已判定卡住并结束),通常是本机到 npm 官方源的链路不通"
|
|
2818
|
+
: (lastLine || `安装进程退出码 ${code}`), fc);
|
|
2556
2819
|
telemetryRecord(relayDir, "update_failed", { fail_code: fc });
|
|
2557
2820
|
} else {
|
|
2558
2821
|
updateFailures.delete(String(relayDir)); // 成功即清掉旧失败
|
|
@@ -2568,7 +2831,7 @@ function runOnlineUpdate(relayDir) {
|
|
|
2568
2831
|
const rec = recentUpdateFailure(relayDir);
|
|
2569
2832
|
return { ok: false, detail: (rec && rec.detail) || "无法启动更新进程(npx 不可用?)", failCode: rec ? rec.failCode : "unknown" };
|
|
2570
2833
|
}
|
|
2571
|
-
return { ok: true, pid: child.pid, log: join(relayDir, UPDATE_LOG) };
|
|
2834
|
+
return { ok: true, pid: child.pid, log: join(relayDir, UPDATE_LOG), channel: UPDATE_TAG, targetTag: target.tag, targetVersion: target.version };
|
|
2572
2835
|
} catch (e) {
|
|
2573
2836
|
try { rmSync(join(relayDir, UPDATE_MARKER), { force: true }); } catch { /* ignore */ }
|
|
2574
2837
|
telemetryRecord(relayDir, "update_failed", { fail_code: telemetryFailCodeFromError(e) });
|
|
@@ -3200,6 +3463,8 @@ function buildConnectDiagnostics(relayDir, conn, log) {
|
|
|
3200
3463
|
*/
|
|
3201
3464
|
function ensureConnection(relayDir, opts = {}) {
|
|
3202
3465
|
if (UNINSTALLED_DIRS.has(relayDir)) return null;
|
|
3466
|
+
// 顺手治一次「卡住的更新」(复用面板已有的轮询,不需要新定时器 —— 诊断报告 F5)
|
|
3467
|
+
healStalledJobs(relayDir);
|
|
3203
3468
|
const book = connectBook(relayDir);
|
|
3204
3469
|
if (opts.force) { book.attempts = 0; book.lastAttemptAt = 0; }
|
|
3205
3470
|
const now = Date.now();
|
|
@@ -3279,7 +3544,7 @@ const TELEMETRY_EVENT_NAMES = new Set([
|
|
|
3279
3544
|
const TELEMETRY_FAIL_CODES = new Set([
|
|
3280
3545
|
"node_missing", "node_too_old", "npm_unreachable", "npm_eacces", "platform_unsupported",
|
|
3281
3546
|
"runtime_install_timeout", "launchd_failed", "bridge_exit", "bind_conflict", "bind_device_limit",
|
|
3282
|
-
"npx_cmd_unavailable", "registry_timeout", "install_script_missing", "npx_exit_nonzero", "npx_output_encoding",
|
|
3547
|
+
"npx_cmd_unavailable", "registry_timeout", "install_script_missing", "npx_exit_nonzero", "npx_output_encoding", "update_stalled",
|
|
3283
3548
|
"unknown",
|
|
3284
3549
|
]);
|
|
3285
3550
|
/** node 半只发 source=plugin。 */
|
|
@@ -3858,7 +4123,29 @@ function registerRoutes(ctx, relayDir) {
|
|
|
3858
4123
|
path: "/dsh-remote/self",
|
|
3859
4124
|
handler: async (_req, res) => {
|
|
3860
4125
|
const runtimeReady = existsSync(join(relayDir, "dsh-setup.mjs"));
|
|
3861
|
-
|
|
4126
|
+
// channel:当前更新通道(面板要显示出来 —— 否则预发版用户会点出一个方向相反的操作)
|
|
4127
|
+
sendJson(res, 200, { ok: true, version: PLUGIN_VERSION, channel: UPDATE_TAG, runtimeReady, relayDir });
|
|
4128
|
+
},
|
|
4129
|
+
},
|
|
4130
|
+
{
|
|
4131
|
+
// 取消「正在进行的更新」:结束挂死的更新子进程 + 清标记。
|
|
4132
|
+
// 面板在「疑似卡住」时提供这个按钮 —— 此前用户面对的是没有时间、没有日志、没有按钮的 spinner。
|
|
4133
|
+
// 注意:取消**不写 update_failed 遥测**(用户主动取消不是失败),只在本地留一条可查询记录。
|
|
4134
|
+
method: "POST",
|
|
4135
|
+
path: "/dsh-remote/self/update/cancel",
|
|
4136
|
+
handler: async (_req, res) => {
|
|
4137
|
+
updateCancelledDirs.add(relayDir); // 先落闸:kill 会触发 exit,那里的重试逻辑必须看到"已取消"
|
|
4138
|
+
const marker = join(relayDir, UPDATE_MARKER);
|
|
4139
|
+
const info = readMarkerInfo(marker);
|
|
4140
|
+
const pid = Number(info && info.pid);
|
|
4141
|
+
let killed = false;
|
|
4142
|
+
if (Number.isInteger(pid) && pid > 0 && pidAlive(pid) === true) killed = killPid(pid, "SIGKILL");
|
|
4143
|
+
stopProgressWatch(UPDATE_WATCH_KEY);
|
|
4144
|
+
let cleared = false;
|
|
4145
|
+
try { rmSync(marker, { force: true }); cleared = !existsSync(marker); } catch { /* ignore */ }
|
|
4146
|
+
appendLogLine(relayDir, UPDATE_LOG, `[update] 用户取消:结束进程(${killed ? "已结束" : "无活动进程"}),清标记(${cleared ? "成功" : "失败"})`);
|
|
4147
|
+
if (cleared) noteUpdateFailure(relayDir, "更新已被取消,可以重新点「一键更新」再试一次", "user_cancelled");
|
|
4148
|
+
sendJson(res, 200, { ok: true, killed, cleared, detail: cleared ? "已取消" : "取消失败:标记未能清除" });
|
|
3862
4149
|
},
|
|
3863
4150
|
},
|
|
3864
4151
|
{
|
|
@@ -3879,7 +4166,7 @@ function registerRoutes(ctx, relayDir) {
|
|
|
3879
4166
|
method: "POST",
|
|
3880
4167
|
path: "/dsh-remote/self/update",
|
|
3881
4168
|
handler: async (_req, res) => {
|
|
3882
|
-
sendJson(res, 200, { ok: true, ...runOnlineUpdate(relayDir) });
|
|
4169
|
+
sendJson(res, 200, { ok: true, ...(await runOnlineUpdate(relayDir)) });
|
|
3883
4170
|
},
|
|
3884
4171
|
},
|
|
3885
4172
|
{
|
|
@@ -3887,13 +4174,26 @@ function registerRoutes(ctx, relayDir) {
|
|
|
3887
4174
|
path: "/dsh-remote/self/update-log",
|
|
3888
4175
|
handler: async (_req, res) => {
|
|
3889
4176
|
const failure = recentUpdateFailure(relayDir);
|
|
4177
|
+
const markerInfo = readMarkerInfo(join(relayDir, UPDATE_MARKER));
|
|
4178
|
+
const running = existsSync(join(relayDir, UPDATE_MARKER));
|
|
4179
|
+
const live = progressInfoOf(UPDATE_WATCH_KEY);
|
|
4180
|
+
const idleMs = running ? progressIdleMs(relayDir, UPDATE_LOG, markerInfo && markerInfo.at) : 0;
|
|
4181
|
+
const idleThresholdMs = stallIdleMs("update");
|
|
3890
4182
|
sendJson(res, 200, {
|
|
3891
4183
|
ok: true,
|
|
3892
|
-
running
|
|
4184
|
+
running,
|
|
4185
|
+
channel: UPDATE_TAG,
|
|
3893
4186
|
log: tailOf(join(relayDir, UPDATE_LOG)),
|
|
3894
4187
|
// 供前端在"没成功"时如实报错(此前失败只写日志,界面显示"已完成")
|
|
3895
4188
|
failure: failure ? { detail: failure.detail, failCode: failure.failCode, at: failure.at } : null,
|
|
3896
|
-
version: PLUGIN_VERSION
|
|
4189
|
+
version: PLUGIN_VERSION,
|
|
4190
|
+
// 【0.6.9】让「卡住」在界面上可见:已用时 / 距上次日志变化多久 / 是否超过阈值。
|
|
4191
|
+
// 用日志 mtime 判定 —— 宿主重启后依然有效(实测:重启也清不掉的永久「更新中」)。
|
|
4192
|
+
startedAt: (markerInfo && markerInfo.at) || (live && live.startedAt) || 0,
|
|
4193
|
+
elapsedMs: running && markerInfo ? Date.now() - markerInfo.at : (live ? live.elapsedMs : 0),
|
|
4194
|
+
idleMs,
|
|
4195
|
+
idleThresholdMs,
|
|
4196
|
+
stalled: Boolean(running && idleMs > idleThresholdMs) || Boolean(live && live.stalled)
|
|
3897
4197
|
});
|
|
3898
4198
|
},
|
|
3899
4199
|
},
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-web",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.9-beta.2",
|
|
4
4
|
"description": "公网远程控制 DeepSeek Harness(dsh web):安装即得专属加密地址,人在外面也能用手机访问电脑上的 dsh——无需同一局域网/WiFi、无需公网 IP 与内网穿透,全程加密;手机端 100% 还原电脑体验(对话/工具/审批/设置)。技术用户可选自建服务,流量走自己的服务器。Remote control DeepSeek Harness (dsh web) from anywhere over the public internet — install, get an encrypted URL, use it from your phone on any network (dual-half cordis plugin: Settings panel + same-origin /dsh-remote routes). (2026-09 由 dsh-remote-ui 更名 / renamed from dsh-remote-ui; dsh-remote 的 dsh web 插件半,不是纯 UI/皮肤插件)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|