dsh-remote-web 0.7.3-beta.1 → 0.7.3-beta.3

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 CHANGED
@@ -317,7 +317,10 @@ function hardenFile(file) {
317
317
  /** 执行 shell 命令,不抛异常,返回 { ok, stdout, stderr, code }。 */
318
318
  function sh(cmd) {
319
319
  try {
320
- const stdout = execSync(cmd, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 15000 });
320
+ // windowsHide(C1 黑窗不变量):Windows 上 execSync 经 cmd.exe 起控制台程序时,
321
+ // 若宿主(dsh web / 桌面端)没有控制台,系统会给子进程分配一个可见窗口。
322
+ // POSIX 上该选项被忽略。
323
+ const stdout = execSync(cmd, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 15000, windowsHide: true });
321
324
  return { ok: true, stdout: String(stdout ?? ""), stderr: "", code: 0 };
322
325
  } catch (e) {
323
326
  return { ok: false, stdout: String(e.stdout ?? ""), stderr: String(e.stderr ?? ""), code: e.status ?? -1 };
@@ -532,7 +535,9 @@ function safeSpawn(command, args, opts = {}) {
532
535
  const { onError, ...spawnOpts } = opts;
533
536
  let child;
534
537
  try {
535
- child = spawn(command, args, spawnOpts);
538
+ // C1:默认 windowsHide(调用方显式传 false 才会覆盖)。插件派生的每一个子进程都是
539
+ // "后台辅助进程",Windows 上绝不该出现可见控制台窗口(诊断报告 04 的 W1 黑窗)。
540
+ child = spawn(command, args, { windowsHide: true, ...spawnOpts });
536
541
  } catch (e) {
537
542
  return { child: null, error: e };
538
543
  }
@@ -553,7 +558,8 @@ function spawnAndConfirm(command, args, opts = {}) {
553
558
  return new Promise((resolve) => {
554
559
  let child;
555
560
  try {
556
- child = spawn(command, args, opts);
561
+ // C1:同 safeSpawn,默认 windowsHide(Windows 上不弹控制台)。
562
+ child = spawn(command, args, { windowsHide: true, ...opts });
557
563
  } catch (e) {
558
564
  resolve({ child: null, error: e });
559
565
  return;
@@ -2325,6 +2331,12 @@ function startBridgeDetached(relayDir, fallbackReason = "") {
2325
2331
  // 上游端口**必须**透传:watcher 用它判断「dsh web 在线吗」,bridge 用它做转发目标。
2326
2332
  // 不传的话两边都回落到写死的 3080 —— dsh web 不在默认端口时 bridge 永远不启动(0.6.12 修)。
2327
2333
  DSH_BRIDGE_UPSTREAM: upstreamUrl(),
2334
+ // C7:**必须显式**把 relayDir 传给 watcher。
2335
+ // 旧实现只靠 spawnEnv 继承宿主的 DSH_RELAY_DIR(第 7800+ 行:relayDir 也可能来自
2336
+ // config.relayDir),于是"relayDir 来自配置、宿主环境没有该变量"时,watcher/bridge 会
2337
+ // 回落到 ~/.dsh-remote —— 面板看到的现象是「守护在跑、bridge 永远不出现」(1415 那类故障),
2338
+ // 而且 pid 文件/日志也都写去了另一个目录。
2339
+ DSH_RELAY_DIR: relayDir,
2328
2340
  }),
2329
2341
  stdio: ["ignore", out, out],
2330
2342
  windowsHide: true,
@@ -4220,6 +4232,93 @@ function spawnUpdater(relayDir, extraEnv, onError, spec = UPDATE_SPEC) {
4220
4232
  });
4221
4233
  }
4222
4234
 
4235
+ /* ── C8:升级/更新「覆盖运行时之前停旧进程 + 装完按命令行清残留」───────────────────
4236
+ *
4237
+ * 为什么必须停(诊断报告 04 §五 R1):安装器的 ensureRuntimeCopy() 会把 clients/ 与
4238
+ * node_modules/ws **覆盖式**拷进运行时目录,而旧 watcher/bridge 正在用这些文件跑。
4239
+ * Windows 上杀软持有句柄 → 覆盖写失败 → 半装 → bridge `ERR_MODULE_NOT_FOUND: Cannot find
4240
+ * package 'ws'`;同时旧 bridge 成孤儿、与新起的一份同 deviceId 互踢。
4241
+ *
4242
+ * 安全纪律(这套代码在**开发者的真机上**被测试执行,误杀/误停是不可接受的):
4243
+ * · 只停「命令行里带**本 relayDir** 路径」的进程 —— 别的配置目录、别的用户的 bridge 一律不碰;
4244
+ * · 取不到命令行(ps/PowerShell 不可用)→ 不停(漏停由安装器自己接管处理,方向选安全侧);
4245
+ * · 走 stopBridgeDetached/manualStatus 那条路(pid 文件 + 命令行扫描),**不碰 launchd/systemd**
4246
+ * —— 服务本身由安装器收尾时重写并重启,插件这一侧只负责"别让旧代码继续跑"。
4247
+ */
4248
+
4249
+ /** 该 pid 的完整命令行(Windows 复用带 TTL 缓存的 PowerShell 扫描;取不到返回空串)。 */
4250
+ function commandLineOfPid(pid) {
4251
+ if (!Number.isInteger(pid) || pid <= 0) return "";
4252
+ if (isWindows()) {
4253
+ const hit = windowsNodeProcesses().find((p) => p.pid === pid);
4254
+ return hit ? String(hit.commandLine || "") : "";
4255
+ }
4256
+ const r = sh(`ps -p ${pid} -o command=`);
4257
+ return r.ok ? String(r.stdout || "").trim() : "";
4258
+ }
4259
+
4260
+ /** 这个 pid 是否真的在跑**本 relayDir** 里的脚本(命令行校验;取不到 → false)。 */
4261
+ function pidRunsFrom(pid, relayDir) {
4262
+ const cmd = commandLineOfPid(pid);
4263
+ if (!cmd) return false; // 看不清就不动:误杀无关进程的代价远高于漏停
4264
+ const norm = (v) => String(v).replace(/\\/g, "/").toLowerCase();
4265
+ return norm(cmd).includes(norm(relayDir));
4266
+ }
4267
+
4268
+ /** 收集本 relayDir 这套运行时的 watcher/bridge pid(pid 文件 + 命令行扫描)。 */
4269
+ function collectRuntimePids(relayDir) {
4270
+ const pids = new Set();
4271
+ for (const name of [WATCHER_PID_FILE, BRIDGE_PID_FILE]) {
4272
+ const pid = readPidFile(relayDir, name);
4273
+ if (Number.isInteger(pid) && pid > 0 && pid !== process.pid) pids.add(pid);
4274
+ }
4275
+ const ms = manualStatus(relayDir) || {};
4276
+ for (const pid of [...(ms.watcher || []), ...(ms.bridge || []), ...(ms.allBridge || [])]) {
4277
+ if (Number.isInteger(pid) && pid > 0 && pid !== process.pid) pids.add(pid);
4278
+ }
4279
+ return [...pids];
4280
+ }
4281
+
4282
+ /**
4283
+ * 覆盖运行时之前停掉本 relayDir 的旧 watcher/bridge(幂等)。
4284
+ * @returns {{skipped: boolean, pids: number[]}}
4285
+ */
4286
+ function stopBridgeForRuntimeReplace(relayDir) {
4287
+ if (skipsSystemOps()) return { skipped: true, pids: [] };
4288
+ const pids = collectRuntimePids(relayDir).filter((pid) => pidAlive(pid) !== false && pidRunsFrom(pid, relayDir));
4289
+ for (const pid of pids) killPid(pid, "SIGKILL"); // Windows 上是 taskkill /T /F:连带子进程
4290
+ for (const name of [WATCHER_PID_FILE, BRIDGE_PID_FILE]) {
4291
+ try { rmSync(join(relayDir, name), { force: true }); } catch { /* 非关键 */ }
4292
+ }
4293
+ return { skipped: false, pids };
4294
+ }
4295
+
4296
+ /**
4297
+ * 装完清残留:只处理**本轮更新前就在跑**的那批 pid,且仍满足命令行校验。
4298
+ * 为什么不用"扫所有本 relayDir 的 bridge":npx 安装器的收尾会重启服务并拉起**新的** bridge,
4299
+ * 那一个是新代码、必须留下;按"更新前的 pid 集合"点名清理才不会误杀新实例。
4300
+ * @returns {number[]} 被结束的 pid
4301
+ */
4302
+ function sweepBridgeAfterRuntimeReplace(relayDir, beforePids = []) {
4303
+ if (skipsSystemOps() || !Array.isArray(beforePids) || !beforePids.length) return [];
4304
+ const killed = [];
4305
+ for (const pid of beforePids) {
4306
+ if (pidAlive(pid) === false) continue;
4307
+ if (!pidRunsFrom(pid, relayDir)) continue; // pid 可能已被系统回收 → 必须重新校验命令行
4308
+ if (killPid(pid, "SIGKILL")) killed.push(pid);
4309
+ }
4310
+ return killed;
4311
+ }
4312
+
4313
+ /** 更新失败/取消时把 bridge 恢复起来(否则用户会莫名失去远程访问)。只在本轮真停过进程时执行。 */
4314
+ function restoreBridgeAfterUpdate(relayDir, stoppedPids = []) {
4315
+ if (skipsSystemOps() || !stoppedPids.length) return null;
4316
+ try {
4317
+ const r = startBridgeDetached(relayDir);
4318
+ return r;
4319
+ } catch { return null; }
4320
+ }
4321
+
4223
4322
  /**
4224
4323
  * 后台执行在线一键更新:npx 按 dist-tag(默认 latest)(幂等自愈:补运行环境/更新 bridge/收敛 include)。
4225
4324
  * 稳健性:
@@ -4230,6 +4329,8 @@ function spawnUpdater(relayDir, extraEnv, onError, spec = UPDATE_SPEC) {
4230
4329
  * - marker 记录 pid,子进程退出/出错即清理;宿主重启后由 sweepStaleMarkers 立即清掉死进程残留。
4231
4330
  */
4232
4331
  async function runOnlineUpdate(relayDir) {
4332
+ /** C8:本轮更新前停掉的旧进程 pid(失败时要恢复;成功时要清残留)。 */
4333
+ let stopInfo = { skipped: true, pids: [] };
4233
4334
  try {
4234
4335
  mkdirSync(relayDir, { recursive: true });
4235
4336
  const marker = join(relayDir, UPDATE_MARKER);
@@ -4256,6 +4357,30 @@ async function runOnlineUpdate(relayDir) {
4256
4357
  `[update] 开始在线更新 ${spec}(通道 ${UPDATE_TAG};${target.why})(${new Date().toISOString()})`);
4257
4358
  telemetryRecord(relayDir, "update_started"); // 匿名遥测:在线更新开始
4258
4359
 
4360
+ // C8:**覆盖运行时之前**先停掉本 relayDir 的旧 watcher/bridge(幂等)。
4361
+ // 安装器随后会覆盖写 clients/ 与 node_modules/ws —— 旧进程一边跑一边被覆盖,
4362
+ // Windows 上极易半装(`Cannot find package 'ws'`)并留下孤儿进程新老互踢。
4363
+ stopInfo = stopBridgeForRuntimeReplace(relayDir);
4364
+ appendLogLine(relayDir, UPDATE_LOG, stopInfo.skipped
4365
+ ? "[update] 测试隔离:跳过「停旧 bridge」(DSH_RELAY_SKIP_SERVICE=1)"
4366
+ : (stopInfo.pids.length
4367
+ ? `[update] 覆盖运行时前已停旧 bridge/watcher(pid ${stopInfo.pids.join(", ")})`
4368
+ : "[update] 覆盖运行时前检查:本 relayDir 没有旧 bridge/watcher 在跑"));
4369
+ /** 失败/取消:把刚停掉的 bridge 拉回来(只在本轮真停过进程时执行,避免测试里碰真实服务)。 */
4370
+ const restoreIfStopped = () => {
4371
+ if (!stopInfo.pids.length) return;
4372
+ const r = restoreBridgeAfterUpdate(relayDir, stopInfo.pids);
4373
+ appendLogLine(relayDir, UPDATE_LOG,
4374
+ `[update] 已重新拉起 bridge(${(r && r.status) || "unknown"}),远程访问不受影响`);
4375
+ };
4376
+ /** 成功:按"更新前那批 pid"点名清残留(保持命令行校验,绝不误杀安装器刚拉起的新实例)。 */
4377
+ const sweepIfSucceeded = () => {
4378
+ const killed = sweepBridgeAfterRuntimeReplace(relayDir, stopInfo.pids);
4379
+ if (killed.length) {
4380
+ appendLogLine(relayDir, UPDATE_LOG, `[update] 装完清残留:已结束旧 bridge 进程(pid ${killed.join(", ")})`);
4381
+ }
4382
+ };
4383
+
4259
4384
  let retried = false;
4260
4385
  let stalled = false; // 本轮是否因"长时间无输出"被判定卡住(决定失败归因与是否回退)
4261
4386
  const clear = () => {
@@ -4270,6 +4395,7 @@ async function runOnlineUpdate(relayDir) {
4270
4395
  const fc = telemetryFailCodeFromError(e);
4271
4396
  noteUpdateFailure(relayDir, e.message, fc); // ← 面板可查询,不再只躺在日志里
4272
4397
  telemetryRecord(relayDir, "update_failed", { fail_code: fc });
4398
+ restoreIfStopped(); // C8:更新没起来 → 把刚停掉的 bridge 拉回来,别让用户失去远程访问
4273
4399
  };
4274
4400
  /**
4275
4401
  * 第三跳:离线通道(0.6.12)。
@@ -4319,6 +4445,7 @@ async function runOnlineUpdate(relayDir) {
4319
4445
  if (updateCancelledDirs.has(relayDir)) {
4320
4446
  updateCancelledDirs.delete(relayDir);
4321
4447
  appendLogLine(relayDir, UPDATE_LOG, "[update] 已取消:不再重试");
4448
+ restoreIfStopped(); // C8:取消也要把 bridge 拉回来
4322
4449
  return;
4323
4450
  }
4324
4451
  appendLogLine(relayDir, UPDATE_LOG, `[update] 离线通道退出 code=${code ?? "?"}`);
@@ -4331,8 +4458,10 @@ async function runOnlineUpdate(relayDir) {
4331
4458
  ? "离线通道也长时间没有输出(已判定卡住并结束):请检查本机能否访问 registry.npmjs.org"
4332
4459
  : (lastLine || `离线通道退出码 ${code}`), fc);
4333
4460
  telemetryRecord(relayDir, "update_failed", { fail_code: fc });
4461
+ restoreIfStopped(); // C8
4334
4462
  } else {
4335
4463
  updateFailures.delete(String(relayDir));
4464
+ sweepIfSucceeded(); // C8:装完清残留(按更新前的 pid 点名 + 命令行复核)
4336
4465
  // 更新成功 → 落一条「必须重启 dsh web」的强提醒(见 run() 成功分支的同款注释)
4337
4466
  markRestartPending(relayDir, "restart", "插件已更新,重启 dsh web 后生效");
4338
4467
  }
@@ -4373,6 +4502,7 @@ async function runOnlineUpdate(relayDir) {
4373
4502
  if (updateCancelledDirs.has(relayDir)) {
4374
4503
  updateCancelledDirs.delete(relayDir);
4375
4504
  appendLogLine(relayDir, UPDATE_LOG, "[update] 已取消:不再换源重试");
4505
+ restoreIfStopped(); // C8:取消也要把 bridge 拉回来
4376
4506
  return;
4377
4507
  }
4378
4508
  if (!retried && (code !== 0 || stalled)) {
@@ -4407,8 +4537,10 @@ async function runOnlineUpdate(relayDir) {
4407
4537
  ? "更新进程长时间没有任何输出(已判定卡住并结束),通常是本机到 npm 官方源的链路不通"
4408
4538
  : (lastLine || `安装进程退出码 ${code}`), fc);
4409
4539
  telemetryRecord(relayDir, "update_failed", { fail_code: fc });
4540
+ restoreIfStopped(); // C8:三次尝试都已失败 → 先把 bridge 拉回来(用户可以先继续用,再手动重试)
4410
4541
  } else {
4411
4542
  updateFailures.delete(String(relayDir)); // 成功即清掉旧失败
4543
+ sweepIfSucceeded(); // C8:装完清残留(安装器可能又拉起一个新 bridge,只清更新前那批)
4412
4544
  // 【2026-09-26】更新成功 → 写一条 kind="restart" 的待重启标记。
4413
4545
  //
4414
4546
  // 修的是"文案与状态机不一致":版本卡一直写「请重启 dsh web 后生效」(client.js 的
@@ -4438,6 +4570,11 @@ async function runOnlineUpdate(relayDir) {
4438
4570
  } catch (e) {
4439
4571
  try { rmSync(join(relayDir, UPDATE_MARKER), { force: true }); } catch { /* ignore */ }
4440
4572
  telemetryRecord(relayDir, "update_failed", { fail_code: telemetryFailCodeFromError(e) });
4573
+ // C8:异常也要把停掉的 bridge 拉回来(否则"更新失败"= 远程访问直接没了)
4574
+ if (stopInfo.pids.length && !skipsSystemOps()) {
4575
+ const r = restoreBridgeAfterUpdate(relayDir, stopInfo.pids);
4576
+ appendLogLine(relayDir, UPDATE_LOG, `[update] 响应异常,已重新拉起 bridge(${(r && r.status) || "unknown"})`);
4577
+ }
4441
4578
  return { ok: false, detail: String(e.message || e) };
4442
4579
  }
4443
4580
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-web",
3
- "version": "0.7.3-beta.1",
3
+ "version": "0.7.3-beta.3",
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",
@@ -117,7 +117,19 @@ const DEFAULT_CONFIG = path.join(
117
117
  const CONFIG_PATH = process.env.DSH_BRIDGE_CONFIG || DEFAULT_CONFIG;
118
118
  // 隧道模式(唯一):bridge 主动 WS 连 relay-router 的 /_bridge
119
119
  const TUNNEL_URL = (process.env.DSH_BRIDGE_TUNNEL_URL || "").replace(/\/+$/, "");
120
- const TUNNEL_HEARTBEAT_MS = Math.max(100, Number(process.env.DSH_BRIDGE_HEARTBEAT_MS) || 15_000);
120
+ /**
121
+ * 隧道心跳间隔(C5)。
122
+ *
123
+ * 为什么从 15s 调到 20s:生产上"30 秒级断连、75% 的隧道会话活不过 60s"(诊断报告 R2),
124
+ * 15s 的心跳 + "丢一次 pong 就断" 让弱网(丢包 5%)下的正常抖动被当成故障,
125
+ * 每次误断都会重连重建隧道 + 打断在途请求。20s 与中继/nginx 的 3600s read timeout 无关,
126
+ * 只影响"多久发现半开连接":最坏 3 个心跳周期(≈60s)发现真死的连接,仍远小于移动网络
127
+ * 常见的 NAT 空闲超时(通常 2~5 分钟)。
128
+ */
129
+ export const TUNNEL_HEARTBEAT_DEFAULT_MS = 20_000;
130
+ /** 连续丢多少个 pong 才判定隧道已死(C5:旧实现丢 1 次就 terminate,弱网误判率过高)。 */
131
+ export const HEARTBEAT_MAX_MISSED_PONGS = 2;
132
+ const TUNNEL_HEARTBEAT_MS = Math.max(100, Number(process.env.DSH_BRIDGE_HEARTBEAT_MS) || TUNNEL_HEARTBEAT_DEFAULT_MS);
121
133
  /**
122
134
  * 上游 dsh web 的地址。
123
135
  *
@@ -1402,6 +1414,61 @@ async function resolveToken(refresh = false) {
1402
1414
  // ============================================================
1403
1415
 
1404
1416
  let tunnelRetry = 0;
1417
+ /** C2:连续收到 close code=4000 的次数(跨 connectTunnel 保留;非 4000 的断开清零)。 */
1418
+ let consecutive4000 = 0;
1419
+
1420
+ /* ── 重连策略(C2 + C5)────────────────────────────────────────────────────────
1421
+ *
1422
+ * code=4000 的语义(中继 relay-router/src/index.mjs 唯一用途):**同 deviceId 的新连接
1423
+ * 把本条连接顶掉了**(replaced by new connection)。所以对被踢的一方,"重连"的唯一效果
1424
+ * 是把对方再顶一次 —— 两个实例就这么以 2~3.5s 的周期乒乓(生产日志 dev-a50dced9cc19
1425
+ * 327 次/10min、win32 持续互踢 6 台里 3 台全 0.7.2)。
1426
+ *
1427
+ * 修法(诊断报告 04 §4.4.2 的 P0-1):
1428
+ * · 第 1 次 4000 → 长退避 30~60s(是原来 2s 的 15~30 倍),让"新连接"先站稳;
1429
+ * · 连续第 2 次 4000 → 直接优雅退出(退出码 4),把设备让给对方。watcher 收到 code=4
1430
+ * 会暂停 5 分钟再拉,避免继续对新实例发起第 3 次顶撞。
1431
+ * · 非 4000 的普通断线保持原有指数退避,但上限 30s→10s 并加 0~3s 抖动(C5):
1432
+ * 30s 上限会让"一次瞬时断网"恢复得比必要慢得多;抖动则避免多台设备同秒重连造成
1433
+ * 中继/nginx 连接尖峰(诊断 R1 的连接耗尽与重连风暴互相放大)。
1434
+ * 抖动只加在 base ≥4s 的档位:第一档(2s)保持确定值,测试里"5 秒内必须重连"的
1435
+ * 硬期限才不会被随机数撞破。
1436
+ */
1437
+ export const DUPLICATE_CLOSE_CODE = 4000;
1438
+ /** bridge 判定"本机已有另一个同 deviceId 实例"后的退出码(必须与 dsh-setup.mjs 的常量一致)。 */
1439
+ export const DUPLICATE_INSTANCE_EXIT_CODE = 4;
1440
+ export const DUPLICATE_RECONNECT_BASE_MS = 30_000;
1441
+ export const MAX_RECONNECT_DELAY_MS = 10_000;
1442
+ export const RECONNECT_JITTER_MS = 3_000;
1443
+ export const RECONNECT_JITTER_MIN_BASE_MS = 4_000;
1444
+
1445
+ /**
1446
+ * 算下一次重连:返回 { exit, delayMs, consecutive4000, reason }。
1447
+ * @param {{ code?: number, retry?: number, consecutive4000?: number, rand?: () => number }} args
1448
+ */
1449
+ export function reconnectPlan({ code, retry = 0, consecutive4000: prev = 0, rand = Math.random } = {}) {
1450
+ if (code === DUPLICATE_CLOSE_CODE) {
1451
+ const next = Math.max(0, prev) + 1;
1452
+ if (next >= 2) return { exit: true, delayMs: 0, consecutive4000: next, reason: "duplicate-exit" };
1453
+ const delayMs = DUPLICATE_RECONNECT_BASE_MS + Math.floor(rand() * DUPLICATE_RECONNECT_BASE_MS);
1454
+ return { exit: false, delayMs, consecutive4000: next, reason: "duplicate-backoff" };
1455
+ }
1456
+ const n = Math.max(0, retry);
1457
+ const base = Math.min(MAX_RECONNECT_DELAY_MS, 2_000 * 2 ** n);
1458
+ const jitter = base >= RECONNECT_JITTER_MIN_BASE_MS ? Math.floor(rand() * RECONNECT_JITTER_MS) : 0;
1459
+ return { exit: false, delayMs: base + jitter, consecutive4000: 0, reason: "normal" };
1460
+ }
1461
+
1462
+ /**
1463
+ * 心跳一拍(C5):连续丢 HEARTBEAT_MAX_MISSED_PONGS 次 pong 才判定隧道已死。
1464
+ * 纯函数,便于单测("丢 1 次就断"是这次要改掉的旧契约)。
1465
+ */
1466
+ export function heartbeatStep({ pongReceived, missedPongs = 0 }) {
1467
+ if (pongReceived) return { missedPongs: 0, terminate: false, ping: true };
1468
+ const missed = Math.max(0, missedPongs) + 1;
1469
+ const terminate = missed >= HEARTBEAT_MAX_MISSED_PONGS;
1470
+ return { missedPongs: missed, terminate, ping: !terminate };
1471
+ }
1405
1472
 
1406
1473
  /** 把 DSH_BRIDGE_TUNNEL_URL 归一化为 router 注册端点(缺省补 / _bridge)。 */
1407
1474
  function tunnelEndpoint(raw) {
@@ -1473,6 +1540,7 @@ function connectTunnel(token) {
1473
1540
  const send = makeChunkedSender((s) => { if (ws.readyState === WebSocket.OPEN) ws.send(s); });
1474
1541
  let heartbeat;
1475
1542
  let pongReceived = true;
1543
+ let missedPongs = 0;
1476
1544
 
1477
1545
  ws.on("open", () => {
1478
1546
  tunnelRetry = 0;
@@ -1483,16 +1551,21 @@ function connectTunnel(token) {
1483
1551
  ws.send(JSON.stringify({ type: "tunnel-register", deviceId: DEVICE_ID, token, name: os.hostname() || "dsh-bridge", caps: e2ee.caps() }));
1484
1552
  } catch (e) { console.log(`[bridge] 注册发送失败: ${e.message}`); }
1485
1553
  heartbeat = setInterval(() => {
1486
- if (!pongReceived) {
1487
- console.log("[bridge] 隧道心跳超时,主动重连...");
1488
- ws.terminate();
1554
+ // C5:连续 2 次没收到 pong 才判定半开(旧实现丢 1 次就断 → 弱网下正常的单次丢包
1555
+ // 也会被当成故障重连,直接贡献了"75% 的隧道会话活不过 60s")。
1556
+ const step = heartbeatStep({ pongReceived, missedPongs });
1557
+ missedPongs = step.missedPongs;
1558
+ pongReceived = false;
1559
+ if (step.terminate) {
1560
+ console.log(`[bridge] 隧道连续 ${missedPongs} 次心跳无响应,主动重连...`);
1561
+ try { ws.terminate(); } catch { /* 已关闭 */ }
1489
1562
  return;
1490
1563
  }
1491
- pongReceived = false;
1564
+ if (missedPongs > 0) console.log(`[bridge] 隧道心跳无响应(${missedPongs}/${HEARTBEAT_MAX_MISSED_PONGS}),继续探测...`);
1492
1565
  try { ws.ping(); } catch { ws.terminate(); }
1493
1566
  }, TUNNEL_HEARTBEAT_MS);
1494
1567
  });
1495
- ws.on("pong", () => { pongReceived = true; });
1568
+ ws.on("pong", () => { pongReceived = true; missedPongs = 0; });
1496
1569
  ws.on("message", (raw) => {
1497
1570
  const receive = makeFrameReceiver();
1498
1571
  try {
@@ -1520,10 +1593,24 @@ function connectTunnel(token) {
1520
1593
  clearInterval(heartbeat);
1521
1594
  console.log(`[bridge] 隧道断开(code=${code}${reason ? ", " + reason : ""})`);
1522
1595
  closeAllWsSessions();
1523
- const delay = Math.min(30_000, 2_000 * 2 ** tunnelRetry);
1596
+ // C2 + C5:4000(被同 deviceId 的新连接顶掉)走长退避/退出;其余走 2/4/8/10s + 抖动。
1597
+ const plan = reconnectPlan({ code, retry: tunnelRetry, consecutive4000 });
1598
+ consecutive4000 = plan.consecutive4000;
1524
1599
  tunnelRetry += 1;
1525
- console.log(`[bridge] ${Math.round(delay / 1000)}s 后重连...`);
1526
- setTimeout(async () => connectTunnel(code === 4003 ? await resolveToken(true) : token), delay);
1600
+ if (plan.exit) {
1601
+ // 连续两次被顶 = 本机确实还有另一个 bridge 在同一 deviceId 上活着。再重连只会把它再顶一次,
1602
+ // 所以让位:用专用退出码退出,watcher 据此暂停拉起(见 dsh-setup.mjs 的 DUPLICATE_INSTANCE_EXIT_CODE)。
1603
+ console.error("[bridge] 检测到同 deviceId 的另一个 bridge,本实例退出(连续 2 次被顶,不再重连以免互踢)。");
1604
+ console.error("[bridge] 若这是升级/重装留下的旧实例:重启一次 dsh web(或运行 dsh-remote doctor)即可只保留一个。");
1605
+ try { closeAllWsSessions(); } catch { /* 已关闭 */ }
1606
+ process.exit(DUPLICATE_INSTANCE_EXIT_CODE);
1607
+ }
1608
+ if (plan.reason === "duplicate-backoff") {
1609
+ console.log(`[bridge] 本实例被同 deviceId 的新连接顶替,${Math.round(plan.delayMs / 1000)}s 后再试(期间不重连,避免互踢)...`);
1610
+ } else {
1611
+ console.log(`[bridge] ${Math.round(plan.delayMs / 1000)}s 后重连...`);
1612
+ }
1613
+ setTimeout(async () => connectTunnel(code === 4003 ? await resolveToken(true) : token), plan.delayMs);
1527
1614
  });
1528
1615
  ws.on("error", (e) => console.log(`[bridge] 隧道错误: ${e.message || ""}`));
1529
1616
  }
@@ -1757,6 +1844,112 @@ function redactText(e) {
1757
1844
  return String(e && e.message ? e.message : e).slice(0, 200);
1758
1845
  }
1759
1846
 
1847
+ /* ── C2:同机单实例保护(lock 文件 + PID/命令行校验,失败安全)────────────────────
1848
+ *
1849
+ * 为什么需要(诊断报告 04 §4.4):同机两个 bridge 用同一个 deviceId 连中继会**互相顶号**
1850
+ *(router close(4000)),周期 2~3.5s 的乒乓。来源有孤儿进程(watcher 被 TerminateProcess
1851
+ * 收掉、bridge 漏成孤儿)、升级后旧进程未退、插件自愈与任务计划同时拉。watcher 层的 pid 文件
1852
+ * 管不住这几种,所以 bridge 自己再上一把"本配置目录内只允许一个实例"的锁。
1853
+ *
1854
+ * 判定纪律(误杀/误挡的代价不对称):
1855
+ * · pid 死了 / pid 被系统回收给**无关进程** → 抢锁(陈旧锁绝不能把 bridge 永久挡在门外);
1856
+ * · pid 活着且命令行确认是 `dsh-bridge.mjs` → 判定"已有实例",本实例退出(退出码 4,
1857
+ * watcher 会暂停 5 分钟,见 dsh-setup.mjs);
1858
+ * · 取不到命令行(ps/PowerShell 不可用)→ **放行**(fail-safe 方向):宁可冒着多开的风险也
1859
+ * 不能让"锁检查失败"变成"bridge 永远起不来",多开由上面的 4000 长退避/退出兜底。
1860
+ */
1861
+ const INSTANCE_LOCK_FILE = ".dsh-bridge-instance.lock";
1862
+ /**
1863
+ * 是否启用单实例锁:生产形态(配置目录运行时 / npm 安装)恒启用;
1864
+ * 仓库里直接 `node clients/dsh-remote/dsh-bridge.mjs` 调试时不落锁,避免把锁文件写进工作区。
1865
+ */
1866
+ const INSTANCE_GUARD_ENABLED = Boolean(process.env.DSH_BRIDGE_CONFIG) || ROOT.includes(`${path.sep}node_modules${path.sep}`);
1867
+
1868
+ /** 该 pid 的完整命令行(取不到返回空串 —— 调用方据此走 fail-safe)。 */
1869
+ function commandLineOfPid(pid) {
1870
+ if (!Number.isInteger(pid) || pid <= 0) return "";
1871
+ if (process.platform === "win32") {
1872
+ const script = `(Get-CimInstance Win32_Process -Filter "ProcessId=${pid}").CommandLine`;
1873
+ const encoded = Buffer.from(script, "utf16le").toString("base64");
1874
+ try {
1875
+ return String(spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-EncodedCommand", encoded],
1876
+ { encoding: "utf8", timeout: 8000, windowsHide: true }).stdout || "").trim();
1877
+ } catch { return ""; }
1878
+ }
1879
+ try {
1880
+ return String(execSync(`ps -p ${pid} -o command=`, { encoding: "utf8", timeout: 3000, windowsHide: true }) || "").trim();
1881
+ } catch { return ""; }
1882
+ }
1883
+
1884
+ function isOurBridgeCommand(cmd) {
1885
+ const c = String(cmd || "");
1886
+ return /dsh-bridge\.mjs/.test(c) && /clients[\\/]dsh-remote/.test(c);
1887
+ }
1888
+
1889
+ /**
1890
+ * 锁归属判定(纯函数,便于单测):steal=可以抢锁;blocked=已有真实例;unknown=看不住但别阻塞。
1891
+ * @returns {"steal"|"blocked"|"unknown"}
1892
+ */
1893
+ export function instanceLockVerdict({ selfPid = 0, pid = 0, alive = false, isOurBridge = false, commandKnown = true } = {}) {
1894
+ if (!Number.isInteger(pid) || pid <= 0) return "steal";
1895
+ if (pid === selfPid) return "steal";
1896
+ if (!alive) return "steal";
1897
+ if (isOurBridge) return "blocked";
1898
+ if (!commandKnown) return "unknown";
1899
+ return "steal"; // pid 被系统回收给了无关进程
1900
+ }
1901
+
1902
+ /** 抢单实例锁。返回 { ok, pid?, degraded? };ok=false 表示确实有另一个实例。 */
1903
+ function acquireInstanceLock() {
1904
+ if (!INSTANCE_GUARD_ENABLED) return { ok: true, skipped: true };
1905
+ const file = path.join(RELAY_DIR, INSTANCE_LOCK_FILE);
1906
+ const payload = JSON.stringify({ pid: process.pid, at: Date.now(), script: "dsh-bridge.mjs" });
1907
+ for (let attempt = 0; attempt < 2; attempt += 1) {
1908
+ try {
1909
+ fs.mkdirSync(RELAY_DIR, { recursive: true });
1910
+ const fd = fs.openSync(file, "wx"); // 排他创建:要么拿到,要么 EEXIST
1911
+ fs.writeSync(fd, payload);
1912
+ fs.closeSync(fd);
1913
+ return { ok: true };
1914
+ } catch (e) {
1915
+ if (e && e.code !== "EEXIST") return { ok: true, degraded: true }; // 写不了锁 → 失败安全:照常启动
1916
+ let info = null;
1917
+ try { info = JSON.parse(fs.readFileSync(file, "utf8")); } catch { info = null; }
1918
+ const pid = Number(info && info.pid);
1919
+ const alive = Number.isInteger(pid) && pid > 0 ? isPidAlive(pid) : false;
1920
+ const cmd = alive ? commandLineOfPid(pid) : "";
1921
+ const verdict = instanceLockVerdict({
1922
+ selfPid: process.pid, pid, alive,
1923
+ isOurBridge: isOurBridgeCommand(cmd),
1924
+ commandKnown: Boolean(cmd)
1925
+ });
1926
+ if (verdict === "blocked") return { ok: false, pid };
1927
+ if (verdict === "unknown") {
1928
+ // 看不住:不删别人的锁,也不阻塞自己(多开由 4000 处理兜底)。
1929
+ return { ok: true, degraded: true, pid };
1930
+ }
1931
+ try { fs.rmSync(file, { force: true }); } catch { /* 抢不到就算了,下一轮 */ }
1932
+ }
1933
+ }
1934
+ return { ok: true, degraded: true };
1935
+ }
1936
+
1937
+ /** 释放自己那把锁(只删 owner === 本进程 的锁,绝不删别人的)。 */
1938
+ function releaseInstanceLock() {
1939
+ if (!INSTANCE_GUARD_ENABLED) return;
1940
+ const file = path.join(RELAY_DIR, INSTANCE_LOCK_FILE);
1941
+ try {
1942
+ const info = JSON.parse(fs.readFileSync(file, "utf8"));
1943
+ if (Number(info && info.pid) === process.pid) fs.rmSync(file, { force: true });
1944
+ } catch { /* 没锁/已被抢 → 忽略 */ }
1945
+ }
1946
+
1947
+ /** 本进程 pid 是否活着(不影响别的进程;EPERM 视为活着)。 */
1948
+ function isPidAlive(pid) {
1949
+ if (!Number.isInteger(pid) || pid <= 0) return false;
1950
+ try { process.kill(pid, 0); return true; } catch (e) { return e.code === "EPERM"; }
1951
+ }
1952
+
1760
1953
  async function runTunnel() {
1761
1954
  // 先动态确定上游再连隧道:上游端口不是 3080 时(DSH Desktop 默认 43120)这一步是关键 ——
1762
1955
  // 否则 bridge 会连上中继、手机也能打开页面,但每个请求都打到一个没人听的端口。
@@ -1834,6 +2027,18 @@ async function main() {
1834
2027
  process.on("message", (m) => {
1835
2028
  if (m && m.type === "shutdown") shutdown("IPC shutdown");
1836
2029
  });
2030
+ // C2:同机单实例锁(理由/判定纪律见 acquireInstanceLock 上方的长注释)。
2031
+ // 放在认证之前:被挡下的第二个实例**不消耗一次 device-login**(登录接口按出口 IP 限流)。
2032
+ const lock = acquireInstanceLock();
2033
+ if (!lock.ok) {
2034
+ console.error(`[bridge] 本机已有另一个 bridge 实例在运行(pid=${lock.pid},同一配置目录),本实例退出以免两个实例互踢。`);
2035
+ console.error("[bridge] 若这来自升级/重装留下的旧实例:结束该进程后重启一次 dsh web 即可。");
2036
+ process.exit(DUPLICATE_INSTANCE_EXIT_CODE); // watcher 据此暂停 5 分钟再拉,避免继续顶撞
2037
+ }
2038
+ if (lock.degraded) {
2039
+ console.log("[bridge] 单实例锁检查降级(取不到进程命令行或锁不可写)——照常启动;若出现 code=4000 互踢,本实例会自动让位。");
2040
+ }
2041
+ process.on("exit", releaseInstanceLock);
1837
2042
  return runTunnel();
1838
2043
  }
1839
2044