@mrrisega/dsh-remote 0.7.3-beta.1 → 0.7.3-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/clients/dsh-remote/dsh-bridge.mjs +214 -9
- package/clients/dsh-remote/e2ee-shim-script.js +98 -18
- package/clients/dsh-remote/mirror-diag-script.js +19 -0
- package/clients/dsh-remote/mobile-adapter.mjs +552 -16
- package/clients/dsh-remote/test/bridge-guard.test.mjs +169 -0
- package/clients/dsh-remote/test/e2ee-shim-selfheal.test.mjs +264 -0
- package/clients/dsh-remote/test/mirror-diag.test.mjs +75 -0
- package/clients/dsh-remote/test/mobile-adapter-copy.test.mjs +56 -0
- package/clients/dsh-remote/test/mobile-adapter-guards.test.mjs +87 -8
- package/clients/dsh-remote/test/mobile-adapter-late-frame.test.mjs +9 -0
- package/clients/dsh-remote/test/mobile-adapter-load-watch.test.mjs +233 -28
- package/clients/dsh-remote/upstream-discovery.mjs +6 -1
- package/dsh-setup.mjs +225 -6
- package/package.json +1 -1
- package/packages/dsh-remote-web/lib/index.js +140 -3
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +214 -9
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-shim-script.js +98 -18
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/mirror-diag-script.js +19 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +552 -16
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/upstream-discovery.mjs +6 -1
- package/packages/dsh-remote-web/runtime/dsh-setup.mjs +225 -6
- package/packages/dsh-remote-web/test/bridge-hardening.test.mjs +299 -0
- package/packages/dsh-remote-web/test/windows-compat.test.mjs +82 -12
|
@@ -103,7 +103,12 @@ export function resolveUpstreamHint({ relayDir = "", env = process.env } = {}) {
|
|
|
103
103
|
|
|
104
104
|
function tryExec(cmd, args, timeoutMs = 4000) {
|
|
105
105
|
try {
|
|
106
|
-
|
|
106
|
+
// windowsHide(C1):这里会跑 netstat/lsof/ps 这类**控制台程序**。watcher/bridge 常以
|
|
107
|
+
// 无控制台方式启动(launchd / 任务计划 wscript / 插件的 detached spawn),此时 Windows 会
|
|
108
|
+
// 为子进程新分配一个可见控制台窗口 → 用户看到"一闪而过"的黑窗(诊断报告 04 §3.2 第二条)。
|
|
109
|
+
// 上游探测在上游请求失败/ws-open 失败时每 15s 冷却反复触发(见 refreshUpstream),所以闪窗成串。
|
|
110
|
+
// POSIX 上该选项被忽略。
|
|
111
|
+
return String(execFileSync(cmd, args, { encoding: "utf8", timeout: timeoutMs, windowsHide: true, stdio: ["ignore", "pipe", "ignore"] }) || "");
|
|
107
112
|
} catch {
|
|
108
113
|
return "";
|
|
109
114
|
}
|
|
@@ -63,6 +63,17 @@ const WIN_TASK_NAME = "dsh-remote-bridge";
|
|
|
63
63
|
*/
|
|
64
64
|
const WATCHER_PID_FILE = ".dsh-watcher.pid";
|
|
65
65
|
const BRIDGE_PID_FILE = ".dsh-bridge.pid";
|
|
66
|
+
/**
|
|
67
|
+
* C2 互踢根治:bridge 判定"本机已有同 deviceId 的另一个 bridge"时的退出码(必须与
|
|
68
|
+
* dsh-bridge.mjs 里的 DUPLICATE_INSTANCE_EXIT_CODE 一致),以及 watcher 收到它之后的暂停时长。
|
|
69
|
+
*
|
|
70
|
+
* 为什么 watcher 要暂停而不是照常 10s 重拉:互踢的本质是"两个实例抢同一个 deviceId",
|
|
71
|
+
* 重拉的唯一效果是**把对方也顶掉**(对方同样重连),形成 2~3.5s 周期的乒乓(生产日志实测
|
|
72
|
+
* dev-a50dced9cc19 327 次/10min)。本机已有实例在跑时,让位才是对的;5 分钟后若对方已经
|
|
73
|
+
* 退出(用户重装/升级收尾),本守护自然恢复。
|
|
74
|
+
*/
|
|
75
|
+
const DUPLICATE_INSTANCE_EXIT_CODE = 4;
|
|
76
|
+
const DUPLICATE_BRIDGE_BACKOFF_MS = 5 * 60 * 1000;
|
|
66
77
|
|
|
67
78
|
// ---------- 运行时自物化(npm/npx 安装 → 固化到配置目录,脱离 npx 缓存) ----------
|
|
68
79
|
// npx 每次安装的缓存目录(~/.npm/_npx/<hash>)不固定:缓存一旦清理,指向它的自启动服务
|
|
@@ -232,7 +243,8 @@ function runtimeSetupPath() {
|
|
|
232
243
|
|
|
233
244
|
function sh(cmd, timeoutMs = 15000, cwd = undefined) {
|
|
234
245
|
try {
|
|
235
|
-
|
|
246
|
+
// windowsHide:POSIX 上被忽略;Windows 上避免 execSync 经 cmd.exe 执行控制台程序时闪窗/弹窗。
|
|
247
|
+
const stdout = execSync(cmd, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: timeoutMs, windowsHide: true, ...(cwd ? { cwd } : {}) });
|
|
236
248
|
return { ok: true, stdout: String(stdout ?? ""), stderr: "", code: 0 };
|
|
237
249
|
} catch (e) {
|
|
238
250
|
return { ok: false, stdout: String(e.stdout ?? ""), stderr: String(e.stderr ?? ""), code: e.status ?? -1 };
|
|
@@ -411,13 +423,50 @@ function hardenFile(file) {
|
|
|
411
423
|
}
|
|
412
424
|
|
|
413
425
|
// ---------- 配置读写 ----------
|
|
426
|
+
/** 配置读失败只报一次(每 10s 一轮的 ensureBridge 会把同一行刷成噪音)。 */
|
|
427
|
+
let configReadWarned = false;
|
|
414
428
|
function loadConfig() {
|
|
415
|
-
try {
|
|
416
|
-
|
|
429
|
+
try {
|
|
430
|
+
return JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
|
|
431
|
+
} catch (e) {
|
|
432
|
+
// ⚠️ 静默返回 {} 是历史行为(首次安装时文件确实不存在)。但**读失败不能与"没登录"混淆**:
|
|
433
|
+
// 配置文件存在却读不出来(杀软占用 / OneDrive 同步中 / EPERM / 半写)时,指纹会瞬间变成
|
|
434
|
+
// `saas|||`,旧逻辑据此按"账号变了"停掉 bridge → 下一轮再拉起 → 反复重启(诊断报告 04 §4.3)。
|
|
435
|
+
// 这里只补"可观测"(写一次原因),重启抑制由 accountFingerprint 的连续确认负责。
|
|
436
|
+
if (e && e.code !== "ENOENT" && !configReadWarned) {
|
|
437
|
+
configReadWarned = true;
|
|
438
|
+
console.warn(`⚠️ 读取配置失败(${e.code || e.message}):${CONFIG_PATH} —— 本轮按空配置处理,但不据此重启 bridge。`);
|
|
439
|
+
}
|
|
440
|
+
return {};
|
|
441
|
+
}
|
|
417
442
|
}
|
|
443
|
+
/**
|
|
444
|
+
* 保存配置。
|
|
445
|
+
*
|
|
446
|
+
* 【C8 原子写】旧实现是"先截断再写"(writeFileSync 直接覆盖):saas 模式**每 10 秒**重写一次,
|
|
447
|
+
* 而插件半(设置页/切账号/备份)也在写同一个文件 —— Windows 上被杀软扫描/OneDrive 同步放大成
|
|
448
|
+
* "读到半截 JSON" → loadConfig 静默 {} → 指纹抖动 → bridge 反复重启。
|
|
449
|
+
*
|
|
450
|
+
* 现在写成 `<config>.<pid>.tmp` 再 rename 覆盖(rename 在同一目录内是原子的):
|
|
451
|
+
* 读的一方要么看到旧内容、要么看到新内容,永远读不到半截。
|
|
452
|
+
* 失败安全:Windows 上目标文件被杀软/备份工具持有句柄时 rename 可能 EBUSY/EPERM ——
|
|
453
|
+
* 先短暂重试,仍失败就退回直接写(宁可丢原子性,也绝不写不进去把配置搞丢)。
|
|
454
|
+
*/
|
|
418
455
|
function saveConfig(cfg) {
|
|
419
456
|
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
420
|
-
|
|
457
|
+
const body = JSON.stringify(cfg, null, 2);
|
|
458
|
+
const tmp = `${CONFIG_PATH}.${process.pid}.tmp`;
|
|
459
|
+
let renamed = false;
|
|
460
|
+
try {
|
|
461
|
+
fs.writeFileSync(tmp, body, { mode: 0o600 });
|
|
462
|
+
for (let i = 0; i < 3 && !renamed; i += 1) {
|
|
463
|
+
try { fs.renameSync(tmp, CONFIG_PATH); renamed = true; }
|
|
464
|
+
catch (e) { if (i === 2) throw e; sleepSync(30); }
|
|
465
|
+
}
|
|
466
|
+
} catch {
|
|
467
|
+
try { fs.rmSync(tmp, { force: true }); } catch { /* 临时文件删不掉不致命 */ }
|
|
468
|
+
fs.writeFileSync(CONFIG_PATH, body, { mode: 0o600 }); // 兜底:绝不让保存失败
|
|
469
|
+
}
|
|
421
470
|
hardenFile(CONFIG_PATH); // Windows 上 mode 是空操作 → 必须显式收紧 ACL(内含明文账号密码)
|
|
422
471
|
}
|
|
423
472
|
|
|
@@ -584,6 +633,83 @@ function pidAlive(pid) {
|
|
|
584
633
|
try { process.kill(pid, 0); return true; } catch (e) { return e.code === "EPERM"; }
|
|
585
634
|
}
|
|
586
635
|
|
|
636
|
+
/* ── C2:孤儿 bridge 清扫(命令行校验,绝不误杀)─────────────────────────────────
|
|
637
|
+
*
|
|
638
|
+
* 为什么 pid 文件回收(上面的 shouldReapLeftoverBridge)还不够:pid 文件只覆盖"上一任 watcher
|
|
639
|
+
* 写过的那个 pid"。真实场景里还有两类抓不到的孤儿(诊断报告 04 §4.4):
|
|
640
|
+
* ① watcher 被 TerminateProcess / 关闭控制台收掉时,bridge 子进程被 reparent 成孤儿继续持有隧道;
|
|
641
|
+
* ② 0.7.2 的升级覆盖写运行时**不停旧进程**,旧 bridge 继续用旧脚本跑。
|
|
642
|
+
* 两种孤儿都与新 bridge 同 deviceId → 中继 close(4000) 互踢(生产上 3 台 Win 设备长期乒乓,全 0.7.2)。
|
|
643
|
+
*
|
|
644
|
+
* 安全边界(误杀无关进程是不可接受的):
|
|
645
|
+
* · 只认**命令行同时包含** `dsh-bridge.mjs` 与**本运行时** `clients/dsh-remote` 路径的进程
|
|
646
|
+
* (与 looksLikeBridge 同一套纪律;还额外要求是本 THIS_DIR 的运行时,避免碰到另一个配置目录/用户);
|
|
647
|
+
* · 取不到命令行(ps/PowerShell 不可用)→ 一个都不杀。失败方向必须是"漏杀"而不是"误杀"。
|
|
648
|
+
*/
|
|
649
|
+
function isThisRuntimeBridgeCmd(cmd) {
|
|
650
|
+
const c = String(cmd || "");
|
|
651
|
+
if (!/dsh-bridge\.mjs/.test(c)) return false;
|
|
652
|
+
const needle = path.join(THIS_DIR, "clients", "dsh-remote");
|
|
653
|
+
// 命令行里的分隔符可能与本机 path.sep 不同(Windows 上两者都可能出现)→ 归一化后比较
|
|
654
|
+
const norm = (v) => v.replace(/[\\/]+/g, "/").toLowerCase();
|
|
655
|
+
return norm(c).includes(norm(needle));
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/** 列出本机属于**本运行时**的 bridge 进程 pid(命令行校验;取不到命令行 → 空数组)。 */
|
|
659
|
+
function listRuntimeBridgePids() {
|
|
660
|
+
const pids = [];
|
|
661
|
+
if (IS_WIN) {
|
|
662
|
+
// 一次性 PowerShell CIM 枚举(逐个 windowsProcessCommandLine 会起 N 个进程,太贵)
|
|
663
|
+
const script = [
|
|
664
|
+
"try { $p = Get-CimInstance Win32_Process -Filter \"Name='node.exe'\" }",
|
|
665
|
+
"catch { $p = Get-WmiObject Win32_Process -Filter \"Name='node.exe'\" }",
|
|
666
|
+
"$p | Select-Object ProcessId,CommandLine | ConvertTo-Json -Compress",
|
|
667
|
+
].join("; ");
|
|
668
|
+
const encoded = Buffer.from(script, "utf16le").toString("base64");
|
|
669
|
+
try {
|
|
670
|
+
const r = spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-EncodedCommand", encoded],
|
|
671
|
+
{ encoding: "utf8", timeout: 8000, windowsHide: true, maxBuffer: 8 * 1024 * 1024 });
|
|
672
|
+
const raw = String(r.stdout || "").trim();
|
|
673
|
+
if (raw) {
|
|
674
|
+
const parsed = JSON.parse(raw);
|
|
675
|
+
for (const p of Array.isArray(parsed) ? parsed : [parsed]) {
|
|
676
|
+
const pid = Number(p && p.ProcessId);
|
|
677
|
+
if (Number.isInteger(pid) && pid > 0 && isThisRuntimeBridgeCmd(p.CommandLine)) pids.push(pid);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
} catch { /* 无 PowerShell / 解析失败 → 不杀任何进程 */ }
|
|
681
|
+
return pids;
|
|
682
|
+
}
|
|
683
|
+
const r = sh("pgrep -fl 'dsh-bridge\\.mjs'");
|
|
684
|
+
if (!r.ok) return pids;
|
|
685
|
+
for (const line of String(r.stdout || "").split("\n")) {
|
|
686
|
+
const m = /^(\d+)\s+(.+)$/.exec(line.trim());
|
|
687
|
+
if (!m) continue;
|
|
688
|
+
if (isThisRuntimeBridgeCmd(m[2])) pids.push(Number(m[1]));
|
|
689
|
+
}
|
|
690
|
+
return pids;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* 清扫孤儿 bridge(接管/退出时调用)。返回被结束的 pid 列表。
|
|
695
|
+
* @param {{ fast?: boolean }} opts fast=true 时不等待(退出钩子里只剩 3s 预算)
|
|
696
|
+
*/
|
|
697
|
+
function sweepStrayBridges({ fast = false } = {}) {
|
|
698
|
+
const strays = listRuntimeBridgePids().filter((pid) => pid !== process.pid);
|
|
699
|
+
const killed = [];
|
|
700
|
+
for (const pid of strays) {
|
|
701
|
+
try { process.kill(pid, "SIGTERM"); killed.push(pid); } catch { /* 已退出 */ }
|
|
702
|
+
}
|
|
703
|
+
if (!fast) {
|
|
704
|
+
for (const pid of strays) {
|
|
705
|
+
const dl = Date.now() + 3000;
|
|
706
|
+
while (Date.now() < dl && pidAlive(pid)) sleepSync(100);
|
|
707
|
+
if (pidAlive(pid)) { try { process.kill(pid, "SIGKILL"); } catch { /* 已退出 */ } }
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
return killed;
|
|
711
|
+
}
|
|
712
|
+
|
|
587
713
|
/**
|
|
588
714
|
* 注册 Windows 登录任务(幂等:先删再建)。
|
|
589
715
|
* /DELAY 0000:15:登录后等 15 秒再起,避免与桌面环境抢启动时机
|
|
@@ -1435,6 +1561,32 @@ function accountFingerprint(cfg) {
|
|
|
1435
1561
|
return [c.local_key ? "local" : "saas", c.phone || c.email || "", String(c.device_id || ""), String(c.local_key || "")].join("|");
|
|
1436
1562
|
}
|
|
1437
1563
|
|
|
1564
|
+
/* ── 账号指纹"变化确认"(C8)────────────────────────────────────────────────────
|
|
1565
|
+
*
|
|
1566
|
+
* 🔴 为什么不能"指纹一变就重启 bridge"(诊断报告 04 §4.3):
|
|
1567
|
+
* `loadConfig()` 读失败会静默返回 `{}`,此时指纹 = `saas|||`,与真实值不等 ——
|
|
1568
|
+
* 旧逻辑据此按"账号变了"停 bridge,下一轮再拉起,于是每 10~20s 重启一次;
|
|
1569
|
+
* Windows 上被杀软扫描/OneDrive 同步/文件占用放大的"读抖动"就是这样变成重启循环的。
|
|
1570
|
+
*
|
|
1571
|
+
* 修法:指纹变化必须**连续 2 轮**(ensureBridge 每 10s 一轮)看到同一个新值,
|
|
1572
|
+
* 或该新值已持续 ≥5s,才认为"真的变了"。单轮的 `{}` 抖动会被下一轮复位清掉。
|
|
1573
|
+
* 抽成纯函数是因为这条判断的代价很实在(每误判一次就断一次隧道),必须能被直接测。
|
|
1574
|
+
*/
|
|
1575
|
+
const FINGERPRINT_CONFIRM_ROUNDS = 2;
|
|
1576
|
+
const FINGERPRINT_CONFIRM_MS = 5000;
|
|
1577
|
+
|
|
1578
|
+
/** 记一次"疑似新指纹":同一个 fp 连击计数,换值则重新从 1 开始。 */
|
|
1579
|
+
function noteFingerprintCandidate(prev, fp, now = Date.now()) {
|
|
1580
|
+
if (!prev || prev.value !== fp) return { value: fp, rounds: 1, since: now };
|
|
1581
|
+
return { value: fp, rounds: prev.rounds + 1, since: prev.since };
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
/** 这个"疑似变化"是否已经确认(连续 2 轮,或持续 ≥5s)。 */
|
|
1585
|
+
function fingerprintChangeConfirmed(cand, now = Date.now()) {
|
|
1586
|
+
if (!cand || !cand.value) return false;
|
|
1587
|
+
return cand.rounds >= FINGERPRINT_CONFIRM_ROUNDS || now - cand.since >= FINGERPRINT_CONFIRM_MS;
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1438
1590
|
/**
|
|
1439
1591
|
* 服务端登录限流的"最早可重试时刻"(bridge 在收到 429 时落盘)。
|
|
1440
1592
|
* 为什么需要:限流窗口是 15 分钟,而 watcher 每 10 秒就会重新拉起 bridge ——
|
|
@@ -1590,11 +1742,30 @@ async function runBridge() {
|
|
|
1590
1742
|
removePidFile(BRIDGE_PID_FILE);
|
|
1591
1743
|
}
|
|
1592
1744
|
}
|
|
1745
|
+
// ── C2:清扫「pid 文件之外的孤儿 bridge」(命令行校验,绝不误杀)──────────────
|
|
1746
|
+
//
|
|
1747
|
+
// pid 文件只能覆盖"上一任 watcher 写过的那个 pid"。真实世界里还有两类抓不到的孤儿:
|
|
1748
|
+
// ① watcher 被 TerminateProcess / 关闭控制台收掉 → bridge 被 reparent 成孤儿继续持有隧道;
|
|
1749
|
+
// ② 0.7.2 升级覆盖运行时**不停旧进程** → 旧 bridge 继续用旧脚本跑。
|
|
1750
|
+
// 它们与新 bridge 共用同一 deviceId → 中继 close(4000) 互踢(生产 3 台 Win 设备长期乒乓,全 0.7.2)。
|
|
1751
|
+
// 走到这里说明**本机此刻只有本守护**(去重已通过),所以任何"本运行时的 bridge"都是上一代的遗留。
|
|
1752
|
+
{
|
|
1753
|
+
const strays = sweepStrayBridges();
|
|
1754
|
+
if (strays.length) {
|
|
1755
|
+
console.log(`[dsh-remote] 已清扫 ${strays.length} 个孤儿 bridge 进程(pid ${strays.join(", ")})——避免与新实例互踢。`);
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1593
1758
|
}
|
|
1594
1759
|
let cfg = loadConfig();
|
|
1595
1760
|
let warnedNoLogin = false;
|
|
1596
1761
|
/** 当前 bridge 子进程使用的账号指纹(账号/模式/设备身份任一变化 → 重启子进程)。 */
|
|
1597
1762
|
let bridgeAccountFp = "";
|
|
1763
|
+
/** C8:疑似变化但还没确认的新指纹(连续 2 轮/≥5s 才确认,防 loadConfig 静默 {} 抖动)。 */
|
|
1764
|
+
let pendingFp = null;
|
|
1765
|
+
/** C2:bridge 判为「本机已有另一个实例」后的暂停拉起截止时刻。 */
|
|
1766
|
+
let duplicateBridgeBackoffUntil = 0;
|
|
1767
|
+
/** 暂停提示只打一次,避免每 10s 刷屏。 */
|
|
1768
|
+
let warnedDuplicateBridge = false;
|
|
1598
1769
|
/** 限流提示只打一次,避免刷屏。 */
|
|
1599
1770
|
let warnedRateLimit = false;
|
|
1600
1771
|
|
|
@@ -1642,11 +1813,25 @@ async function runBridge() {
|
|
|
1642
1813
|
//(2026-09-19 实测故障)。
|
|
1643
1814
|
const fp = accountFingerprint(cfg);
|
|
1644
1815
|
if (!childStopped(bridgeProc) && bridgeAccountFp && bridgeAccountFp !== fp) {
|
|
1816
|
+
// C8:**连续确认**才重启(理由见 noteFingerprintCandidate 上方的长注释)。
|
|
1817
|
+
// 单独一次读到 `{}`(文件被占用/半写/杀软扫描)不构成"账号变了",下一轮自然复位。
|
|
1818
|
+
pendingFp = noteFingerprintCandidate(pendingFp, fp);
|
|
1819
|
+
if (!fingerprintChangeConfirmed(pendingFp)) {
|
|
1820
|
+
if (pendingFp.rounds === 1) {
|
|
1821
|
+
// 不打印指纹内容:它含手机号/设备 id(日志会被用户复制到群里)
|
|
1822
|
+
console.log("[dsh-remote] 账号配置疑似变化,先观察一轮再决定是否重启 bridge(防配置读抖动)。");
|
|
1823
|
+
}
|
|
1824
|
+
return;
|
|
1825
|
+
}
|
|
1826
|
+
pendingFp = null;
|
|
1645
1827
|
console.log("[dsh-remote] 账号配置已变化 → 重启 bridge 让新账号生效...");
|
|
1646
1828
|
await stopChildGracefully(bridgeProc); // 先请它优雅退出,跑完微信通道下线通知
|
|
1647
1829
|
bridgeAccountFp = "";
|
|
1830
|
+
duplicateBridgeBackoffUntil = 0; // 用户明确换了账号:清掉"让位"暂停,立即用新凭据拉起
|
|
1648
1831
|
return; // 下一轮(10s 内)用新配置重新拉起
|
|
1649
1832
|
}
|
|
1833
|
+
// 指纹又回到一致(或本来就没变)→ 之前的"疑似变化"作废,绝不让它跨轮累积。
|
|
1834
|
+
pendingFp = null;
|
|
1650
1835
|
|
|
1651
1836
|
// 服务端登录限流窗口内不反复拉起 bridge(凭据没错,改了也没用)
|
|
1652
1837
|
const rlUntil = loginRateLimitUntil();
|
|
@@ -1659,6 +1844,18 @@ async function runBridge() {
|
|
|
1659
1844
|
}
|
|
1660
1845
|
warnedRateLimit = false;
|
|
1661
1846
|
|
|
1847
|
+
// C2:bridge 连续 2 次收到 code=4000 已判定"本机还有另一个同 deviceId 的实例"并退出(code=4)。
|
|
1848
|
+
// 那段时间内本守护**不再拉起**:拉起只会把对方顶掉,继续乒乓互踢。
|
|
1849
|
+
if (Date.now() < duplicateBridgeBackoffUntil) {
|
|
1850
|
+
if (!warnedDuplicateBridge) {
|
|
1851
|
+
warnedDuplicateBridge = true;
|
|
1852
|
+
console.log(`[dsh-remote] 本机已有另一个同 deviceId 的 bridge(互踢保护)→ 本守护暂停拉起约 ${Math.ceil((duplicateBridgeBackoffUntil - Date.now()) / 60000)} 分钟。`);
|
|
1853
|
+
console.log(" 若这是升级/重装留下的旧实例,可执行 `dsh-remote doctor` 或重启一次 dsh web 让它退出。");
|
|
1854
|
+
}
|
|
1855
|
+
return;
|
|
1856
|
+
}
|
|
1857
|
+
warnedDuplicateBridge = false;
|
|
1858
|
+
|
|
1662
1859
|
const alive = await checkUpstream();
|
|
1663
1860
|
if (alive && childStopped(bridgeProc)) {
|
|
1664
1861
|
starting = true;
|
|
@@ -1685,9 +1882,28 @@ async function runBridge() {
|
|
|
1685
1882
|
// 只有它能让我们在停 bridge 之前请它跑完退出钩子(notifystop)—— 见 stopChildGracefully。
|
|
1686
1883
|
bridgeProc = spawn(NODE_BIN,
|
|
1687
1884
|
[path.join(THIS_DIR, "clients/dsh-remote/dsh-bridge.mjs")],
|
|
1688
|
-
{
|
|
1689
|
-
|
|
1885
|
+
{
|
|
1886
|
+
env: nodeEnv(childEnv),
|
|
1887
|
+
stdio: ["inherit", "inherit", "inherit", "ipc"],
|
|
1888
|
+
// 🔴 C1 黑窗根治(2026-10-20 诊断 W1,生产截图 04):这里**必须**带 windowsHide。
|
|
1889
|
+
// 原理同 spawnDetachedBridge 的注释:watcher 自己常以"无控制台"方式被拉起
|
|
1890
|
+
//(launchd / 任务计划里的 wscript / 插件的 detached spawn)。无控制台的父进程
|
|
1891
|
+
// spawn 一个控制台程序(node.exe)却不给 CREATE_NO_WINDOW 时,Windows 会**为子进程
|
|
1892
|
+
// 新分配一个可见控制台窗口**;而 bridge 的 stdout/stderr 又被 watcher 指向日志文件,
|
|
1893
|
+
// 于是用户看到的就是一个**空白黑窗**。bridge 崩溃 → watcher 每 10s 重拉 → 每 10s 弹一个。
|
|
1894
|
+
// POSIX 上该选项被忽略。
|
|
1895
|
+
windowsHide: true
|
|
1896
|
+
});
|
|
1897
|
+
bridgeProc.on("exit", (code) => {
|
|
1690
1898
|
removePidFile(BRIDGE_PID_FILE);
|
|
1899
|
+
// C2 互踢根治的 watcher 半边:bridge 连续 2 次收到 code=4000(= 本机已有同 deviceId 的
|
|
1900
|
+
// 另一个 bridge 把本连接顶掉)后会以"重复实例"退出码优雅退出(见 dsh-bridge.mjs)。
|
|
1901
|
+
// 这种退出**绝不能**照常 10s 重拉:本机已经有另一个实例在跑,重拉只会继续乒乓互踢。
|
|
1902
|
+
if (code === DUPLICATE_INSTANCE_EXIT_CODE) {
|
|
1903
|
+
duplicateBridgeBackoffUntil = Date.now() + DUPLICATE_BRIDGE_BACKOFF_MS;
|
|
1904
|
+
console.log(`[dsh-remote] bridge 检测到同 deviceId 的另一个实例并退出(code=${code})——本守护暂停 ${Math.round(DUPLICATE_BRIDGE_BACKOFF_MS / 60000)} 分钟后再试,避免两个实例反复互踢。`);
|
|
1905
|
+
return;
|
|
1906
|
+
}
|
|
1691
1907
|
console.log("[dsh-remote] bridge 退出,等待重启...");
|
|
1692
1908
|
});
|
|
1693
1909
|
// Windows:落 pid 文件,供插件半发现进程(Windows 没有 pgrep/ps)。
|
|
@@ -1728,6 +1944,9 @@ async function runBridge() {
|
|
|
1728
1944
|
if (bridgeProc && bridgeProc.exitCode === null) {
|
|
1729
1945
|
try { bridgeProc.kill("SIGKILL"); } catch { /* 已退出 */ }
|
|
1730
1946
|
}
|
|
1947
|
+
// C2:退出时顺手清掉本运行时的全部 bridge 残留(fast:不等待)。
|
|
1948
|
+
// 否则 Windows 上 watcher 被收掉后 bridge 可能成孤儿、继续与新实例互踢。
|
|
1949
|
+
try { sweepStrayBridges({ fast: true }); } catch { /* 清扫失败不影响退出 */ }
|
|
1731
1950
|
process.exit(0);
|
|
1732
1951
|
}, 3000);
|
|
1733
1952
|
});
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* C2 / C8 回归:客户端升级与守护层的「不折腾用户的那些约束」。
|
|
3
|
+
*
|
|
4
|
+
* 背景(2026-10-20 用户群故障诊断,报告 00 §6.2 / 04 §四·§五):
|
|
5
|
+
* · C8-a **配置抖动**:loadConfig() 任何读失败都静默返回 {} → 账号指纹瞬间变化 →
|
|
6
|
+
* 旧逻辑据此"账号变了,重启 bridge" → 下一轮再拉起。Windows 上被杀软/OneDrive
|
|
7
|
+
* 放大的读抖动就这样变成每 10~20s 一次的重启循环(§4.3)。修法:指纹变化必须
|
|
8
|
+
* 连续 2 轮(或持续 ≥5s)确认;saveConfig 改原子写(tmp + rename),让读方永远读不到半截。
|
|
9
|
+
* · C8-b **升级杀旧进程**:安装器覆盖写 clients/ 与 node_modules/ws 时,旧 watcher/bridge
|
|
10
|
+
* 正在跑那份文件 → Windows 杀软持有句柄 → 半装(`Cannot find package 'ws'`)+ 孤儿互踢。
|
|
11
|
+
* 修法:覆盖前按"本 relayDir 的命令行"停旧进程,装完按 pid 点名清残留;失败则恢复。
|
|
12
|
+
* · C2 **孤儿 bridge 清扫**:pid 文件之外的 bridge(watcher 被 TerminateProcess 收掉、
|
|
13
|
+
* 升级后旧进程未退)由 watcher 按命令行清扫,绝不误杀无关进程。
|
|
14
|
+
*
|
|
15
|
+
* 这些分支里的"安全边界"(误杀/误停/误重启)代价都很高,所以纯函数 + 注入替身逐条上锁。
|
|
16
|
+
*/
|
|
17
|
+
import assert from "node:assert/strict";
|
|
18
|
+
import http from "node:http";
|
|
19
|
+
import { spawn } from "node:child_process";
|
|
20
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
21
|
+
import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
22
|
+
import os from "node:os";
|
|
23
|
+
import path from "node:path";
|
|
24
|
+
import test from "node:test";
|
|
25
|
+
import { fileURLToPath } from "node:url";
|
|
26
|
+
|
|
27
|
+
import { apply } from "../lib/index.js";
|
|
28
|
+
|
|
29
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
30
|
+
const ROOT = path.join(HERE, "..", "..", "..");
|
|
31
|
+
const INDEX_SRC = readFileSync(path.join(HERE, "..", "lib", "index.js"), "utf8").replace(/\r\n/g, "\n");
|
|
32
|
+
const SETUP_SRC = readFileSync(path.join(ROOT, "dsh-setup.mjs"), "utf8").replace(/\r\n/g, "\n");
|
|
33
|
+
const BRIDGE_SRC = readFileSync(path.join(ROOT, "clients", "dsh-remote", "dsh-bridge.mjs"), "utf8").replace(/\r\n/g, "\n");
|
|
34
|
+
|
|
35
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
36
|
+
async function waitFor(fn, { timeout = 10000, step = 50 } = {}) {
|
|
37
|
+
const deadline = Date.now() + timeout;
|
|
38
|
+
for (;;) {
|
|
39
|
+
if (await fn()) return true;
|
|
40
|
+
if (Date.now() > deadline) return false;
|
|
41
|
+
await sleep(step);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 提取 `function name(...) { ... }` 的完整源码(按花括号配对,忽略字符串里的括号风险极小)。 */
|
|
46
|
+
function extractFunction(src, name) {
|
|
47
|
+
const start = src.indexOf(`function ${name}(`);
|
|
48
|
+
assert.ok(start >= 0, `源码里找不到 function ${name}`);
|
|
49
|
+
const open = src.indexOf("{", start);
|
|
50
|
+
let depth = 0;
|
|
51
|
+
for (let i = open; i < src.length; i += 1) {
|
|
52
|
+
if (src[i] === "{") depth += 1;
|
|
53
|
+
else if (src[i] === "}") { depth -= 1; if (depth === 0) return src.slice(start, i + 1); }
|
|
54
|
+
}
|
|
55
|
+
throw new Error(`function ${name} 花括号不配对`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ─────────────────────── ① C8:指纹确认(读抖动不得触发重启)───────────────────────
|
|
59
|
+
|
|
60
|
+
/** 从 dsh-setup.mjs 里取出"指纹变化确认"那段(两个常量 + 两个纯函数),单独求值。 */
|
|
61
|
+
function loadFingerprintConfirmers() {
|
|
62
|
+
const start = SETUP_SRC.indexOf("const FINGERPRINT_CONFIRM_ROUNDS");
|
|
63
|
+
const end = SETUP_SRC.indexOf("const LOGIN_RATELIMIT_FILE");
|
|
64
|
+
assert.ok(start > 0 && end > start, "应在 dsh-setup.mjs 里找到指纹确认实现");
|
|
65
|
+
return new Function(
|
|
66
|
+
`${SETUP_SRC.slice(start, end)}\nreturn { FINGERPRINT_CONFIRM_ROUNDS, FINGERPRINT_CONFIRM_MS, noteFingerprintCandidate, fingerprintChangeConfirmed };`
|
|
67
|
+
)();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
test("C8 指纹抖动被抑制:单轮读到 {} / 半截配置不得触发「账号变了→重启 bridge」", () => {
|
|
71
|
+
const { FINGERPRINT_CONFIRM_ROUNDS, FINGERPRINT_CONFIRM_MS, noteFingerprintCandidate, fingerprintChangeConfirmed } = loadFingerprintConfirmers();
|
|
72
|
+
assert.ok(FINGERPRINT_CONFIRM_ROUNDS >= 2, "确认轮数至少 2");
|
|
73
|
+
assert.ok(FINGERPRINT_CONFIRM_MS >= 5000, "确认时长至少 5s(等待跨轮)");
|
|
74
|
+
|
|
75
|
+
// 模拟 ensureBridge 每 10s 一轮:真实指纹 A,某轮 loadConfig() 读失败 → {} → 指纹成 "saas|||"
|
|
76
|
+
const A = "saas|13800000000|dev-1234|";
|
|
77
|
+
const EMPTY = "saas|||";
|
|
78
|
+
const t0 = 1_000_000;
|
|
79
|
+
let pending = null;
|
|
80
|
+
let restarts = 0;
|
|
81
|
+
const rounds = [A, EMPTY, A, EMPTY, A, EMPTY, A];
|
|
82
|
+
let maxRounds = 0;
|
|
83
|
+
rounds.forEach((fp, i) => {
|
|
84
|
+
const now = t0 + i * 10_000;
|
|
85
|
+
if (fp === A) { pending = null; return; } // 指纹回到一致 → 疑似变化作废
|
|
86
|
+
pending = noteFingerprintCandidate(pending, fp, now);
|
|
87
|
+
maxRounds = Math.max(maxRounds, pending.rounds);
|
|
88
|
+
if (fingerprintChangeConfirmed(pending, now)) restarts += 1;
|
|
89
|
+
});
|
|
90
|
+
assert.equal(restarts, 0, "★抖动(A/{} 交替)必须一次都不重启:旧实现每轮都会重启");
|
|
91
|
+
assert.equal(maxRounds, 1, "待确认状态不得跨轮累积(相邻两轮看到的必须是同一个新值才算数)");
|
|
92
|
+
assert.equal(pending, null, "指纹回到一致后,待确认状态必须清空");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("C8 真·切号仍要生效:连续 2 轮看到新指纹(或持续 ≥5s)就重启", () => {
|
|
96
|
+
const { noteFingerprintCandidate, fingerprintChangeConfirmed } = loadFingerprintConfirmers();
|
|
97
|
+
const A = "saas|13800000000|dev-1234|";
|
|
98
|
+
const B = "saas|13900000000|dev-1234|";
|
|
99
|
+
const t0 = 2_000_000;
|
|
100
|
+
let pending = noteFingerprintCandidate(null, B, t0);
|
|
101
|
+
assert.equal(fingerprintChangeConfirmed(pending, t0), false, "第一轮只是「疑似」,不能立刻重启");
|
|
102
|
+
// 第二轮(ensureBridge 下一轮,10s 后)看到同一个新指纹 → 确认
|
|
103
|
+
pending = noteFingerprintCandidate(pending, B, t0 + 10_000);
|
|
104
|
+
assert.equal(fingerprintChangeConfirmed(pending, t0 + 10_000), true, "连续 2 轮 → 确认真切号");
|
|
105
|
+
|
|
106
|
+
// 只看到一次、但已经过去 ≥5s(例如被限流/上游离线跳过若干轮)→ 也确认
|
|
107
|
+
const single = noteFingerprintCandidate(null, B, t0);
|
|
108
|
+
assert.equal(fingerprintChangeConfirmed(single, t0 + 4_999), false);
|
|
109
|
+
assert.equal(fingerprintChangeConfirmed(single, t0 + 5_000), true);
|
|
110
|
+
|
|
111
|
+
// 换了另一个新值就把连击清零(B/C 交替不是"连续同一个")
|
|
112
|
+
const flip = noteFingerprintCandidate(noteFingerprintCandidate(null, B, t0), "saas|13700000000|dev-1234|", t0 + 100);
|
|
113
|
+
assert.equal(flip.rounds, 1);
|
|
114
|
+
assert.equal(fingerprintChangeConfirmed(flip, t0 + 100), false);
|
|
115
|
+
|
|
116
|
+
// 集成形态:ensureBridge 里必须真的用这套判定,而不是"指纹一变就重启"
|
|
117
|
+
const ensure = SETUP_SRC.slice(SETUP_SRC.indexOf("const ensureBridge = async () => {"), SETUP_SRC.indexOf("// watcher 自身的 pid 也要落盘"));
|
|
118
|
+
assert.ok(/pendingFp = noteFingerprintCandidate\(pendingFp, fp\)/.test(ensure), "ensureBridge 必须走连续确认");
|
|
119
|
+
assert.ok(/if \(!fingerprintChangeConfirmed\(pendingFp\)\)/.test(ensure), "未确认时必须直接 return(不重启)");
|
|
120
|
+
assert.ok(/pendingFp = null;/.test(ensure), "指纹复位/确认后要清空待确认状态");
|
|
121
|
+
assert.ok(/bridgeAccountFp !== fp/.test(ensure), "仍然只在「读到的指纹与当前子进程不同」时才考虑重启");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// ─────────────────────── ② C8:配置原子写 ───────────────────────
|
|
125
|
+
|
|
126
|
+
test("C8 配置原子写:写临时文件 + rename(读方永远读不到半截 JSON);rename 失败才退回直写", () => {
|
|
127
|
+
const fnSrc = extractFunction(SETUP_SRC, "saveConfig");
|
|
128
|
+
const calls = [];
|
|
129
|
+
const make = (fsFake) => new Function("fs", "CONFIG_PATH", "CONFIG_DIR", "sleepSync", "hardenFile", "process",
|
|
130
|
+
`${fnSrc}\nreturn saveConfig;`)(fsFake, "/tmp/relay/.dsh-config.json", "/tmp/relay", () => {}, (p) => calls.push(["harden", p]), { pid: 4242 });
|
|
131
|
+
|
|
132
|
+
// ① 正常路径:先写 <config>.<pid>.tmp,再 rename 覆盖;**绝不**直接写目标文件
|
|
133
|
+
const fsOk = {
|
|
134
|
+
mkdirSync: () => calls.push(["mkdir"]),
|
|
135
|
+
writeFileSync: (p, body) => calls.push(["write", p, body]),
|
|
136
|
+
renameSync: (from, to) => calls.push(["rename", from, to]),
|
|
137
|
+
rmSync: (p) => calls.push(["rm", p]),
|
|
138
|
+
};
|
|
139
|
+
make(fsOk)({ phone: "13800000000" });
|
|
140
|
+
const write = calls.find((c) => c[0] === "write");
|
|
141
|
+
const rename = calls.find((c) => c[0] === "rename");
|
|
142
|
+
assert.ok(write, "必须写临时文件");
|
|
143
|
+
assert.equal(write[1], "/tmp/relay/.dsh-config.json.4242.tmp", "临时文件应带 pid(并发写互不覆盖)");
|
|
144
|
+
assert.ok(rename, "必须 rename 到目标路径(原子替换)");
|
|
145
|
+
assert.equal(rename[1], write[1], "rename 的源就是刚写的临时文件");
|
|
146
|
+
assert.equal(rename[2], "/tmp/relay/.dsh-config.json");
|
|
147
|
+
assert.ok(!calls.some((c) => c[0] === "write" && c[1] === "/tmp/relay/.dsh-config.json"),
|
|
148
|
+
"★不得直接覆盖目标文件(旧实现先截断再写 = 读方可能读到半截)");
|
|
149
|
+
assert.ok(calls.some((c) => c[0] === "harden"), "写完仍要收紧文件权限(内含明文账号密码)");
|
|
150
|
+
|
|
151
|
+
// ② rename 一直失败(Windows 被杀软/备份工具持有句柄)→ 重试后兜底直写,绝不丢配置
|
|
152
|
+
calls.length = 0;
|
|
153
|
+
const fsBusy = {
|
|
154
|
+
mkdirSync: () => {},
|
|
155
|
+
writeFileSync: (p, body) => calls.push(["write", p, body]),
|
|
156
|
+
renameSync: () => { const e = new Error("EBUSY"); e.code = "EBUSY"; throw e; },
|
|
157
|
+
rmSync: (p) => calls.push(["rm", p]),
|
|
158
|
+
};
|
|
159
|
+
make(fsBusy)({ phone: "13800000000" });
|
|
160
|
+
assert.ok(calls.some((c) => c[0] === "write" && c[1] === "/tmp/relay/.dsh-config.json"),
|
|
161
|
+
"rename 不可用时必须兜底直写(宁可丢原子性,也不能保存失败把配置丢了)");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// ─────────────────────── ③ C8:升级前停旧进程 / 装完清残留 ───────────────────────
|
|
165
|
+
|
|
166
|
+
test("C8 更新流:覆盖运行时**之前**停旧 bridge;成功清残留、失败恢复(顺序与落点)", () => {
|
|
167
|
+
const body = INDEX_SRC.slice(INDEX_SRC.indexOf("async function runOnlineUpdate(relayDir) {"), INDEX_SRC.indexOf("function tailOf("));
|
|
168
|
+
assert.ok(/stopInfo = stopBridgeForRuntimeReplace\(relayDir\)/.test(body), "更新开始要停本 relayDir 的旧 watcher/bridge");
|
|
169
|
+
const stopAt = body.indexOf("stopInfo = stopBridgeForRuntimeReplace(relayDir)");
|
|
170
|
+
const runAt = body.indexOf("const child = run();");
|
|
171
|
+
assert.ok(stopAt > 0 && runAt > 0 && stopAt < runAt, "★必须先停旧进程,再启动 npx 安装(否则覆盖写运行中的文件)");
|
|
172
|
+
assert.ok(/sweepIfSucceeded\(\);/.test(body), "成功路径要清残留");
|
|
173
|
+
assert.ok(/restoreIfStopped\(\);/.test(body), "失败/取消路径要把 bridge 拉回来(别让用户莫名失去远程访问)");
|
|
174
|
+
// 失败分支的覆盖:三跳(官方源/默认源/离线)任一失败都要恢复
|
|
175
|
+
const restoreCount = (body.match(/restoreIfStopped\(\);/g) || []).length;
|
|
176
|
+
assert.ok(restoreCount >= 4, `失败/取消/异常各分支都要恢复(实际 ${restoreCount} 处)`);
|
|
177
|
+
// 恢复只在本轮真停过进程时才执行(测试/无进程环境下不得乱拉)
|
|
178
|
+
assert.ok(/if \(!stopInfo\.pids\.length\) return;/.test(body), "没停过进程就不要恢复(避免测试里碰真实服务)");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("C8 清残留只按命令行点名:死 pid/无关进程/更新前不存在的新实例一律不动", () => {
|
|
182
|
+
const fnSrc = extractFunction(INDEX_SRC, "sweepBridgeAfterRuntimeReplace");
|
|
183
|
+
const make = () => new Function("skipsSystemOps", "pidAlive", "pidRunsFrom", "killPid",
|
|
184
|
+
`${fnSrc}\nreturn sweepBridgeAfterRuntimeReplace;`)(
|
|
185
|
+
() => false,
|
|
186
|
+
(pid) => pid !== 999, // 999 = 已死
|
|
187
|
+
(pid, relayDir) => { // 只有 11/12 的命令行属于本 relayDir(12 其实已被回收给无关进程)
|
|
188
|
+
if (pid === 12) return false;
|
|
189
|
+
return pid === 11 || pid === 10;
|
|
190
|
+
},
|
|
191
|
+
(pid, sig) => { killed.push([pid, sig]); return true; }
|
|
192
|
+
);
|
|
193
|
+
const killed = [];
|
|
194
|
+
const sweep = make();
|
|
195
|
+
const out = sweep("/tmp/relay", [10, 11, 12, 999]);
|
|
196
|
+
assert.deepEqual(out, [10, 11], "只结束「还活着且命令行仍指向本 relayDir」的那批 pid");
|
|
197
|
+
assert.deepEqual(killed.map((k) => k[0]).sort((a, b) => a - b), [10, 11]);
|
|
198
|
+
// 不传 pid 列表 = 什么都不做(成功但没停过旧进程时绝不清扫,避免误杀安装器刚拉起的新实例)
|
|
199
|
+
assert.deepEqual(sweep("/tmp/relay", []), []);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("C8 停旧进程前必须做命令行校验:看不清就不动(误杀无关进程不可接受)", () => {
|
|
203
|
+
const fnSrc = extractFunction(INDEX_SRC, "pidRunsFrom");
|
|
204
|
+
const make = (cmd) => new Function("commandLineOfPid", `${fnSrc}\nreturn pidRunsFrom;`)(() => cmd);
|
|
205
|
+
assert.equal(make("")(123, "/home/u/.dsh-remote"), false, "取不到命令行 → 不动");
|
|
206
|
+
assert.equal(make("node /home/u/.dsh-remote/clients/dsh-remote/dsh-bridge.mjs")(123, "/home/u/.dsh-remote"), true);
|
|
207
|
+
assert.equal(make("node /opt/other/.dsh-remote/clients/dsh-remote/dsh-bridge.mjs")(123, "/home/u/.dsh-remote"), false,
|
|
208
|
+
"别的目录的 bridge 不得被停");
|
|
209
|
+
assert.equal(make("node C:\\Users\\u\\.dsh-remote\\clients\\dsh-remote\\dsh-bridge.mjs")(123, "C:\\Users\\u\\.dsh-remote"), true,
|
|
210
|
+
"Windows 反斜杠路径也要能匹配");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// ─────────────────────── ④ C2:watcher 侧孤儿 bridge 清扫 ───────────────────────
|
|
214
|
+
|
|
215
|
+
test("C2 watcher 孤儿清扫:按「本运行时命令行」双重校验,且退出码 4 时暂停拉起", () => {
|
|
216
|
+
// 清扫函数必须同时校验脚本名与**本运行时**路径(别的配置目录/用户的 bridge 不碰)
|
|
217
|
+
const isCmd = extractFunction(SETUP_SRC, "isThisRuntimeBridgeCmd");
|
|
218
|
+
const make = (thisDir) => new Function("path", "THIS_DIR", `${isCmd}\nreturn isThisRuntimeBridgeCmd;`)(path, thisDir);
|
|
219
|
+
const check = make("/home/u/.dsh-remote");
|
|
220
|
+
assert.equal(check(`node /home/u/.dsh-remote/clients/dsh-remote/dsh-bridge.mjs --x`), true);
|
|
221
|
+
assert.equal(check(`node /opt/other/.dsh-remote/clients/dsh-remote/dsh-bridge.mjs`), false, "别的配置目录的 bridge 绝不能被扫到");
|
|
222
|
+
assert.equal(check(`node /home/u/.dsh-remote/clients/dsh-remote/wechat-channel.mjs`), false, "不是 bridge 脚本");
|
|
223
|
+
|
|
224
|
+
// 接管/退出时都要清扫孤儿(否则孤儿与新实例同 deviceId 互踢)
|
|
225
|
+
const run = SETUP_SRC.slice(SETUP_SRC.indexOf("async function runBridge() {"), SETUP_SRC.indexOf("// ---------- setup:一键安装"));
|
|
226
|
+
assert.ok(/const strays = sweepStrayBridges\(\);/.test(run), "watcher 启动/接管时要清扫 pid 文件之外的孤儿 bridge");
|
|
227
|
+
assert.ok(/sweepStrayBridges\(\{ fast: true \}\)/.test(run), "watcher 退出时也要顺手清扫(fast)");
|
|
228
|
+
// bridge 侧"重复实例"退出码必须与 watcher 侧一致(否则 watcher 不会暂停、继续 10s 重拉互踢)
|
|
229
|
+
assert.match(BRIDGE_SRC, /export const DUPLICATE_INSTANCE_EXIT_CODE = 4;/);
|
|
230
|
+
assert.match(SETUP_SRC, /const DUPLICATE_INSTANCE_EXIT_CODE = 4;/);
|
|
231
|
+
assert.ok(/code === DUPLICATE_INSTANCE_EXIT_CODE/.test(SETUP_SRC), "watcher 要识别这个退出码");
|
|
232
|
+
assert.ok(/duplicateBridgeBackoffUntil = Date\.now\(\) \+ DUPLICATE_BRIDGE_BACKOFF_MS/.test(SETUP_SRC),
|
|
233
|
+
"识别后必须暂停拉起(否则 10s 后又去顶撞对方)");
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// ─────────────────────── ⑤ 端到端:一键更新真的会停掉本 relayDir 的旧 bridge ───────────────────────
|
|
237
|
+
|
|
238
|
+
/** 假 npx:写一行进度(避免被"无输出看门狗"判卡住)后成功退出。 */
|
|
239
|
+
const FAKE_NPX = `#!/bin/sh
|
|
240
|
+
echo "[fake-npx] progress"
|
|
241
|
+
sleep 0.3
|
|
242
|
+
exit 0
|
|
243
|
+
`;
|
|
244
|
+
|
|
245
|
+
test("C8 端到端:一键更新覆盖运行时前,先结束本 relayDir 的旧 bridge(命令行校验通过才杀)", async () => {
|
|
246
|
+
const root = await mkdtemp(path.join(os.tmpdir(), "dsh-update-stop-"));
|
|
247
|
+
const fakeBin = path.join(root, "bin");
|
|
248
|
+
const relayDir = path.join(root, "relay");
|
|
249
|
+
const home = path.join(root, "home");
|
|
250
|
+
await mkdir(fakeBin, { recursive: true });
|
|
251
|
+
await mkdir(relayDir, { recursive: true });
|
|
252
|
+
await mkdir(home, { recursive: true });
|
|
253
|
+
await writeFile(path.join(fakeBin, "npx"), FAKE_NPX);
|
|
254
|
+
await chmod(path.join(fakeBin, "npx"), 0o755);
|
|
255
|
+
// 假"旧 bridge":真进程 + 真命令行(路径在本 relayDir 下),才能过 collectRuntimePids/pidRunsFrom
|
|
256
|
+
const stub = path.join(relayDir, "clients", "dsh-remote", "dsh-bridge.mjs");
|
|
257
|
+
await mkdir(path.dirname(stub), { recursive: true });
|
|
258
|
+
await writeFile(stub, "setTimeout(() => {}, 600000);\n");
|
|
259
|
+
const oldBridge = spawn(process.execPath, [stub], { stdio: "ignore" });
|
|
260
|
+
await writeFile(path.join(relayDir, ".dsh-bridge.pid"), String(oldBridge.pid));
|
|
261
|
+
|
|
262
|
+
const saved = {};
|
|
263
|
+
for (const k of ["HOME", "DSH_SETUP_NPX_DIR", "DSH_RELAY_SKIP_SERVICE", "DSH_UPDATE_IDLE_MS", "DSH_INSTALL_IDLE_MS",
|
|
264
|
+
"DSH_STALL_POLL_MS", "DSH_REMOTE_TELEMETRY", "DSH_RELAY_SELFHEAL_MS"]) saved[k] = process.env[k];
|
|
265
|
+
process.env.HOME = home;
|
|
266
|
+
process.env.DSH_SETUP_NPX_DIR = fakeBin;
|
|
267
|
+
process.env.DSH_REMOTE_TELEMETRY = "0";
|
|
268
|
+
process.env.DSH_RELAY_SELFHEAL_MS = "600000"; // 别让自愈在我们观察期间另外拉起东西
|
|
269
|
+
delete process.env.DSH_RELAY_SKIP_SERVICE; // 要真的走"停旧进程"分支
|
|
270
|
+
|
|
271
|
+
const routes = new Map();
|
|
272
|
+
apply({
|
|
273
|
+
webServer: { register(route) { routes.set(route.path, route.handler); return () => {}; } },
|
|
274
|
+
effect(register) { return register(); },
|
|
275
|
+
logger: { info() {}, warn() {} },
|
|
276
|
+
}, { relayDir });
|
|
277
|
+
const host = http.createServer((req, res) => {
|
|
278
|
+
const handler = routes.get(new URL(req.url, "http://x").pathname);
|
|
279
|
+
(handler || ((_r, rs) => { rs.writeHead(404); rs.end(); }))(req, res);
|
|
280
|
+
});
|
|
281
|
+
await new Promise((resolve) => host.listen(0, "127.0.0.1", resolve));
|
|
282
|
+
const base = `http://127.0.0.1:${host.address().port}`;
|
|
283
|
+
try {
|
|
284
|
+
const started = await (await fetch(base + "/dsh-remote/self/update", { method: "POST" })).json();
|
|
285
|
+
assert.equal(started.ok, true, `更新应能启动:${JSON.stringify(started)}`);
|
|
286
|
+
const logPath = path.join(relayDir, ".dsh-update.log");
|
|
287
|
+
const stopped = await waitFor(() => {
|
|
288
|
+
try { process.kill(oldBridge.pid, 0); return false; } catch { return true; }
|
|
289
|
+
}, { timeout: 8000 });
|
|
290
|
+
assert.equal(stopped, true, "★覆盖运行时的旧 bridge 必须被结束(否则半装 + 新老互踢)");
|
|
291
|
+
const log = existsSync(logPath) ? readFileSync(logPath, "utf8") : "";
|
|
292
|
+
assert.match(log, /覆盖运行时前已停旧 bridge\/watcher(pid /, `更新日志要写明停了哪些进程,实际:\n${log.slice(-600)}`);
|
|
293
|
+
} finally {
|
|
294
|
+
try { oldBridge.kill("SIGKILL"); } catch { /* 已退出 */ }
|
|
295
|
+
await new Promise((resolve) => host.close(resolve));
|
|
296
|
+
for (const [k, v] of Object.entries(saved)) { if (v === undefined) delete process.env[k]; else process.env[k] = v; }
|
|
297
|
+
await rm(root, { recursive: true, force: true });
|
|
298
|
+
}
|
|
299
|
+
});
|