@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.
@@ -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
- const raw = readFileSync(filePath, "utf8").trim();
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 of [PROVISION_MARKER, UPDATE_MARKER]) {
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 clear = () => { try { rmSync(marker, { force: true }); } catch { /* ignore */ } };
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,或退出码 0 但安装脚本没落盘(装到一半/装错包)→ 都算失败并进入退避
910
- const code2 = code === 0 ? "install_script_missing" : telemetryFailCodeFromText(readTail(log));
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.8";
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
- const UPDATE_TAG = (process.env.DSH_UPDATE_TAG || "latest").replace(/^@/, "");
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
- /** 查询所选通道(npm dist-tag)最新版(官方源优先,失败回退 npmmirror;纯服务端无 CORS 限制)。 */
2394
- async function npmLatestVersion() {
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 (typeof tags[UPDATE_TAG] === "string") return tags[UPDATE_TAG];
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", UPDATE_SPEC], {
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
- appendLogLine(relayDir, UPDATE_LOG, `[update] 开始在线更新 ${UPDATE_SPEC} (${new Date().toISOString()})`);
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
- const clear = () => { try { rmSync(marker, { force: true }); } catch { /* ignore */ } };
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
- // 第一次:官方 npm 源;失败(exit≠0/网络)才回退用户默认源(通常为国内镜像)
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
- if (!retried && code !== 0) {
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, `[update] 官方源安装失败(exit=${code}),回退默认源(npmmirror 等)重试…`);
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
- const fc = telemetryFailCodeFromText(tail);
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, lastLine || `安装进程退出码 ${code}`, fc);
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
- sendJson(res, 200, { ok: true, version: PLUGIN_VERSION, runtimeReady, relayDir });
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: existsSync(join(relayDir, UPDATE_MARKER)),
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.8",
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",