dsh-remote-web 0.7.2 → 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/lib/index.js +140 -3
- package/package.json +1 -1
- package/runtime/clients/dsh-remote/dsh-bridge.mjs +214 -9
- package/runtime/clients/dsh-remote/e2ee-shim-script.js +98 -18
- package/runtime/clients/dsh-remote/mirror-diag-script.js +19 -0
- package/runtime/clients/dsh-remote/mobile-adapter.mjs +803 -16
- package/runtime/clients/dsh-remote/upstream-discovery.mjs +6 -1
- package/runtime/dsh-setup.mjs +225 -6
|
@@ -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
|
}
|
package/runtime/dsh-setup.mjs
CHANGED
|
@@ -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
|
});
|