@mrrisega/dsh-remote 0.7.7 → 0.7.9-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1687,21 +1687,59 @@ function launchdStatusIn(target) {
1687
1687
  return { running: false, pid: null, state: "", runs: null, lastExitCode: null, crashing: false };
1688
1688
  }
1689
1689
 
1690
- // ---------- Windows:bridge 进程发现(0.6.7) ----------
1690
+ // ---------- Windows:bridge 进程发现(0.6.7 立;2026-10-29 反馈后重写) ----------
1691
1691
  //
1692
1692
  // Windows 没有 pgrep/ps/launchctl,而 bridge 在 Windows 上也不由服务管理器托管
1693
- // (dsh-setup.mjs 明确不生成 Windows 自启动)。所以进程发现按「pid 文件优先、PowerShell 兜底」两级:
1693
+ // (dsh-setup.mjs 明确不生成 Windows 自启动)。所以进程发现按「pid 文件 → bridge 自写证据 → PowerShell 扫描」三级:
1694
1694
  // ① pid 文件:插件自己 spawn 的 watcher、以及 `dsh-remote run` 跑起来的 watcher/bridge
1695
1695
  // 都会把 pid 落到配置目录(见 dsh-setup.mjs 的 writePidFile)。读文件 + process.kill(pid,0)
1696
1696
  // 判活,零 shell 开销——面板 2~3s 轮询一次也扛得住。
1697
- // ② PowerShell 扫 node.exe 的命令行:兼容「用户跑的是没写 pid 文件的旧版运行时」。
1697
+ // ② bridge **自己**写的单实例锁 `.dsh-bridge-instance.lock`({pid,at,script},见 dsh-bridge.mjs 的
1698
+ // acquireInstanceLock)与状态文件 `.dsh-bridge-state.json` 里的 pid:进程自证身份。父进程
1699
+ // watcher 会把 `.dsh-bridge.pid` 删掉(子进程一退出就删),而孤儿 bridge 仍活着的场景里,
1700
+ // 这条「自写证据」是唯一还成立的判据(2026-10-29 事故正是这个形态)。
1701
+ // ③ PowerShell 全量枚举 + 命令行匹配:兼容「用户跑的是没写 pid 文件的旧版运行时」。
1698
1702
  // 这条路径要起一个 powershell.exe(数百毫秒),故结果带 TTL 缓存,绝不被轮询打爆。
1703
+ //
1704
+ // 【2026-10-29 修复的三处结构性缺陷】(取证:dsh-relay-internal/2026-10-29-反馈bridge_wedged-取证/)
1705
+ // F1 旧条件 `if (!watcher.length && !bridge.length)`:**watcher pid 文件有效时永不扫**。于是
1706
+ // 「watcher 在跑 + bridge pid 文件刚被删(新 bridge 撞本地实例锁秒退时 watcher 会删它)」
1707
+ // ⇒ bridge 恒为空 ⇒ 面板「bridge 未运行 / 中继未注册」⇒ 90s 后判「守护卡死」⇒ 误重启 3 次
1708
+ // ⇒ bridge_wedged,而中继数据面全程在线。现在改成「**只要 bridge 为空就扫**」,扫描结果只补充不覆盖。
1709
+ // F2 旧扫描写死 `Name='node.exe'`:桌面端(Electron)宿主用 process.execPath 拉起 watcher,
1710
+ // 镜像名是桌面端 exe;bridge 的解释器是 NODE_BIN(可能也是同一个 exe)⇒ 两类进程都可能全漏。
1711
+ // 现在无 Name 过滤、按命令行匹配,并把 Name 一起带回(故障时一眼看出解释器是谁)。
1712
+ // F3 扫描失败与「扫到 0 个」不再混为一谈:失败时置 degraded,随 /bridge-status 下发(scanDegraded)。
1699
1713
 
1700
1714
  const WATCHER_PID_FILE = ".dsh-watcher.pid";
1701
1715
  const BRIDGE_PID_FILE = ".dsh-bridge.pid";
1716
+ /** bridge 自己写的单实例锁(必须与 dsh-bridge.mjs 的 INSTANCE_LOCK_FILE 同名)。 */
1717
+ const BRIDGE_LOCK_FILE = ".dsh-bridge-instance.lock";
1702
1718
  /** Windows 自启动任务名(与 dsh-setup.mjs 的 WIN_TASK_NAME 必须一致)。 */
1703
1719
  const WIN_TASK_NAME = "dsh-remote-bridge";
1704
1720
  const WIN_SCAN_TTL_MS = 5000;
1721
+ /**
1722
+ * pid 文件的「信任窗口」:**取不到进程命令行**时(Windows 上 PowerShell 不可用/进程不在扫描结果里;
1723
+ * POSIX 上 ps 失败),只有 pid 文件足够新才敢按「大概是我们的进程」处理。超过窗口一律当无法确认 ——
1724
+ * 宁可漏杀/漏报,也绝不对一个可能已被系统回收给无关进程的 pid 发 taskkill /T /F。
1725
+ */
1726
+ const PID_FILE_TRUST_MS = 10 * 60 * 1000;
1727
+ /**
1728
+ * 状态文件心跳的信任窗口:新版 bridge 在心跳拍里节流刷新 `.dsh-bridge-state.json` 的
1729
+ * `{pid, heartbeat_at}`(≥10s 一次,隧道心跳默认 20s;见 dsh-bridge.mjs 的 stampBridgeHeartbeat)。
1730
+ * 120s ≈ 容忍 6 拍丢失(机器休眠/事件循环卡顿)。超过窗口就认为「这份 pid 可能已被系统回收
1731
+ * 给无关进程」,此时必须靠命令行确认,绝不能只看 pid 活着就报「bridge 在跑」。
1732
+ */
1733
+ const STATE_HEARTBEAT_TRUST_MS = 120_000;
1734
+
1735
+ /**
1736
+ * 扫描结果缓存 TTL。生产恒为 WIN_SCAN_TTL_MS;`DSH_RELAY_WIN_SCAN_TTL_MS` 只给用例用
1737
+ * (0 = 不缓存),避免同一进程内多条用例被 5s 缓存互相串味 —— 与 DSH_RELAY_WEDGE_MS 同一约定。
1738
+ */
1739
+ function winScanTtlMs() {
1740
+ const v = Number(process.env.DSH_RELAY_WIN_SCAN_TTL_MS);
1741
+ return Number.isFinite(v) && v >= 0 ? v : WIN_SCAN_TTL_MS;
1742
+ }
1705
1743
 
1706
1744
  /** 读 pid 文件(内容为纯数字 pid;缺失/损坏返回 null)。 */
1707
1745
  function readPidFile(relayDir, name) {
@@ -1713,41 +1751,65 @@ function readPidFile(relayDir, name) {
1713
1751
  }
1714
1752
  }
1715
1753
 
1754
+ /** pid 文件有多旧(毫秒);读不到 = Infinity(= 完全不可信)。 */
1755
+ function pidFileAgeMs(relayDir, name) {
1756
+ try { return Date.now() - (statSync(join(relayDir, name)).mtimeMs || 0); } catch { return Infinity; }
1757
+ }
1758
+
1759
+ /** 安静读一个 JSON 证据文件(bridge 自写的锁/状态);缺失/损坏 → null。 */
1760
+ function readJsonFileQuiet(p) {
1761
+ try {
1762
+ const j = JSON.parse(readFileSync(p, "utf8"));
1763
+ return j && typeof j === "object" ? j : null;
1764
+ } catch { return null; }
1765
+ }
1766
+
1716
1767
  /**
1717
- * 用 PowerShell 列出所有 node.exe 的 pid/父 pid/命令行。
1768
+ * 用 PowerShell 列出所有**命令行里带 dsh-setup.mjs / dsh-bridge.mjs** 的进程(不再按镜像名过滤)。
1718
1769
  * 走 -EncodedCommand(UTF-16LE base64):命令行里带引号/中文/空格时不会被 PowerShell 自己的
1719
1770
  * 引号解析吃掉,也不需要经过 shell。Get-CimInstance 不可用时回退 Get-WmiObject(Win7/精简系统)。
1720
- */
1721
- let winProcScanCache = { at: 0, procs: [] };
1722
- function windowsNodeProcesses() {
1723
- if (Date.now() - winProcScanCache.at < WIN_SCAN_TTL_MS) return winProcScanCache.procs;
1771
+ *
1772
+ * @returns {{procs: Array<{pid:number, ppid:number|null, name:string, commandLine:string}>, degraded:boolean}}
1773
+ * degraded=true 表示「没扫成」(无 PowerShell / 调用超时 / JSON 解析失败)——
1774
+ * 必须与「扫到 0 个」区分开,否则诊断里会谎报「本机一个进程都没有」。
1775
+ */
1776
+ let winProcScanCache = { at: 0, procs: [], degraded: false };
1777
+ function windowsProcessScan() {
1778
+ const ttl = winScanTtlMs();
1779
+ if (ttl > 0 && Date.now() - winProcScanCache.at < ttl) return winProcScanCache;
1724
1780
  const script = [
1725
- "try { $p = Get-CimInstance Win32_Process -Filter \"Name='node.exe'\" }",
1726
- "catch { $p = Get-WmiObject Win32_Process -Filter \"Name='node.exe'\" }",
1727
- "$p | Select-Object ProcessId,ParentProcessId,CommandLine | ConvertTo-Json -Compress",
1781
+ "try { $p = Get-CimInstance Win32_Process } catch { $p = Get-WmiObject Win32_Process }",
1782
+ "$p | Where-Object { $_.CommandLine -and ($_.CommandLine -match 'dsh-setup\\.mjs' -or $_.CommandLine -match 'dsh-bridge\\.mjs') } | Select-Object ProcessId,ParentProcessId,Name,CommandLine | ConvertTo-Json -Compress",
1728
1783
  ].join("; ");
1729
1784
  const encoded = Buffer.from(script, "utf16le").toString("base64");
1730
1785
  let procs = [];
1786
+ let degraded = false;
1731
1787
  try {
1732
1788
  const r = spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-EncodedCommand", encoded], {
1733
1789
  encoding: "utf8", timeout: 8000, windowsHide: true, maxBuffer: 8 * 1024 * 1024,
1734
1790
  });
1735
1791
  const raw = String(r.stdout || "").trim();
1736
- if (raw) {
1737
- const parsed = JSON.parse(raw);
1738
- for (const p of (Array.isArray(parsed) ? parsed : [parsed])) {
1739
- const pid = Number(p && p.ProcessId);
1740
- if (!Number.isInteger(pid) || pid <= 0) continue;
1741
- procs.push({
1742
- pid,
1743
- ppid: Number(p.ParentProcessId) || null,
1744
- commandLine: String((p && p.CommandLine) || ""),
1745
- });
1746
- }
1792
+ // 非 0 退出且没有输出 = 没扫成(无 PowerShell / 被策略拒 / WMI 不可用)
1793
+ if (!raw) {
1794
+ if (r.error || (Number.isFinite(r.status) && r.status !== 0)) degraded = true;
1795
+ } else {
1796
+ try {
1797
+ const parsed = JSON.parse(raw);
1798
+ for (const p of (Array.isArray(parsed) ? parsed : [parsed])) {
1799
+ const pid = Number(p && p.ProcessId);
1800
+ if (!Number.isInteger(pid) || pid <= 0) continue;
1801
+ procs.push({
1802
+ pid,
1803
+ ppid: Number(p.ParentProcessId) || null,
1804
+ name: String((p && p.Name) || ""),
1805
+ commandLine: String((p && p.CommandLine) || ""),
1806
+ });
1807
+ }
1808
+ } catch { procs = []; degraded = true; } // 有输出但不是 JSON → 当作没扫成
1747
1809
  }
1748
- } catch { procs = []; } // 无 PowerShell / 解析失败 → 静默降级为空(不阻断状态查询)
1749
- winProcScanCache = { at: Date.now(), procs };
1750
- return procs;
1810
+ } catch { procs = []; degraded = true; } // spawnSync 抛错(ENOENT 等)→ 没扫成
1811
+ if (ttl > 0) winProcScanCache = { at: Date.now(), procs, degraded };
1812
+ return { procs, degraded };
1751
1813
  }
1752
1814
 
1753
1815
  /**
@@ -1766,35 +1828,146 @@ function windowsTaskRegistered() {
1766
1828
  return ok;
1767
1829
  }
1768
1830
 
1769
- /** Windows 版 manualStatus:pid 文件优先,PowerShell 兜底。语义与 POSIX 版一致。 */
1831
+ /* ── 进程身份校验(防 PID 复用:既防误判「在跑」,也防误杀)─────────────────────────
1832
+ *
1833
+ * pid 文件只证明「某个时刻有人写了这个 pid」,**不证明**「现在这个 pid 还是我们的进程」。
1834
+ * Windows 的 PID 会被快速回收,于是「watcher 死了 → pid 文件还在 → 该 pid 已被无关进程占用」
1835
+ * 是常态而不是异常。旧实现只做 pidAlive() 判活,后果有两个方向:
1836
+ * · 误判「守护在运行」⇒ 自愈永久 no-op(「多次修复都没解决」那一类);
1837
+ * · 更严重:stopBridgeDetached 会 taskkill /T /F 杀掉它 ⇒ **杀掉一棵与产品无关的进程树**。
1838
+ * 纪律(与 dsh-setup.mjs 的 looksLikeWatcher/looksLikeBridge 同一方向:宁可漏杀):
1839
+ * · 命令行**确认**是本 relayDir 的 dsh-setup.mjs / dsh-bridge.mjs → ident 非空(是我们的);
1840
+ * · 命令行**取到了但不是**(不指向这两个脚本 / 不指向本 relayDir)→ cmdKnown=true、ident="";
1841
+ * · 取不到命令行(Windows:不在扫描结果里或扫描 degraded;POSIX:ps 失败)→ cmdKnown=false,
1842
+ * 这一档只能用 pid 文件新鲜度兜底(PID_FILE_TRUST_MS)。
1843
+ */
1844
+ function processCommandLine(pid, procs) {
1845
+ if (!Number.isInteger(pid) || pid <= 0) return "";
1846
+ if (procs) {
1847
+ const hit = procs.find((p) => p && p.pid === pid);
1848
+ return hit ? String(hit.commandLine || "") : "";
1849
+ }
1850
+ return commandLineOfPid(pid);
1851
+ }
1852
+
1853
+ /** @returns {{ident:"watcher"|"bridge"|"", cmdKnown:boolean}} */
1854
+ function pidIdentityOf(pid, relayDir, procs) {
1855
+ const cmd = processCommandLine(pid, procs);
1856
+ if (!cmd) return { ident: "", cmdKnown: false };
1857
+ const norm = (v) => String(v).replace(/\\/g, "/").toLowerCase();
1858
+ const isBridge = /dsh-bridge\.mjs/.test(cmd);
1859
+ const isWatcher = /dsh-setup\.mjs/.test(cmd);
1860
+ if (!isBridge && !isWatcher) return { ident: "", cmdKnown: true }; // 取到了命令行:明确不是我们的脚本
1861
+ if (!norm(cmd).includes(norm(relayDir || ""))) return { ident: "", cmdKnown: true }; // 同类脚本,但不是本配置目录
1862
+ return { ident: isBridge ? "bridge" : "watcher", cmdKnown: true };
1863
+ }
1864
+
1865
+ /**
1866
+ * Windows 版 manualStatus:pid 文件 → bridge 自写证据 → PowerShell 扫描(扫描结果只补充、不覆盖)。
1867
+ *
1868
+ * 返回值里的 scanDegraded / bridgeEvidence 是 2026-10-29 事故的产物:面板/诊断必须能区分
1869
+ * 「bridge 真没有(pid 文件、锁、状态文件、扫描四路都说没有)」与「我们根本没能扫」(degraded),
1870
+ * 否则又会像那次一样把「工具看不见」当成「进程不存在」。
1871
+ */
1770
1872
  function windowsManualStatus(relayDir) {
1873
+ const selfPid = process.pid;
1771
1874
  const watcher = [];
1772
1875
  const bridge = [];
1773
1876
  const allBridge = [];
1877
+ const evidence = [];
1774
1878
  const push = (arr, pid) => { if (pid && !arr.includes(pid)) arr.push(pid); };
1879
+ const addBridge = (pid, why) => {
1880
+ if (!pid || pid === selfPid || allBridge.includes(pid)) return;
1881
+ push(bridge, pid); push(allBridge, pid); evidence.push(why);
1882
+ };
1775
1883
  const wPid = readPidFile(relayDir, WATCHER_PID_FILE);
1776
1884
  const bPid = readPidFile(relayDir, BRIDGE_PID_FILE);
1777
- if (wPid && pidAlive(wPid) !== false && wPid !== process.pid) push(watcher, wPid);
1778
- if (bPid && pidAlive(bPid) !== false && bPid !== process.pid) { push(bridge, bPid); push(allBridge, bPid); }
1779
- // 兜底扫描只在 pid 文件一无所获时进行:避免每次轮询都起一个 PowerShell。
1780
- if (!watcher.length && !bridge.length) {
1781
- for (const p of windowsNodeProcesses()) {
1782
- if (p.pid === process.pid || p.pid === wPid || p.pid === bPid) continue;
1783
- if (/dsh-setup\.mjs/.test(p.commandLine)) push(watcher, p.pid);
1784
- else if (/dsh-bridge\.mjs/.test(p.commandLine)) { push(bridge, p.pid); push(allBridge, p.pid); }
1885
+ const wAlive = Boolean(wPid && pidAlive(wPid) !== false);
1886
+ const bAlive = Boolean(bPid && pidAlive(bPid) !== false);
1887
+
1888
+ // ① bridge pid 文件(父进程 watcher 写的那份;子进程一退出它就会被删 —— 这正是事故现场的形态)
1889
+ if (bAlive && bPid !== selfPid) addBridge(bPid, "pid-file");
1890
+
1891
+ // ② bridge 自写证据(F3):进程自己写的「我在跑」比任何外部扫描都可靠,而且不需要起 PowerShell。
1892
+ // · `.dsh-bridge-instance.lock`:{pid,at,script},bridge 启动时自己写(script 即身份自证);
1893
+ // · `.dsh-bridge-state.json`:新版 bridge 写 {pid, heartbeat_at}(心跳拍 ≥10s 刷新一次)。
1894
+ // 这两条正是「父进程把 .dsh-bridge.pid 删了、孤儿 bridge 还活着」时唯一还成立的判据。
1895
+ const state = bridgeStateFile(relayDir);
1896
+ const stPid = Number(state && state.pid);
1897
+ const hbAt = Number(state && state.heartbeat_at) || 0;
1898
+ const hbFresh = hbAt > 0 && Date.now() - hbAt < STATE_HEARTBEAT_TRUST_MS;
1899
+ const stPidAlive = Number.isInteger(stPid) && stPid > 0 && stPid !== selfPid && pidAlive(stPid) !== false;
1900
+ const lock = readJsonFileQuiet(join(relayDir, BRIDGE_LOCK_FILE));
1901
+ const lockPid = Number(lock && lock.pid);
1902
+ const lockAlive = Number.isInteger(lockPid) && lockPid > 0 && lockPid !== selfPid && pidAlive(lockPid) !== false;
1903
+ const lockScriptOk = String((lock && lock.script) || "") === "dsh-bridge.mjs";
1904
+ // 心跳「已经停了」= 状态文件里有过心跳但已超信任窗口。此时锁文件里的 pid 很可能已被系统回收给
1905
+ // 无关进程(bridge 死了、锁没删),必须先拿到命令行确认 —— 否则会永久谎报「bridge 在跑」。
1906
+ const heartbeatStopped = hbAt > 0 && !hbFresh;
1907
+ let lockNeedsProof = false; // 需要命令行确认的锁候选
1908
+
1909
+ let procs = null; // null = 本次没扫描(省掉一次 PowerShell)
1910
+ let scanDegraded = false;
1911
+ if (!bridge.length) {
1912
+ if (lockAlive && lockScriptOk) {
1913
+ if (heartbeatStopped) lockNeedsProof = true; // 心跳停了 → 等扫描用命令行确认
1914
+ else addBridge(lockPid, "instance-lock"); // script 自证 + pid 活着(心跳新鲜或旧版无心跳)
1915
+ }
1916
+ if (!bridge.length && stPidAlive && hbFresh) addBridge(stPid, "state-file");
1917
+ }
1918
+
1919
+ if (!bridge.length) {
1920
+ // ③ F1:**只要 bridge 为空就扫**(不再要求 watcher 也为空)。
1921
+ // 旧条件 `if (!watcher.length && !bridge.length)` 让「watcher 在跑、bridge pid 文件刚被删」
1922
+ // 时一次都不扫 ⇒ bridge 恒为空 ⇒ 误判卡死 → 误重启 → bridge_wedged(2026-10-29 事故)。
1923
+ // F2:扫描不按镜像名过滤(Electron 宿主的 watcher/bridge 不叫 node.exe),按命令行匹配。
1924
+ const sc = windowsProcessScan();
1925
+ procs = sc.procs;
1926
+ scanDegraded = Boolean(sc.degraded);
1927
+ for (const p of procs) {
1928
+ if (!p || p.pid === selfPid) continue;
1929
+ const cmd = String(p.commandLine || "");
1930
+ if (/dsh-setup\.mjs/.test(cmd)) { push(watcher, p.pid); continue; }
1931
+ if (/dsh-bridge\.mjs/.test(cmd)) addBridge(p.pid, "scan");
1932
+ }
1933
+ // 扫描也没找到时,才允许「自写证据 + 命令行确认」这一档(宁可漏报,也不假阳性)
1934
+ if (!bridge.length && lockNeedsProof && pidIdentityOf(lockPid, relayDir, procs).ident === "bridge") {
1935
+ addBridge(lockPid, "instance-lock");
1936
+ }
1937
+ if (!bridge.length && stPidAlive && pidIdentityOf(stPid, relayDir, procs).ident === "bridge") {
1938
+ addBridge(stPid, "state-file");
1939
+ }
1940
+ }
1941
+
1942
+ // ⑤ watcher(守护)pid 文件:必须通过身份/新鲜度校验,否则**不得**报「守护在运行」——
1943
+ // 否则 PID 复用会让 watcherWedgeOf 判「守护活了 N 秒仍无 bridge」→ 误重启(甚至误杀)。
1944
+ if (wAlive && wPid !== selfPid) {
1945
+ let trusted;
1946
+ if (procs) {
1947
+ const id = pidIdentityOf(wPid, relayDir, procs);
1948
+ if (id.ident === "watcher") trusted = true;
1949
+ else if (id.ident === "bridge") { addBridge(wPid, "scan"); trusted = false; } // pid 文件被覆盖成 bridge:算 bridge 更准
1950
+ else trusted = !id.cmdKnown && pidFileAgeMs(relayDir, WATCHER_PID_FILE) < PID_FILE_TRUST_MS;
1951
+ } else {
1952
+ // bridge 已从 pid 文件/自写证据确认 ⇒ 本次没扫描。watcher 列表此时不参与任何风险决策
1953
+ // (wedge 只在「bridge 为空」时才计算),保持旧行为即可,不必为此白起一次 PowerShell。
1954
+ trusted = true;
1785
1955
  }
1956
+ if (trusted) push(watcher, wPid);
1786
1957
  }
1787
- return { watcher, bridge, allBridge };
1958
+
1959
+ return { watcher, bridge, allBridge, scanDegraded, bridgeEvidence: evidence };
1788
1960
  }
1789
1961
 
1790
1962
  /** 检查手动运行的 watcher(dsh-setup.mjs run)与 bridge 子进程(排除 launchd 托管链)。 */
1791
1963
  function manualStatus(relayDir) {
1792
1964
  if (isWindows()) return windowsManualStatus(relayDir || DEFAULT_RELAY_DIR);
1793
1965
  const launchdPid = launchdStatus().pid;
1794
- const out = (() => {
1966
+ const pg = (() => {
1795
1967
  const r = sh("pgrep -fl 'dsh-setup.mjs|dsh-bridge.mjs'");
1796
- return r.ok ? r.stdout : "";
1968
+ return { out: r.ok ? r.stdout : "", stderr: String(r.stderr || "") };
1797
1969
  })();
1970
+ const out = pg.out;
1798
1971
  const watcher = [];
1799
1972
  const bridge = [];
1800
1973
  // 全部 bridge 子进程(含 launchd 托管链里的):连接阶段判定「bridge 进程真的在跑」用它。
@@ -1823,7 +1996,15 @@ function manualStatus(relayDir) {
1823
1996
  if (!launchdManaged) bridge.push(pid); // launchd 托管的 bridge 子进程(原语义:不计入 manual.bridge)
1824
1997
  }
1825
1998
  }
1826
- return { watcher, bridge, allBridge };
1999
+ // scanDegraded/bridgeEvidence 与 Windows 版对齐(见 windowsManualStatus 的注释):
2000
+ // POSIX 上 pgrep 匹配的就是命令行本身,不存在「镜像名不对」的盲区。
2001
+ // 注意 pgrep「没匹配到」= 退出码 1 + 无输出 + 无错误输出 ⇒ 这是**有效结论(0 个)**,不是 degraded;
2002
+ // 只有 pgrep 本身不存在/执行失败(stderr 非空)才算扫描不可用。
2003
+ return {
2004
+ watcher, bridge, allBridge,
2005
+ scanDegraded: Boolean(pg.stderr && pg.stderr.trim()),
2006
+ bridgeEvidence: allBridge.length ? ["pgrep"] : [],
2007
+ };
1827
2008
  }
1828
2009
 
1829
2010
  // ---------- 插件市场一键全功能:缺桌面运行环境时自动后台安装 dsh-remote ----------
@@ -2309,10 +2490,27 @@ function startBridgeDetached(relayDir, fallbackReason = "") {
2309
2490
  detail: "桌面运行环境(bridge)尚未安装,已在后台自动安装,完成后会自动启动 bridge——请稍候刷新面板(也可点下方「一键更新」查看进度)",
2310
2491
  };
2311
2492
  }
2312
- // 幂等:watcher 已在跑就别再拉第二个(否则会有两个 bridge 抢同一个设备登记)
2313
- const existing = readPidFile(relayDir, WATCHER_PID_FILE);
2314
- if (existing && pidAlive(existing) !== false) {
2315
- return { ok: true, status: "running", pid: existing };
2493
+ // 幂等:watcher 已在跑就别再拉第二个(否则会有两个 bridge 抢同一个设备登记)。
2494
+ // ⚠️ 2026-10-29(PID 复用):pid **活着**不等于它是我们的 watcher —— pid 文件可能是上一任守护
2495
+ // 留下的,而那个 pid 早已被系统回收给无关进程。不校验就会**永久**返回「已在运行」⇒
2496
+ // 面板「启动 / 立即修复」与每轮自愈全部空转(这与「多次修复都没解决」是同一类故障)。
2497
+ const forced = takeForcedDetachedSpawn();
2498
+ if (forced) {
2499
+ // 旧守护杀不掉/被跳过结束,但本轮确实要「换掉它」:跳过幂等判断,直接拉起新实例。
2500
+ // 新实例会用自己的 pid 覆盖 pid 文件;旧实例若真活着,dsh-setup.mjs 自己的 pid 去重会拦住它自己。
2501
+ appendLogLine(relayDir, AUTO_INSTALL_LOG,
2502
+ `[dsh-remote-web] ${forced} → 强制拉起新守护(跳过「已在运行」幂等判断)`);
2503
+ }
2504
+ const existing = forced ? null : readPidFile(relayDir, WATCHER_PID_FILE);
2505
+ if (existing && existing !== process.pid && pidAlive(existing) !== false) {
2506
+ const id = pidIdentityOf(existing, relayDir);
2507
+ const fresh = pidFileAgeMs(relayDir, WATCHER_PID_FILE) < PID_FILE_TRUST_MS;
2508
+ if (id.ident || (!id.cmdKnown && fresh)) return { ok: true, status: "running", pid: existing };
2509
+ appendLogLine(relayDir, AUTO_INSTALL_LOG,
2510
+ `[dsh-remote-web] watcher pid 文件里的 pid=${existing} `
2511
+ + `${id.cmdKnown ? "不是本运行环境的进程(PID 可能已被系统回收)" : "无法确认归属且 pid 文件已超 10 分钟未更新"}`
2512
+ + " → 按过期处理,重新拉起守护");
2513
+ removePidFileQuiet(relayDir, WATCHER_PID_FILE);
2316
2514
  }
2317
2515
  const setup = join(relayDir, "dsh-setup.mjs");
2318
2516
  if (!existsSync(setup)) return { ok: false, status: "not-installed", detail: `运行环境入口脚本不存在(${setup})` };
@@ -2360,20 +2558,85 @@ function startBridgeDetached(relayDir, fallbackReason = "") {
2360
2558
  };
2361
2559
  }
2362
2560
 
2561
+ /* ── F4:动进程之前先验身份(2026-10-29 修复 PID 复用误杀)────────────────────────
2562
+ *
2563
+ * 旧实现 `stopBridgeDetached` 对 pid 文件里的 pid 只做 pidAlive() 判活,然后直接
2564
+ * `killPid()`(Windows = `taskkill /PID <pid> /T /F`)。pid 一旦被系统回收给无关进程,
2565
+ * 这条路径会**杀掉一棵与产品无关的进程树**。现在按「身份 / 新鲜度」两级决定:
2566
+ * · ident 非空(命令行确认是本 relayDir 的 dsh-setup.mjs / dsh-bridge.mjs)→ 结束它;
2567
+ * · cmdKnown=true 但 ident=""(取到了命令行、明确不是我们的)→ **不结束**,只清残留 pid 文件;
2568
+ * · 取不到命令行 → 只有 pid 文件在 PID_FILE_TRUST_MS 内才敢结束,否则跳过并写一行安装日志。
2569
+ * pid 文件的删除同样收紧:**只有「进程已确认结束」或「已确认不是我们的进程」才删**,
2570
+ * 不再无条件先删(那会让「进程还在、现场没了」成为常态,正是这次事故取证困难的原因之一)。
2571
+ */
2572
+ function pidFileVerdict(relayDir, name) {
2573
+ const pid = readPidFile(relayDir, name);
2574
+ if (!pid) return { name, pid: null, alive: false, ident: "", cmdKnown: false, ageMs: Infinity, fresh: false };
2575
+ const alive = pidAlive(pid) !== false;
2576
+ const ageMs = pidFileAgeMs(relayDir, name);
2577
+ const id = alive ? pidIdentityOf(pid, relayDir) : { ident: "", cmdKnown: false };
2578
+ return { name, pid, alive, ident: id.ident, cmdKnown: id.cmdKnown, ageMs, fresh: ageMs < PID_FILE_TRUST_MS };
2579
+ }
2580
+
2581
+ function removePidFileQuiet(relayDir, name) {
2582
+ try { rmSync(join(relayDir, name), { force: true }); } catch { /* 非关键 */ }
2583
+ }
2584
+
2585
+ /* 「重启卡死守护」专用的一次性强制拉起标记。
2586
+ *
2587
+ * 场景:pid 文件里的守护**杀不掉**(pid 已被系统回收给无关进程 / kill 工具不可用 / 按安全纪律
2588
+ * 主动跳过)。这时 `startBridge` 的幂等判断会因「pid 文件还在且新鲜」再次返回「已在运行」→
2589
+ * 重启自愈变成永久 no-op(本次要根治的死循环换个形态回来)。所以由调用方显式下一个一次性标记,
2590
+ * 让紧接着的那次 `startBridgeDetached` 跳过幂等判断、直接拉起新实例。
2591
+ * 用模块级标记而不是改 `startBridge(relayDir)` 签名:那两处签名被源码契约用例按字面锁定。
2592
+ */
2593
+ let forcedDetachedSpawn = "";
2594
+ function forceNextDetachedSpawn(reason) { forcedDetachedSpawn = String(reason || "restart"); }
2595
+ function takeForcedDetachedSpawn() { const r = forcedDetachedSpawn; forcedDetachedSpawn = ""; return r; }
2596
+
2363
2597
  /** 停止「脱离进程」方式运行的 bridge(结束 watcher 与其 bridge 子进程,并清掉 pid 文件)。 */
2364
2598
  function stopBridgeDetached(relayDir, opts = {}) {
2365
2599
  // 账号切换时用 force:即使处于测试隔离模式也要把进程停掉(否则"换个账号"在测试里走不通),
2366
2600
  // 但隔离模式下仍只用信号结束**本插件记录的** pid,不碰任何系统服务。
2367
2601
  if (skipsSystemOps() && !opts.force) return { ok: false, status: "skipped", detail: "测试隔离(DSH_RELAY_SKIP_SERVICE=1):跳过真实服务操作" };
2368
- const targets = [];
2369
- for (const name of [WATCHER_PID_FILE, BRIDGE_PID_FILE]) {
2370
- const pid = readPidFile(relayDir, name);
2371
- if (pid && pidAlive(pid) !== false && !targets.includes(pid)) targets.push(pid);
2602
+ const decisions = [WATCHER_PID_FILE, BRIDGE_PID_FILE].map((name) => pidFileVerdict(relayDir, name));
2603
+ const killed = [];
2604
+ const skipped = [];
2605
+ for (const d of decisions) {
2606
+ if (!d.pid) { removePidFileQuiet(relayDir, d.name); continue; } // 文件里没有有效 pid:无主残留
2607
+ if (!d.alive) { removePidFileQuiet(relayDir, d.name); continue; } // 已确认结束 → 删(安全)
2608
+ if (d.ident) {
2609
+ killPid(d.pid, "SIGKILL"); // Windows 上是 taskkill /T /F:连带子进程
2610
+ killed.push(d.pid);
2611
+ if (pidAlive(d.pid) === false) removePidFileQuiet(relayDir, d.name); // 只有确认结束才删
2612
+ continue;
2613
+ }
2614
+ if (d.cmdKnown) {
2615
+ // 取到了命令行、明确不是我们的进程(别的配置目录/别的脚本):不结束,只清残留文件
2616
+ appendLogLine(relayDir, AUTO_INSTALL_LOG,
2617
+ `[dsh-remote-web] ${d.name} 里的 pid=${d.pid} 不是本运行环境的进程(PID 可能已被系统回收),`
2618
+ + "不结束它,按残留清理 pid 文件");
2619
+ removePidFileQuiet(relayDir, d.name);
2620
+ continue;
2621
+ }
2622
+ // 取不到命令行:只有 pid 文件足够新才敢认为它是我们的进程;否则宁可漏杀
2623
+ if (d.fresh) {
2624
+ killPid(d.pid, "SIGKILL");
2625
+ killed.push(d.pid);
2626
+ if (pidAlive(d.pid) === false) removePidFileQuiet(relayDir, d.name); // 只有确认结束才删
2627
+ continue;
2628
+ }
2629
+ skipped.push(d);
2372
2630
  }
2373
- for (const pid of targets) killPid(pid, "SIGKILL");
2374
- for (const name of [WATCHER_PID_FILE, BRIDGE_PID_FILE]) {
2375
- try { rmSync(join(relayDir, name), { force: true }); } catch { /* 非关键 */ }
2631
+ for (const d of skipped) {
2632
+ appendLogLine(relayDir, AUTO_INSTALL_LOG,
2633
+ `[dsh-remote-web] 无法确认 pid 归属,跳过结束(避免误杀):${d.name} pid=${d.pid}`
2634
+ + `(pid 文件已 ${Math.max(1, Math.round((d.ageMs || 0) / 60000))} 分钟未更新,且取不到该进程的命令行)`);
2376
2635
  }
2636
+ // 本轮「没能结束掉」的 pid(尝试过但杀不掉,或按安全纪律主动跳过):
2637
+ // 调用方(尤其是「重启卡死守护」那条自愈)用它决定要不要强制拉起新实例 ——
2638
+ // 否则「pid 文件里那个 pid 杀不掉且仍然新鲜」会让重启变成永久 no-op。
2639
+ const unresolvedPids = [...killed, ...skipped.map((d) => d.pid)].filter((pid) => pidAlive(pid) !== false);
2377
2640
  // 复核:pid 文件之外可能还有用户手动跑起来、或用旧版运行时起的进程
2378
2641
  const manual = manualStatus(relayDir);
2379
2642
  const still = [...new Set([...manual.watcher, ...manual.bridge])];
@@ -2381,7 +2644,12 @@ function stopBridgeDetached(relayDir, opts = {}) {
2381
2644
  ok: still.length === 0,
2382
2645
  status: still.length ? "failed" : "stopped",
2383
2646
  pid: null,
2384
- detail: still.length ? `仍有 ${still.length} 个 bridge 相关进程未结束(pid ${still.join(", ")})` : void 0,
2647
+ killed,
2648
+ skippedPids: skipped.map((d) => d.pid),
2649
+ unresolvedPids,
2650
+ detail: still.length
2651
+ ? `仍有 ${still.length} 个 bridge 相关进程未结束(pid ${still.join(", ")})`
2652
+ : (skipped.length ? "已结束可确认归属的进程;无法确认归属的 pid 已跳过(避免误杀)" : void 0),
2385
2653
  };
2386
2654
  }
2387
2655
 
@@ -4416,7 +4684,8 @@ function spawnUpdater(relayDir, extraEnv, onError, spec = UPDATE_SPEC) {
4416
4684
  function commandLineOfPid(pid) {
4417
4685
  if (!Number.isInteger(pid) || pid <= 0) return "";
4418
4686
  if (isWindows()) {
4419
- const hit = windowsNodeProcesses().find((p) => p.pid === pid);
4687
+ // 2026-10-29:改走「无镜像名过滤」的全量扫描(旧的 Name='node.exe' 过滤看不到 Electron 宿主的进程)。
4688
+ const hit = windowsProcessScan().procs.find((p) => p.pid === pid);
4420
4689
  return hit ? String(hit.commandLine || "") : "";
4421
4690
  }
4422
4691
  const r = sh(`ps -p ${pid} -o command=`);
@@ -4453,8 +4722,11 @@ function stopBridgeForRuntimeReplace(relayDir) {
4453
4722
  if (skipsSystemOps()) return { skipped: true, pids: [] };
4454
4723
  const pids = collectRuntimePids(relayDir).filter((pid) => pidAlive(pid) !== false && pidRunsFrom(pid, relayDir));
4455
4724
  for (const pid of pids) killPid(pid, "SIGKILL"); // Windows 上是 taskkill /T /F:连带子进程
4725
+ // 2026-10-29:pid 文件只在「已确认结束」或「已确认不是我们的进程」时才删;
4726
+ // 取不到命令行且进程仍活着的条目保持现场(宁可留残留文件,也不制造「进程在、文件没了」的假象)。
4456
4727
  for (const name of [WATCHER_PID_FILE, BRIDGE_PID_FILE]) {
4457
- try { rmSync(join(relayDir, name), { force: true }); } catch { /* 非关键 */ }
4728
+ const v = pidFileVerdict(relayDir, name);
4729
+ if (!v.pid || !v.alive || v.cmdKnown) removePidFileQuiet(relayDir, name);
4458
4730
  }
4459
4731
  return { skipped: false, pids };
4460
4732
  }
@@ -5448,6 +5720,11 @@ async function composeConnect(relayDir, opts = {}) {
5448
5720
  const bridgeRunning = Boolean(launchd.running || bridgePids.length > 0 || (manual.bridge || []).length > 0);
5449
5721
  const watcherPids = Array.isArray(manual.watcher) ? manual.watcher : [];
5450
5722
  const watcherRunning = Boolean(!bridgeRunning && watcherPids.length > 0);
5723
+ // F5(2026-10-29):进程发现的「结论」与「工具怎么得出这个结论的」必须一起下发 ——
5724
+ // 那次事故里面板只有一句「bridge 未运行」,看不出是「扫描不可用(工具看不见)」还是
5725
+ // 「四路证据都说没有(真没有)」,于是排查方向被带偏。scanDegraded/bridgeEvidence 就是答案。
5726
+ const scanDegraded = Boolean(manual.scanDegraded);
5727
+ const bridgeEvidence = Array.isArray(manual.bridgeEvidence) ? manual.bridgeEvidence : [];
5451
5728
  const wedge = watcherWedgeOf(relayDir, { watcherRunning, bridgeRunning, now });
5452
5729
  const rateLimitedUntil = loginRateLimitedUntil(relayDir);
5453
5730
  const bindError = readBindError(relayDir);
@@ -5648,6 +5925,11 @@ async function composeConnect(relayDir, opts = {}) {
5648
5925
  bridgeRunning,
5649
5926
  accountCurrent, // 运行中的 bridge 是否属于当前账号(false = 上一个账号的进程还在跑)
5650
5927
  bridgePids,
5928
+ // 进程发现的「证据来源」与「扫描是否可用」(F5,2026-10-29):
5929
+ // bridgeEvidence ∈ {pid-file, instance-lock, state-file, scan, pgrep},
5930
+ // scanDegraded=true 表示这台机器上插件根本没能扫进程(这时「bridge 未运行」没有说服力)。
5931
+ bridgeEvidence,
5932
+ scanDegraded,
5651
5933
  // 守护(watcher)与上游:这两项以前完全不下发,于是「守护活着但拉不起 bridge」
5652
5934
  // 与「上游端口不对」在面板上都长得跟「正在启动」一模一样(用户反馈就是这么来的)。
5653
5935
  watcherPids,
@@ -5771,6 +6053,11 @@ function buildConnectDiagnostics(relayDir, conn, log) {
5771
6053
  ? ""
5772
6054
  : `运行环境缺文件: ${conn.runtimeMissing.slice(0, 8).join(", ")}${conn.runtimeMissing.length > 8 ? ` 等 ${conn.runtimeMissing.length} 个` : ""}`,
5773
6055
  `bridge 进程: ${conn.bridgeRunning ? "在运行" : "未运行"}(pid=${conn.bridgePids && conn.bridgePids.length ? conn.bridgePids.join(",") : "-"},launchd state=${conn.launchdState || "-"},崩溃循环=${conn.launchdCrashLoop ? "是" : "否"})`,
6056
+ // 进程发现的证据来源(F5,2026-10-29):没有这一行时,「bridge 未运行」到底是「真没有」还是
6057
+ // 「工具没看见」在诊断里无法区分 —— 上一次事故就是这么被带偏的。
6058
+ `进程发现: ${conn.scanDegraded
6059
+ ? "⚠️ 进程扫描不可用(PowerShell 调用失败),本次结论只来自 pid 文件/自写证据 —— 「未运行」不一定是真的没跑"
6060
+ : "扫描可用"}|bridge 证据: ${conn.bridgeEvidence && conn.bridgeEvidence.length ? conn.bridgeEvidence.join("/") : "无(pid 文件、实例锁、状态文件、扫描都没有活着的 bridge)"}`,
5774
6061
  `后台守护: ${conn.watcherRunning ? `在运行(pid=${conn.watcherPids.join(",")}${conn.watcherAgeMs ? `,已 ${Math.round(conn.watcherAgeMs / 1000)} 秒` : ""}${conn.watcherWedged ? ",⚠️ 迟迟未拉起 bridge" : ""}${conn.watcherRestarts ? `,已自动重启 ${conn.watcherRestarts} 次` : ""})` : "未运行"}`,
5775
6062
  // 只在真的重启过 bridge 时才出现这一行(正常用户零新增文案,不制造噪音):
5776
6063
  // 它回答的是"插件自己有没有在反复重启 bridge"—— 2026-10-13 那个故障里,这个问题
@@ -6011,7 +6298,14 @@ function ensureConnection(relayDir, opts = {}) {
6011
6298
  // 这两种都必须**显式结束卡死的守护**,否则 startBridge 幂等会直接返回、重启变成 no-op。
6012
6299
  // macOS 走 launchctl,startBridge 内部已先 bootout 再 bootstrap,等价于重启。
6013
6300
  // ★ 0.6.15:以前这里把 Linux 排除在外(那时 Linux 的 startBridge 是死路,重启没意义)。
6014
- if (isWindows() || (isLinux() && !systemdUserAvailableCached())) stopBridgeDetached(relayDir);
6301
+ // ★ 2026-10-29:如果那个守护**杀不掉**(pid 已被系统回收给无关进程 / kill 工具不可用 /
6302
+ // 按安全纪律主动跳过),`startBridge` 的幂等判断会因「pid 文件还在且新鲜」再次返回
6303
+ // 「已在运行」→ 重启变成永久 no-op(就是本次要根治的死循环)。此时必须**强制拉起**:
6304
+ // 新 watcher 会用自己的 pid 覆盖 pid 文件;旧实例若真活着,dsh-setup.mjs 自己的 pid 去重会拦。
6305
+ let stopInfo = null;
6306
+ if (isWindows() || (isLinux() && !systemdUserAvailableCached())) stopInfo = stopBridgeDetached(relayDir);
6307
+ const unresolved = (stopInfo && Array.isArray(stopInfo.unresolvedPids)) ? stopInfo.unresolvedPids : [];
6308
+ if (unresolved.length) forceNextDetachedSpawn(`watcher pid 未能结束(pid ${unresolved.join(", ")})`);
6015
6309
  const r = startBridge(relayDir);
6016
6310
  appendLogLine(relayDir, AUTO_INSTALL_LOG,
6017
6311
  `[dsh-remote-web] 后台守护已存活 ${Math.round(wedge.ageMs / 1000)} 秒仍无 bridge 进程`
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-web",
3
- "version": "0.7.7",
3
+ "version": "0.7.9-beta.1",
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",
@@ -253,7 +253,14 @@ function clearBindError() {
253
253
  * bridge 运行状态文件:供 dsh web 插件面板判定「设备是否已在中继注册成功」(连接阶段 online)。
254
254
  * 面板用它把「bridge 进程在跑」与「设备已注册、手机端真的能用」区分开——后者才是用户关心的状态,
255
255
  * 只按进程存活判断会谎报可用。
256
- * { device_id, started_at, account_bound_at, tunnel_registered_at, phase, last_error? }
256
+ * { pid, heartbeat_at, device_id, started_at, account_bound_at, tunnel_registered_at, phase, last_error? }
257
+ *
258
+ * `pid` / `heartbeat_at` 是 **bridge 自证存活**的第二判据(2026-10 `bridge_wedged` 事故):
259
+ * · 进程启动时写一次 `pid: process.pid`;
260
+ * · 心跳拍里节流刷新 `heartbeat_at`(≥10s 一次,失败静默)。
261
+ * 插件侧可以据此用「state.pid 活着 + heartbeat 新鲜」判断桥是否在跑,**不必**依赖
262
+ * 按镜像名(node.exe)扫描 —— Electron 宿主拉起的进程名压根不是 node.exe,扫描会漏。
263
+ *
257
264
  * 每次进程启动都 reset 一次:上一轮进程的成功记录不能代表当前这一轮。
258
265
  */
259
266
  const BRIDGE_STATE_FILE = path.join(path.dirname(CONFIG_PATH), ".dsh-bridge-state.json");
@@ -268,6 +275,22 @@ function persistBridgeState(patch, opts = {}) {
268
275
  } catch { /* 非关键:面板侧还有日志/账号兜底判据 */ }
269
276
  }
270
277
 
278
+ /**
279
+ * 状态文件心跳:把「我这一秒确实还活着」写进 `.dsh-bridge-state.json`(节流 ≥10s)。
280
+ *
281
+ * 为什么要节流:心跳拍的默认间隔是 20s(TUNNEL_HEARTBEAT_MS),但 RTT 好时自适应会缩短;
282
+ * 每拍都整文件重写既没必要(面板轮询 2~3s,10s 粒度足够"新鲜")又增加与读侧撞车的机会。
283
+ * 失败一律静默:状态文件只是**附加**判据,写不进去不得影响隧道(旧行为保持)。
284
+ */
285
+ const STATE_HEARTBEAT_MIN_MS = 10_000;
286
+ let lastStateHeartbeatAt = 0;
287
+ function stampBridgeHeartbeat() {
288
+ const now = Date.now();
289
+ if (now - lastStateHeartbeatAt < STATE_HEARTBEAT_MIN_MS) return;
290
+ lastStateHeartbeatAt = now;
291
+ persistBridgeState({ pid: process.pid, heartbeat_at: now });
292
+ }
293
+
271
294
  // ---------- 安装来源 / 版本(服务端设备行统计约定) ----------
272
295
  // DSH_BRIDGE_INSTALL_SOURCE 由一键安装器(dsh-setup.mjs)注入 = npx;未设置表示这台电脑走的是
273
296
  // 「插件市场装面板插件 + 插件自愈补装运行环境」那条路径 = plugin_market;白名单外一律 unknown。
@@ -2485,6 +2508,10 @@ function connectTunnel(token) {
2485
2508
  // 事件循环延迟逐拍自适应(见 adaptiveHeartbeat 长注释)。
2486
2509
  const beat = () => {
2487
2510
  heartbeat = null;
2511
+ // 自证存活:每一拍(节流 ≥10s)刷新状态文件里的 pid/heartbeat_at —— 插件用它做
2512
+ // 「state.pid 活着 + heartbeat 新鲜」的第二判据,不依赖按镜像名扫描进程。
2513
+ // 放在判定之前:即使这一拍即将判死重连,只要进程还活着,状态文件就该是新鲜的。
2514
+ stampBridgeHeartbeat();
2488
2515
  const plan = adaptiveHeartbeat({
2489
2516
  pongReceived,
2490
2517
  missedPongs,
@@ -2956,8 +2983,10 @@ async function runTunnel() {
2956
2983
  // (真机日志实测:同一个桥接启动里出现两条并发的 device-login)。
2957
2984
  // 而 /api/device-login 是按出口 IP 限流的 —— 每次启动都多花一份登录额度没有道理。
2958
2985
  WECHAT_TIER_TOKEN = { value: token, at: Date.now() };
2959
- // 新进程开始:重置状态文件(上一轮的成功记录不能代表这一轮,面板据此判定 online)
2960
- persistBridgeState({ device_id: DEVICE_ID, started_at: Date.now(), phase: "connecting", last_error: null }, { reset: true });
2986
+ // 新进程开始:重置状态文件(上一轮的成功记录不能代表这一轮,面板据此判定 online)。
2987
+ // `pid` 一并写入 = **bridge 自证身份**:插件可以用「state.pid 活着 + heartbeat_at 新鲜」
2988
+ // 判断桥是否真的在跑,不必依赖按镜像名(node.exe)扫描 —— 桌面端宿主拉起的进程名不是 node.exe。
2989
+ persistBridgeState({ pid: process.pid, heartbeat_at: Date.now(), device_id: DEVICE_ID, started_at: Date.now(), phase: "connecting", last_error: null }, { reset: true });
2961
2990
  await initE2ee(token);
2962
2991
  const reg = await registerDeviceInAccount(token);
2963
2992
  if (reg && reg.status === "locked") {