@mrrisega/dsh-remote 0.6.10 → 0.6.11

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.
@@ -591,7 +591,17 @@ class EventSubscriber extends EventEmitter {
591
591
  if (ws !== null) {
592
592
  try {
593
593
  ws.removeAllListeners();
594
- ws.close(1000, "subscriber closed");
594
+ // ⚠️ 先挂一个吞掉的 error 监听:**握手中的**连接被 close() 时,ws 会**异步** emit
595
+ // 'error'("WebSocket was closed before the connection was established")。
596
+ // 上面刚 removeAllListeners(),于是它成了**未捕获错误** —— 真机表现是「解绑」
597
+ // 这一步偶发把流程打挂(测试里是一条 e2e 偶发红,2026-09-23 定位)。
598
+ ws.on("error", () => { /* 关闭途中的错误不该冒泡到业务 */ });
599
+ if (ws.readyState === this.#WebSocketImpl.CONNECTING) {
600
+ // 还没握手完 → terminate():立刻中止,且不产生上面那个错误
601
+ ws.terminate();
602
+ } else {
603
+ ws.close(1000, "subscriber closed");
604
+ }
595
605
  } catch {
596
606
  /* 关不掉就等 GC */
597
607
  }
@@ -1666,3 +1666,82 @@ test("★ /help 必须逐行分隔:付费档也不许退化成一坨(无换行/
1666
1666
  await ilink.close();
1667
1667
  }
1668
1668
  });
1669
+
1670
+ // ---------------------------------------------------------------------------
1671
+ // 日报**重复推送**的根治(2026-09-23 业主实测「每日简报推送了两次」)
1672
+ //
1673
+ // 根因:`#maybeDigest` 是「读 state → 等网络发送 → 写 state」的**跨 await 读-改-写**。
1674
+ // 发送要等网络,这段窗口里第二个执行者会读到"今天还没发" → 两条都发出去。
1675
+ // 真实撞车场景有两个:① 更新期间新旧 bridge 短暂共存;② 一次发送超过 60s,下一次 tick 叠上来。
1676
+ // 修法:`openSync(..., "wx")`(不存在才创建,内核保证原子)占位 —— 只有一个执行者拿得到。
1677
+ // ---------------------------------------------------------------------------
1678
+
1679
+ /** 两个 runtime **共用同一个 relayDir**(模拟更新期间新旧 bridge 短暂共存)。 */
1680
+ async function twoRuntimesSameDir(ilink) {
1681
+ const relayDir = tmp();
1682
+ saveAccount(relayDir, { token: "tok-abc", accountId: "bot-1", baseUrl: ilink.baseUrl, userId: "user-1", boundAt: 1 });
1683
+ const mk = () => {
1684
+ const rt = createWeChatRuntime({
1685
+ relayDir,
1686
+ upstream: "http://127.0.0.1:1",
1687
+ secret: SECRET,
1688
+ tier: "pro",
1689
+ logger: quietLogger()
1690
+ });
1691
+ rt.subscriber = makeFakeSubscriber();
1692
+ rt.digestHour = new Date().getHours(); // 让真实路径通过时刻闸门
1693
+ return rt;
1694
+ };
1695
+ return { rtA: mk(), rtB: mk(), relayDir };
1696
+ }
1697
+
1698
+ test("★★ 两个实例共存时日报只能发一条(原子占位 —— 真机曾发两条)", async () => {
1699
+ const ilink = await fakeIlink();
1700
+ try {
1701
+ const { rtA, rtB } = await twoRuntimesSameDir(ilink);
1702
+ const before = ilink.state.sends.length;
1703
+ // 并发触发:模拟两个实例在同一分钟内各自 tick(旧实现会两条都发)
1704
+ await Promise.all([rtA.runMaybeDigestNow(), rtB.runMaybeDigestNow()]);
1705
+ const sent = ilink.state.sends.length - before;
1706
+ assert.equal(sent, 1, `★同一天只能有一条简报,实际发出 ${sent} 条(旧实现:两个执行者都读到"今天还没发")`);
1707
+ } finally {
1708
+ await ilink.close();
1709
+ }
1710
+ });
1711
+
1712
+ test("★ 日报占位是陈旧的(>10 分钟)时必须接管,不能把那天彻底吞掉", async () => {
1713
+ const ilink = await fakeIlink();
1714
+ try {
1715
+ const { rt, relayDir } = await boundRuntime(ilink, { subscriber: makeFakeSubscriber(), tier: "pro" });
1716
+ rt.digestHour = new Date().getHours();
1717
+ const day = `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, "0")}-${String(new Date().getDate()).padStart(2, "0")}`;
1718
+ const lock = path.join(relayDir, `.digest-${day}.sent`);
1719
+ // 造一个 20 分钟前的占位(模拟"上个实例占位后、发送途中被杀")
1720
+ fs.writeFileSync(lock, "");
1721
+ const old = new Date(Date.now() - 20 * 60_000);
1722
+ fs.utimesSync(lock, old, old);
1723
+
1724
+ const before = ilink.state.sends.length;
1725
+ await rt.runMaybeDigestNow();
1726
+ assert.equal(ilink.state.sends.length - before, 1,
1727
+ "★陈旧占位必须被接管并补发 —— 否则进程一被杀,那天就再也不会发简报");
1728
+ } finally {
1729
+ await ilink.close();
1730
+ }
1731
+ });
1732
+
1733
+ test("★ 日报占位一天一个,换天时会清掉旧占位(不攒文件)", async () => {
1734
+ const ilink = await fakeIlink();
1735
+ try {
1736
+ const { rt, relayDir } = await boundRuntime(ilink, { subscriber: makeFakeSubscriber(), tier: "pro" });
1737
+ rt.digestHour = new Date().getHours();
1738
+ // 放两个"昨天/前天"的占位
1739
+ fs.writeFileSync(path.join(relayDir, ".digest-2020-01-01.sent"), "");
1740
+ fs.writeFileSync(path.join(relayDir, ".digest-2020-01-02.sent"), "");
1741
+ await rt.runMaybeDigestNow();
1742
+ const left = fs.readdirSync(relayDir).filter((n) => n.startsWith(".digest-") && n.endsWith(".sent"));
1743
+ assert.equal(left.length, 1, `旧占位必须被清掉,只剩今天的,实际:${left.join(", ")}`);
1744
+ } finally {
1745
+ await ilink.close();
1746
+ }
1747
+ });
@@ -1679,25 +1679,93 @@ export class WeChatRuntime {
1679
1679
  /**
1680
1680
  * 每日简报(兼作 24h 推送窗口的心跳)。
1681
1681
  *
1682
- * ⚠️ 旧实现有两个问题,一起修了:
1682
+ * ⚠️ 旧实现有三个问题,一起修了:
1683
1683
  * ① 只把 `{notified, answered}` 交给下游,**没有 lines** → 用户每天收到的是兜底句
1684
1684
  * 「今天暂时没有要做的事。」—— 那不是"今天干了啥",等于白发(业主:"日报发的内容有点问题")。
1685
1685
  * ② 先把 `last_digest_day` 落盘**再**发送 → 一旦这次没发出去(网络抖动/节点被权限关掉),
1686
1686
  * 当天的简报就被**烧掉**、当天再也不会补发。
1687
+ * ③ 「读 state → 发送 → 写 state」是一段**跨 await 的读-改-写**:发送要等网络,
1688
+ * 这段窗口里第二个执行者会读到"今天还没发"→ **两条都发出去**
1689
+ * (2026-09-23 真机:业主收到两条简报)。现在用**原子占位**把"今天这条归我发"锁死。
1687
1690
  */
1688
1691
  async #maybeDigest() {
1689
1692
  if (this.stopping || !this.channel.account || !this.subscriber) return;
1690
1693
  const now = this.clock();
1691
1694
  const hour = new Date(now).getHours();
1692
1695
  if (hour !== this.digestHour) return;
1696
+ const day = this.#today(now);
1693
1697
  const state = loadState(this.relayDir);
1694
- if (state.last_digest_day === this.#today(now)) return; // 今天已发
1698
+ if (state.last_digest_day === day) return; // 今天已发
1699
+ // ★ 先原子占位再发送:两个实例/两次 tick 并存时,只有一个能拿到(见 #claimDigest)
1700
+ const claim = this.#claimDigest(day);
1701
+ if (!claim.ok) return;
1695
1702
  const s = this.todayStats;
1696
1703
  const lines = this.#digestLines();
1697
1704
  const node = this.subscriber.digestDue({ lines, notified: s.notified, answered: s.answered });
1698
- const r = await this.notify(node);
1705
+ let r = null;
1706
+ try {
1707
+ r = await this.notify(node);
1708
+ } finally {
1709
+ // 没真发出去 → 释放占位,留给下一 tick 补发(见上面 ②:不能把当天烧掉)
1710
+ if (!(r && r.ok)) this.#releaseDigest(claim);
1711
+ }
1699
1712
  // ★ 只有**真的发出去了**才记"今天已发"(见上面 ②)
1700
- if (r && r.ok) this.channel.writeState({ last_digest_day: this.#today(now) });
1713
+ if (r && r.ok) this.channel.writeState({ last_digest_day: day });
1714
+ }
1715
+
1716
+ /**
1717
+ * 原子占位「今天这条简报由我来发」。
1718
+ *
1719
+ * 🔴 为什么光看 state 里的 `last_digest_day` 不够:那是**读-改-写**,中间隔着一次网络发送。
1720
+ * 典型撞车场景有两个,都真实发生过:
1721
+ * · 更新期间新旧 bridge **短暂共存**(旧的还没退、新的已起);
1722
+ * · 一次发送耗时超过 60s,下一次定时器 tick **叠**上来。
1723
+ * 两者都会让第二个执行者在写盘之前读到"今天还没发" → 用户收到两条。
1724
+ * `openSync(..., "wx")`(不存在才创建)由内核保证原子,只有一个执行者能拿到 ✅
1725
+ *
1726
+ * 生命期:发成功 → 占位**留着**(它比 state 更强,重启/换实例也不会丢);
1727
+ * 发失败 → 立刻由 `#releaseDigest` 释放,让下一 tick 补发;
1728
+ * 占位超过 10 分钟 = **陈旧**(进程在发送途中被杀)→ 接管它。
1729
+ * 宁可极小概率重发一次,也不要出现"那天彻底不发了"。
1730
+ */
1731
+ #claimDigest(day) {
1732
+ const file = path.join(this.relayDir, `.digest-${day}.sent`);
1733
+ const STALE_MS = 10 * 60_000;
1734
+ for (let attempt = 0; attempt < 2; attempt += 1) {
1735
+ try {
1736
+ fs.closeSync(fs.openSync(file, "wx"));
1737
+ this.#pruneDigestClaims(day);
1738
+ return { ok: true, file };
1739
+ } catch (e) {
1740
+ if (!e || e.code !== "EEXIST") {
1741
+ // 目录不可写等:退回"这次不发",绝不因此把 bridge 打挂(简报是锦上添花)
1742
+ return { ok: false, file };
1743
+ }
1744
+ let age = 0;
1745
+ try { age = Date.now() - fs.statSync(file).mtimeMs; } catch { age = 0; }
1746
+ if (age < STALE_MS) return { ok: false, file }; // 别人正在发 / 今天已经发过
1747
+ try { fs.rmSync(file, { force: true }); } catch { return { ok: false, file }; }
1748
+ // 删掉陈旧占位后重抢一次
1749
+ }
1750
+ }
1751
+ return { ok: false, file };
1752
+ }
1753
+
1754
+ /** 发送失败时释放占位(交给下一 tick 补发)。 */
1755
+ #releaseDigest(claim) {
1756
+ if (!claim || !claim.file) return;
1757
+ try { fs.rmSync(claim.file, { force: true }); } catch { /* ignore */ }
1758
+ }
1759
+
1760
+ /** 清掉别的日期留下的占位文件(一天一个,不清理会一年攒 365 个)。 */
1761
+ #pruneDigestClaims(today) {
1762
+ try {
1763
+ for (const name of fs.readdirSync(this.relayDir)) {
1764
+ if (!name.startsWith(".digest-") || !name.endsWith(".sent")) continue;
1765
+ if (name === `.digest-${today}.sent`) continue;
1766
+ try { fs.rmSync(path.join(this.relayDir, name), { force: true }); } catch { /* ignore */ }
1767
+ }
1768
+ } catch { /* 目录读不了就算了:占位本身仍生效 */ }
1701
1769
  }
1702
1770
 
1703
1771
  /** 简报正文:回答"**今天**干了啥"(业主口径:日报是回顾,不是待办清单)。 */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrrisega/dsh-remote",
3
- "version": "0.6.10",
3
+ "version": "0.6.11",
4
4
  "description": "手机远程控制 DeepSeek Harness · Remote control DeepSeek Harness (dsh web) from any phone browser — 100% 全功能 App 级体验:发消息、看工具执行、审批权限、改设置、管凭据,含特权操作,免内网穿透。一条命令安装 npx @mrrisega/dsh-remote。Mobile remote control for DSH, self-host or SaaS, no server needed on LAN.",
5
5
  "keywords": [
6
6
  "deepseek-harness",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-web",
3
- "version": "0.6.10",
3
+ "version": "0.6.11",
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",