dsh-cindy-host 0.1.20

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.
Files changed (82) hide show
  1. package/CHANGELOG.md +462 -0
  2. package/LICENSE +21 -0
  3. package/README.md +178 -0
  4. package/cordis.patch.yml +6 -0
  5. package/doc/architecture.md +34 -0
  6. package/doc/cindy-android-verify.md +362 -0
  7. package/doc/cindy-mobile-freeze-handoff.md +124 -0
  8. package/doc/cindy-phone-link.md +2902 -0
  9. package/doc/desktop-runtime-fix-20260930.md +69 -0
  10. package/doc/development-plan.md +19 -0
  11. package/doc/file-peer-apply-recovery-20260921.md +35 -0
  12. package/doc/file-peer-installed-hotfix-20260921.md +53 -0
  13. package/doc/file-peer-minimal-apply-20260921.md +78 -0
  14. package/doc/file-peer-original-contract-alignment-20260921.md +119 -0
  15. package/doc/file-peer-phone-export-observation-20260921.md +75 -0
  16. package/doc/file-peer-product-contract-audit-20260921.md +106 -0
  17. package/doc/file-peer-root-cause-20260921.md +53 -0
  18. package/doc/file-transfer.md +50 -0
  19. package/doc/host-performance-20260930.md +91 -0
  20. package/doc/neonabyss-apk-file-peer-diagnosis-20260921.md +87 -0
  21. package/doc/new-session-sync-fix-20260930.md +59 -0
  22. package/doc/patches/cindy-peer-silence-probe.patch +1112 -0
  23. package/doc/projection-read-probe.md +77 -0
  24. package/doc/publishing.md +259 -0
  25. package/doc/releasing.md +149 -0
  26. package/doc/research.md +24 -0
  27. package/doc/session-discovery-2026-09-20.md +67 -0
  28. package/lib/client.js +763 -0
  29. package/package.json +84 -0
  30. package/src/auth-session.js +189 -0
  31. package/src/authorization-policy.js +59 -0
  32. package/src/cindy-channels.js +1707 -0
  33. package/src/cindy-login-flow.js +159 -0
  34. package/src/cindy-login.js +80 -0
  35. package/src/cindy-message-row.js +432 -0
  36. package/src/cindy-session-row.js +171 -0
  37. package/src/console-sink.js +6 -0
  38. package/src/contracts.js +26 -0
  39. package/src/credential-store.js +128 -0
  40. package/src/demo.js +9 -0
  41. package/src/dsh-active-sessions.js +59 -0
  42. package/src/dsh-host-source.js +62 -0
  43. package/src/dsh-message-fold.js +608 -0
  44. package/src/dsh-plugin.js +1932 -0
  45. package/src/dsh-session-source.js +529 -0
  46. package/src/file-peer-protocol.js +7 -0
  47. package/src/file-peer-rtc.js +41 -0
  48. package/src/fixture-source.js +13 -0
  49. package/src/host-approvals.js +329 -0
  50. package/src/host-attachments.js +249 -0
  51. package/src/host-authorization.js +51 -0
  52. package/src/host-demo.js +3 -0
  53. package/src/host-export-jobs.js +356 -0
  54. package/src/host-export-owner.js +62 -0
  55. package/src/host-file-browser.js +400 -0
  56. package/src/host-file-ice.js +63 -0
  57. package/src/host-file-peer.js +289 -0
  58. package/src/host-file-reference.js +16 -0
  59. package/src/host-files.js +148 -0
  60. package/src/host-frame-budget.js +310 -0
  61. package/src/host-goals.js +284 -0
  62. package/src/host-history-view-limits.js +22 -0
  63. package/src/host-history-view.js +417 -0
  64. package/src/host-input-queue.js +505 -0
  65. package/src/host-media-fetch.js +511 -0
  66. package/src/host-media.js +678 -0
  67. package/src/host-models.js +231 -0
  68. package/src/host-palette.js +111 -0
  69. package/src/host-push-channels.js +37 -0
  70. package/src/host-reconnect.js +54 -0
  71. package/src/host-routes.js +266 -0
  72. package/src/host-settings.js +41 -0
  73. package/src/host-status.js +216 -0
  74. package/src/host-transfer-diagnostics.js +50 -0
  75. package/src/host.js +2497 -0
  76. package/src/login.js +31 -0
  77. package/src/logout.js +3 -0
  78. package/src/projection-read-model-sink.js +6 -0
  79. package/src/projection.js +43 -0
  80. package/src/session-flags.js +160 -0
  81. package/src/session-publisher.js +123 -0
  82. package/src/session-read-model.js +6 -0
@@ -0,0 +1,1112 @@
1
+ From eadedb8d5b1014e4c2ee4885e7ee55532c857714 Mon Sep 17 00:00:00 2001
2
+ From: Yop <chanyuenpang@hotmail.com>
3
+ Date: Fri, 18 Sep 2026 01:55:44 +0800
4
+ Subject: [PATCH 1/5] =?UTF-8?q?fix(device-link):=20peer=20=E7=BA=A7?=
5
+ =?UTF-8?q?=E9=9D=99=E9=BB=98=E6=8E=A2=E9=92=88,=E4=BF=AE=E5=A4=8D?=
6
+ =?UTF-8?q?=E5=9B=9E=E5=89=8D=E5=8F=B0=E5=90=8E=E5=AF=B9=E7=AB=AF=E6=96=B9?=
7
+ =?UTF-8?q?=E5=90=91=E5=93=91=E6=8E=89=E5=8D=B4=E4=B8=8D=E9=87=8D=E5=BB=BA?=
8
+ MIME-Version: 1.0
9
+ Content-Type: text/plain; charset=UTF-8
10
+ Content-Transfer-Encoding: 8bit
11
+
12
+ 挑照片回前台后 socket 半开时,relay 每 10s 的 pong 会让既有半开检测永久失明:
13
+ 手机端 pingIntervalMs=10s / pongMissLimit=1,判死条件是「20s 内没有任何入站帧」,
14
+ 而 relay 自己应答的 pong 恰好每 10s 刷新一次 —— 所有既有判据量的都是
15
+ 「本机↔relay」,量不到「对端方向」。实测:手机每秒重试同一个读、2.5 分钟不恢复,
16
+ 只有重启 App 才恢复。
17
+
18
+ 新增 opt-in 的 peer 级判据(peerSilenceProbeMs,默认 0 = 关闭,桌面端曲线不变):
19
+ - 只有带 src 的 routed 帧算「对端还在说话」,pong/presence 不算;
20
+ - 同一 peer 连续两次请求超时且对端持续静默 → restartConnection(自带 link 复位);
21
+ - 回前台探针改以对端活性作为「relay 可达」的证据,并要求已存在超时证据,
22
+ 避免把长执行通道(desktop-cmd:run 等)运行期间的正常静默打成失败;
23
+ - relay 已判定 DEVICE_OFFLINE 时不介入;同一窗口内最多重建一次。
24
+
25
+ 手机端显式开启 20_000ms(需 ≥ requestTimeoutMs,否则第二次超时时静默凑不满窗口)。
26
+ 新增 5 条单测;device-link 414/414。
27
+
28
+ Signed-off-by: Yop <chanyuenpang@hotmail.com>
29
+ ---
30
+ .../src/device-link/DeviceLinkContext.tsx | 9 ++
31
+ .../device-link/src/__tests__/client.test.ts | 152 ++++++++++++++++++
32
+ packages/device-link/src/client.ts | 133 ++++++++++++++-
33
+ 3 files changed, 292 insertions(+), 2 deletions(-)
34
+
35
+ diff --git a/apps/mobile/src/device-link/DeviceLinkContext.tsx b/apps/mobile/src/device-link/DeviceLinkContext.tsx
36
+ index bd1727d3c..71855eeba 100644
37
+ --- a/apps/mobile/src/device-link/DeviceLinkContext.tsx
38
+ +++ b/apps/mobile/src/device-link/DeviceLinkContext.tsx
39
+ @@ -838,6 +838,15 @@ export function DeviceLinkProvider({ children }: { children: ReactNode }) {
40
+ // worktree:create / schedule 等)在 sendInvoke 按 invokeTimeouts 解析规则单独放宽,
41
+ // 与桌面控制端同一张协议契约表,不受此默认值影响。
42
+ requestTimeoutMs: 15_000,
43
+ + // peer 级静默探针(2026-09-17 实测)。挑照片回前台后 socket 半开时:上面的
44
+ + // 心跳只量「本机↔relay」,而 relay 每 10s 主动回一次 pong,于是「20s 内有
45
+ + // 任意入站帧就清零」让半开检测永久失明 —— 手机每秒重试同一个读、2.5 分钟
46
+ + // 不恢复,只有重启 App 才恢复。开启后追加一条**对端**活性判据:同一 peer
47
+ + // 连续两次请求超时、且这 20s 内对端一个带 src 的帧都没发 → 强制重建连接
48
+ + // (restartConnection 会一并复位 link 状态,host 侧重开链路)。
49
+ + // 窗口必须 ≥ requestTimeoutMs(15s),否则第二次超时时静默时长还凑不满窗口;
50
+ + // 两次超时是刻意的门槛:单次慢请求(桌面正忙)不该拆掉共享 socket。
51
+ + peerSilenceProbeMs: 20_000,
52
+ },
53
+ });
54
+ clientRef.current = client;
55
+ diff --git a/packages/device-link/src/__tests__/client.test.ts b/packages/device-link/src/__tests__/client.test.ts
56
+ index dee0caa8b..1f7c9f334 100644
57
+ --- a/packages/device-link/src/__tests__/client.test.ts
58
+ +++ b/packages/device-link/src/__tests__/client.test.ts
59
+ @@ -8744,3 +8744,155 @@ describe('定时重发的单趟预算(TRANSPORT_RETRY_PASS_BUDGET)', () => {
60
+ h.client.stop();
61
+ }, 10_000);
62
+ });
63
+ +
64
+ +/**
65
+ + * peer 级静默探针(2026-09-17 安卓端实测的补偿机制)。
66
+ + *
67
+ + * 现场:挑照片回前台后 socket 半开,手机每秒重试同一个读、2.5 分钟不恢复,只有重启
68
+ + * App 才恢复。原因是**所有既有活性判据都是 relay 级**的:relay 每 10s 回一次 pong,
69
+ + * 手机的半开检测(`client.ts` 心跳)「20 秒内有任意入站帧就清零」因此永久失明。
70
+ + * 这里断言新增的 peer 级判据:只有带 `src` 的对端帧算「对端还在说话」。
71
+ + */
72
+ +describe('peer 级静默探针', () => {
73
+ + it('默认关闭:relay 可达但对端全程静默,连续请求超时也不重建共享 socket', async () => {
74
+ + // pingIntervalMs 拉长把「本机↔relay」的判死逻辑摘出去:这样唯一可能重建的
75
+ + // 只有 peer 级探针,断言才有意义。
76
+ + const h = makeHarness({ timing: { pingIntervalMs: 60_000, requestTimeoutMs: 20 } });
77
+ + h.client.start();
78
+ + await tick();
79
+ + const socket = h.current();
80
+ + socket.ack();
81
+ +
82
+ + await expect(h.client.invoke('dev-b', { channel: 'x', args: [] }, 20))
83
+ + .rejects.toMatchObject({ code: 'INVOKE_TIMEOUT' });
84
+ + await expect(h.client.invoke('dev-b', { channel: 'x', args: [] }, 20))
85
+ + .rejects.toMatchObject({ code: 'INVOKE_TIMEOUT' });
86
+ + await tick(40);
87
+ +
88
+ + expect(h.sockets).toHaveLength(1);
89
+ + h.client.stop();
90
+ + });
91
+ +
92
+ + it('开启后:同一 peer 连续两次超时且对端持续静默 → 强制重建连接', async () => {
93
+ + const h = makeHarness({
94
+ + timing: { pingIntervalMs: 60_000, requestTimeoutMs: 20, peerSilenceProbeMs: 35 },
95
+ + });
96
+ + h.client.start();
97
+ + await tick();
98
+ + const socket = h.current();
99
+ + socket.ack();
100
+ +
101
+ + // 第一次超时:连续计数只有 1,单次慢请求不拆链路。
102
+ + await expect(h.client.invoke('dev-b', { channel: 'x', args: [] }, 20))
103
+ + .rejects.toMatchObject({ code: 'INVOKE_TIMEOUT' });
104
+ + await tick(5);
105
+ + expect(h.sockets).toHaveLength(1);
106
+ +
107
+ + // 第二次超时:连续两次 + 静默已过窗口 → 重建。
108
+ + await expect(h.client.invoke('dev-b', { channel: 'x', args: [] }, 20))
109
+ + .rejects.toMatchObject({ code: 'INVOKE_TIMEOUT' });
110
+ + await tick(30);
111
+ +
112
+ + expect(h.sockets.length).toBeGreaterThan(1);
113
+ + expect(socket.terminated || socket.closed !== null).toBe(true);
114
+ + h.client.stop();
115
+ + });
116
+ +
117
+ + it('开启后:对端仍有帧(带 src)时永不判定静默', async () => {
118
+ + const h = makeHarness({
119
+ + timing: { pingIntervalMs: 60_000, requestTimeoutMs: 20, peerSilenceProbeMs: 35 },
120
+ + });
121
+ + h.client.start();
122
+ + await tick();
123
+ + const socket = h.current();
124
+ + socket.ack();
125
+ +
126
+ + await expect(h.client.invoke('dev-b', { channel: 'x', args: [] }, 20))
127
+ + .rejects.toMatchObject({ code: 'INVOKE_TIMEOUT' });
128
+ + // 对端发来一帧:连续超时计数清零,静默窗口重新开始计时。
129
+ + socket.push({
130
+ + v: PROTOCOL_VERSION,
131
+ + kind: 'push',
132
+ + src: 'dev-b',
133
+ + payload: { channel: 'demo', payload: {} },
134
+ + });
135
+ + await expect(h.client.invoke('dev-b', { channel: 'x', args: [] }, 20))
136
+ + .rejects.toMatchObject({ code: 'INVOKE_TIMEOUT' });
137
+ + await tick(30);
138
+ +
139
+ + expect(h.sockets).toHaveLength(1);
140
+ + h.client.stop();
141
+ + });
142
+ +
143
+ + it('开启后:relay 已判定该 peer 离线时不重建(DEVICE_OFFLINE 是路由事实)', async () => {
144
+ + const h = makeHarness({
145
+ + timing: { pingIntervalMs: 60_000, requestTimeoutMs: 20, peerSilenceProbeMs: 35 },
146
+ + });
147
+ + h.client.start();
148
+ + await tick();
149
+ + const socket = h.current();
150
+ + socket.ack();
151
+ +
152
+ + const offline = h.client.invoke('dev-b', { channel: 'x', args: [] }, 20);
153
+ + const sent = socket.sent.find((e) => e.kind === 'invoke')!;
154
+ + socket.push({
155
+ + v: PROTOCOL_VERSION,
156
+ + kind: 'relay-error',
157
+ + id: sent.id,
158
+ + payload: { code: 'DEVICE_OFFLINE', message: 'offline' },
159
+ + });
160
+ + await expect(offline).rejects.toMatchObject({ code: 'DEVICE_OFFLINE' });
161
+ +
162
+ + await expect(h.client.invoke('dev-b', { channel: 'x', args: [] }, 20))
163
+ + .rejects.toMatchObject({ code: 'INVOKE_TIMEOUT' });
164
+ + await expect(h.client.invoke('dev-b', { channel: 'x', args: [] }, 20))
165
+ + .rejects.toMatchObject({ code: 'INVOKE_TIMEOUT' });
166
+ + await tick(30);
167
+ +
168
+ + expect(h.sockets).toHaveLength(1);
169
+ + h.client.stop();
170
+ + });
171
+ +
172
+ + it('开启后:回前台探针只在「确实在等对端回包」时才重建', async () => {
173
+ + vi.useFakeTimers();
174
+ + // peerSilenceProbeMs 取 1ms:这一条断言的是**闸门**(有没有 pending),
175
+ + // 不依赖窗口长度,避免在 fake timers 下把断言压在时钟精度上。
176
+ + const h = makeHarness({
177
+ + timing: { pingIntervalMs: 60_000, peerSilenceProbeMs: 1 },
178
+ + });
179
+ + try {
180
+ + h.client.start();
181
+ + await vi.advanceTimersByTimeAsync(0);
182
+ + const socket = h.current();
183
+ + socket.ack();
184
+ +
185
+ + // (1) 空闲回前台:没有在途请求 → 探针超时也不该拆掉健康的共享 socket。
186
+ + h.client.notifyNetworkChanged({ urgent: true });
187
+ + await vi.advanceTimersByTimeAsync(16_000);
188
+ + expect(h.sockets).toHaveLength(1);
189
+ +
190
+ + // (2) 有 pending 但**还没有任何超时证据** → 也不重建:长执行通道运行期间
191
+ + // 被控端本来就可能几十秒不发帧,那种请求不该被探针打成失败。
192
+ + const inFlight = h.client.invoke('dev-b', { channel: 'x', args: [] }, 60_000);
193
+ + void inFlight.catch(() => {});
194
+ + h.client.notifyNetworkChanged({ urgent: true });
195
+ + await vi.advanceTimersByTimeAsync(16_000);
196
+ + expect(h.sockets).toHaveLength(1);
197
+ +
198
+ + // (3) 已经有过一次「等不到对端而超时」+ 仍在等回包 → 探针超时后重建。
199
+ + const timedOut = h.client.invoke('dev-b', { channel: 'x', args: [] }, 20);
200
+ + const timedOutAssertion = expect(timedOut).rejects
201
+ + .toMatchObject({ code: 'INVOKE_TIMEOUT' });
202
+ + await vi.advanceTimersByTimeAsync(25);
203
+ + await timedOutAssertion;
204
+ + expect(h.sockets).toHaveLength(1);
205
+ +
206
+ + h.client.notifyNetworkChanged({ urgent: true });
207
+ + await vi.advanceTimersByTimeAsync(16_000);
208
+ + expect(h.sockets.length).toBeGreaterThan(1);
209
+ + } finally {
210
+ + h.client.stop();
211
+ + vi.useRealTimers();
212
+ + }
213
+ + });
214
+ +});
215
+ diff --git a/packages/device-link/src/client.ts b/packages/device-link/src/client.ts
216
+ index 165359098..169005ceb 100644
217
+ --- a/packages/device-link/src/client.ts
218
+ +++ b/packages/device-link/src/client.ts
219
+ @@ -247,6 +247,24 @@ export interface DeviceLinkTiming {
220
+ pongMissLimit: number;
221
+ /** invoke / link-open 默认等待响应时长 */
222
+ requestTimeoutMs: number;
223
+ + /**
224
+ + * peer 级静默探针窗口(ms,0 = 关闭)。
225
+ + *
226
+ + * 心跳判死(pongMisses)量的是**本机↔relay**:`pong` 由 relay 自己应答(线上契约),
227
+ + * 所以只要 relay 还在回 pong,对端方向整个哑掉也永远 online、永远不重建。
228
+ + * 2026-09-17 安卓端实测:挑照片回前台后 socket 半开,手机每秒重试同一个读、
229
+ + * 持续 2.5 分钟不恢复,只有重启 App 才恢复(手机侧 pingIntervalMs=10s /
230
+ + * pongMissLimit=1,10s 一次的 pong 恰好让它的半开检测永久失明)。
231
+ + *
232
+ + * 开启后追加一条**peer 级**判据:只有带 `src` 的 routed 帧才算「对端还在说话」
233
+ + * (pong/presence 由 relay 产生,不算),并且要求更强的证据才动手:
234
+ + * - 同一 peer **连续两个**请求超时(中间收到它的任何一帧即作废;单次慢请求不拆共享 socket);
235
+ + * - relay 未判定该 peer 离线(DEVICE_OFFLINE 是路由事实,不是 socket 问题);
236
+ + * - 同一窗口内最多重建一次。
237
+ + *
238
+ + * 默认 0 = 关闭,桌面端既有曲线不变;目前只有手机端显式开启。
239
+ + */
240
+ + peerSilenceProbeMs: number;
241
+ /**
242
+ * getToken 的等待上限。token 刷新可能走网络(移动端弱网下可能长时间无响应),
243
+ * 不设上限时 connect 会卡在 connecting 且没有任何重连计时器兜底。
244
+ @@ -305,6 +323,7 @@ const DEFAULT_TIMING: DeviceLinkTiming = {
245
+ // 允许弱网在一个额外周期内恢复;真正无响应仍由连续 miss + 无入站流量判定。
246
+ pongMissLimit: 3,
247
+ requestTimeoutMs: 30_000,
248
+ + peerSilenceProbeMs: 0,
249
+ getTokenTimeoutMs: 15_000,
250
+ handshakeTimeoutMs: 15_000,
251
+ transportRetryIntervalMs: TRANSPORT_RETRY_INTERVAL_MS,
252
+ @@ -670,6 +689,15 @@ export class DeviceLinkClient {
253
+ private pongMisses = 0;
254
+ /** 最近一次收到任何有效 relay 帧的时刻;避免把有业务流量的 socket 误判为僵死。 */
255
+ private lastInboundAt = 0;
256
+ + /**
257
+ + * 最近一次收到**对端帧**(带 relay 注入的 `src`)的时刻。relay 自己应答的
258
+ + * pong / presence 不算:它们只证明 relay 可达,不证明对端方向还通。
259
+ + */
260
+ + private lastPeerInboundAt = 0;
261
+ + /** peer 级静默重建的限速时间戳:同一窗口内最多重建一次,防重连风暴。 */
262
+ + private peerSilenceRebuildAt = 0;
263
+ + /** 每个 peer 的连续请求超时次数(收到该 peer 的帧即清零)。 */
264
+ + private readonly peerTimeoutStreak = new Map<string, number>();
265
+ private networkChangeTimer: ReturnType<typeof setTimeout> | null = null;
266
+ private networkProbeTimer: ReturnType<typeof setTimeout> | null = null;
267
+ private networkProbeStartedAt = 0;
268
+ @@ -857,7 +885,13 @@ export class DeviceLinkClient {
269
+ }
270
+ // Activity after the latest hint already proves this relay is reachable.
271
+ // Do not compare Wi-Fi labels: switching access points can keep them equal.
272
+ - if (this.lastInboundAt > this.networkChangeHintedAt) return;
273
+ + // peer 级探针开启时改用**对端**活性:relay 自己回的 pong 每 10s 刷新
274
+ + // lastInboundAt,拿它当可达证据会让这条探针在真机上恒为空转(实测见
275
+ + // peerSilenceProbeMs 的说明)。
276
+ + const activityAt = this.peerSilenceProbeEnabled()
277
+ + ? this.lastPeerInboundAt
278
+ + : this.lastInboundAt;
279
+ + if (activityAt > this.networkChangeHintedAt) return;
280
+ const epoch = this.connEpoch;
281
+ const socket = this.ws;
282
+ const startedAt = this.monotonicNow();
283
+ @@ -869,6 +903,17 @@ export class DeviceLinkClient {
284
+ this.networkProbeTimer = null;
285
+ if (this.stopped || this.connEpoch !== epoch || this.ws !== socket) return;
286
+ this.log.info(`network probe timed out (elapsedMs=${this.monotonicNow() - startedAt})`);
287
+ + const now = this.monotonicNow();
288
+ + // peer 级探针开启时,只有「本机确实在等对端回包、且对端一直没说话」才重建:
289
+ + // 空闲回前台本来就没有对端帧,为此重建共享 socket 只会带来无谓抖动。
290
+ + if (
291
+ + this.peerSilenceProbeEnabled()
292
+ + && !this.shouldRebuildForPeerSilence(now, undefined, { requirePending: true })
293
+ + ) {
294
+ + this.log.debug('network probe timed out without peer silence evidence; retaining socket');
295
+ + return;
296
+ + }
297
+ + if (this.peerSilenceProbeEnabled()) this.peerSilenceRebuildAt = now;
298
+ this.restartConnection('network-probe-timeout');
299
+ }, Math.max(DEFAULT_TIMING.handshakeTimeoutMs, this.timing.handshakeTimeoutMs));
300
+ try {
301
+ @@ -889,6 +934,74 @@ export class DeviceLinkClient {
302
+ this.networkProbeNextAllowedAt = 0;
303
+ }
304
+
305
+ + /** peer 级静默探针是否开启(默认关闭,桌面端既有曲线不变)。 */
306
+ + private peerSilenceProbeEnabled(): boolean {
307
+ + return this.timing.peerSilenceProbeMs > 0;
308
+ + }
309
+ +
310
+ + /**
311
+ + * 是否已能判定「对端方向哑到必须重建连接」。
312
+ + *
313
+ + * 刻意保守:relay 已判定该 peer 离线时不动手(DEVICE_OFFLINE 是路由事实,不是
314
+ + * socket 问题);同一窗口内最多重建一次,避免重连风暴。
315
+ + */
316
+ + private shouldRebuildForPeerSilence(
317
+ + now: number,
318
+ + dst: string | undefined,
319
+ + opts?: { requirePending?: boolean },
320
+ + ): boolean {
321
+ + const window = this.timing.peerSilenceProbeMs;
322
+ + if (!(window > 0)) return false;
323
+ + if (this.status !== 'online') return false;
324
+ + // 0 = 本次生命周期内还没重建过(单调时钟的绝对值可能很小,不能只比差值)。
325
+ + if (this.peerSilenceRebuildAt !== 0 && now - this.peerSilenceRebuildAt < window) return false;
326
+ + if (dst !== undefined && this.peerOfflineNotifiedGeneration.has(dst)) return false;
327
+ + if (opts?.requirePending === true && !this.hasPeerTimeoutEvidence()) return false;
328
+ + return now - this.lastPeerInboundAt >= window;
329
+ + }
330
+ +
331
+ + /**
332
+ + * 是否已经有「请求等不到对端而超时」的证据,且此刻仍有请求在等回包。
333
+ + *
334
+ + * 回前台探针只凭「有 pending」还不足以拆共享 socket:长执行通道
335
+ + * (desktop-cmd:run / worktree:create 等按通道放宽超时的请求)运行期间被控端
336
+ + * 本来就可能几十秒不发一个帧,那时重建会把**正在执行**的命令打成失败。
337
+ + * 超时证据天然排除这种请求——它们的超时被放宽到分钟级,不会先超时。
338
+ + */
339
+ + private hasPeerTimeoutEvidence(): boolean {
340
+ + if (this.pending.size === 0) return false;
341
+ + for (const [deviceId, streak] of this.peerTimeoutStreak) {
342
+ + if (streak < 1) continue;
343
+ + if (this.peerOfflineNotifiedGeneration.has(deviceId)) continue;
344
+ + return true;
345
+ + }
346
+ + return false;
347
+ + }
348
+ +
349
+ + /**
350
+ + * 记录一次请求超时,并在「同一 peer 连续两次超时 + 对端持续静默」时重建连接。
351
+ + *
352
+ + * 连续计数不需要额外的时间窗:收到该 peer 的任一帧(`handleFrame`)、或本次重建
353
+ + * 之后就清零,所以「连续两次」必然意味着这两次之间对端一个字都没说 —— 那正是要
354
+ + * 抓的形态。加窗口反而会在测试/CI 的真实开销下把证据判散。
355
+ + */
356
+ + private notePeerRequestTimeout(dst: string, now: number): void {
357
+ + const window = this.timing.peerSilenceProbeMs;
358
+ + if (!(window > 0)) return;
359
+ + const streak = (this.peerTimeoutStreak.get(dst) ?? 0) + 1;
360
+ + this.peerTimeoutStreak.set(dst, streak);
361
+ + if (streak < 2) return;
362
+ + if (!this.shouldRebuildForPeerSilence(now, dst)) return;
363
+ + this.peerSilenceRebuildAt = now;
364
+ + this.peerTimeoutStreak.delete(dst);
365
+ + this.log.warn(
366
+ + 'peer silence detected, forcing reconnect'
367
+ + + ` (peer=${dst}, silentForMs=${now - this.lastPeerInboundAt},`
368
+ + + ` pending=${this.pending.size}, timeouts=${streak})`,
369
+ + );
370
+ + this.restartConnection('peer-silence');
371
+ + }
372
+ +
373
+ /**
374
+ * 有界等待连接就绪。online 立即 resolve;否则订阅状态变化,在 timeoutMs 内
375
+ * 等到 online 就 resolve,超时 / stopped 则 reject(NOT_CONNECTED)。
376
+ @@ -1486,6 +1599,9 @@ export class DeviceLinkClient {
377
+ if (env.dst) this.settleOutboundRouteAttemptsForId(env.dst, id);
378
+ if (env.dst && env.kind === 'invoke') this.dropReliablePendingForRequest(env.dst, id);
379
+ logFinished('timeout');
380
+ + // peer 级探针:同一 peer 连续两个请求超时(中间对端一个字都没说),才认为
381
+ + // 「对端方向整体哑了」并重建共享 socket —— 单次慢请求(桌面正忙)不足以拆链路。
382
+ + if (env.dst) this.notePeerRequestTimeout(env.dst, this.monotonicNow());
383
+ reject(new DeviceLinkError('INVOKE_TIMEOUT', `no ${expectKind} within ${timeout}ms`));
384
+ }, timeout);
385
+
386
+ @@ -1910,6 +2026,9 @@ export class DeviceLinkClient {
387
+ }
388
+ this.pongMisses = 0;
389
+ this.lastInboundAt = this.monotonicNow();
390
+ + this.lastPeerInboundAt = this.lastInboundAt;
391
+ + // 新连接:上一代的连续超时计数不再适用(限速时间戳仍保留,防跨代重连风暴)。
392
+ + this.peerTimeoutStreak.clear();
393
+ this.pingTimer = setInterval(() => {
394
+ if (this.status !== 'online') return;
395
+ const now = this.monotonicNow();
396
+ @@ -2033,7 +2152,17 @@ export class DeviceLinkClient {
397
+ }
398
+ if (validForHeartbeat) {
399
+ this.lastInboundAt = this.monotonicNow();
400
+ - if (this.networkProbeTimer) {
401
+ + // relay 注入的 `src` = 这帧来自某个对端设备。只有这类帧能证明**对端方向**通;
402
+ + // pong/presence 由 relay 自己产生,只能证明 relay 可达。
403
+ + const peerSource = typeof env.src === 'string' && env.src.length > 0 ? env.src : null;
404
+ + if (peerSource !== null) {
405
+ + this.lastPeerInboundAt = this.lastInboundAt;
406
+ + // 对端又说话了:它的连续超时计数不再成立。
407
+ + this.peerTimeoutStreak.delete(peerSource);
408
+ + }
409
+ + // peer 级探针开启时,「relay 可达」也必须由对端帧来证明:relay 自己回的 pong
410
+ + // 每 10s 刷新 lastInboundAt,拿它当确认会让这条探针在真机上恒为空转。
411
+ + if (this.networkProbeTimer && (peerSource !== null || !this.peerSilenceProbeEnabled())) {
412
+ clearTimeout(this.networkProbeTimer);
413
+ this.networkProbeTimer = null;
414
+ this.networkProbeNextAllowedAt = this.lastInboundAt + 3_000;
415
+ --
416
+ 2.52.0.windows.1
417
+
418
+
419
+ From 308b4badc974dd79be598964bb809ff38fd5bf0f Mon Sep 17 00:00:00 2001
420
+ From: Yop <chanyuenpang@hotmail.com>
421
+ Date: Fri, 18 Sep 2026 16:28:55 +0800
422
+ Subject: [PATCH 2/5] =?UTF-8?q?fix(device-link):=20=E5=9B=9E=E5=89=8D?=
423
+ =?UTF-8?q?=E5=8F=B0=E6=8E=A2=E9=92=88=E4=B8=8D=E5=86=8D=E8=A6=81=E6=B1=82?=
424
+ =?UTF-8?q?=E3=80=8C=E5=85=88=E6=9C=89=E8=BF=87=E4=B8=80=E6=AC=A1=E8=AF=B7?=
425
+ =?UTF-8?q?=E6=B1=82=E8=B6=85=E6=97=B6=E3=80=8D?=
426
+ MIME-Version: 1.0
427
+ Content-Type: text/plain; charset=UTF-8
428
+ Content-Transfer-Encoding: 8bit
429
+
430
+ 现场(2026-09-18):装上带 peerSilenceProbeMs 的包后,后台断线仍然不恢复。原因是判据
431
+ 设计过严 —— 升级需要「同一 peer 连续两次请求超时」,而回前台后 App 往往只发一轮请求
432
+ (每个只超时一次),连续计数永远到不了 2;回前台探针那条路又额外要求「已存在超时证据」,
433
+ 于是两条路都永远不会重建。
434
+
435
+ 改为:探针超时(= 提示之后一个对端帧都没来)本身就是证据,只要此刻确实有**短超时**
436
+ 业务请求在等回包就重建。长执行通道(desktop-cmd:run / worktree:create 等分钟级超时)用
437
+ 请求自己的生效超时排除,不再依赖「先超时过一次」。
438
+
439
+ - PendingRequest 记录生效 timeoutMs
440
+ - hasPeerTimeoutEvidence → hasPromptAwaitingReply(短超时 = <= 窗口*2)
441
+ - 探针路径传 { requirePromptRequest: true, silenceProven: true }
442
+ - 单测:空闲回前台不重建 / 长执行通道不重建 / 短超时请求+探针超时 → 重建
443
+
444
+ device-link 414/414。
445
+
446
+ Signed-off-by: Yop <chanyuenpang@hotmail.com>
447
+ ---
448
+ .../device-link/src/__tests__/client.test.ts | 30 +++++------
449
+ packages/device-link/src/client.ts | 52 +++++++++++++------
450
+ 2 files changed, 48 insertions(+), 34 deletions(-)
451
+
452
+ diff --git a/packages/device-link/src/__tests__/client.test.ts b/packages/device-link/src/__tests__/client.test.ts
453
+ index 1f7c9f334..d6890a3c3 100644
454
+ --- a/packages/device-link/src/__tests__/client.test.ts
455
+ +++ b/packages/device-link/src/__tests__/client.test.ts
456
+ @@ -8853,12 +8853,14 @@ describe('peer 级静默探针', () => {
457
+ h.client.stop();
458
+ });
459
+
460
+ - it('开启后:回前台探针只在「确实在等对端回包」时才重建', async () => {
461
+ + it('开启后:回前台探针只在「有短超时请求在等回包」时才重建', async () => {
462
+ vi.useFakeTimers();
463
+ - // peerSilenceProbeMs 取 1ms:这一条断言的是**闸门**(有没有 pending),
464
+ - // 不依赖窗口长度,避免在 fake timers 下把断言压在时钟精度上。
465
+ + // 窗口 20s ⇒「短超时」的界线 = window*2 = 40s:25s 的普通请求算在等答复,
466
+ + // 10 分钟的长执行通道不算。现场修正(2026-09-18):原先探针还要求「先有过一次请求
467
+ + // 超时」,而回前台后 App 往往只发一轮请求(每个只超时一次),连续计数到不了 2,
468
+ + // 于是探针永远不会重建 —— 这正是「装了带修复的包仍然不恢复」。
469
+ const h = makeHarness({
470
+ - timing: { pingIntervalMs: 60_000, peerSilenceProbeMs: 1 },
471
+ + timing: { pingIntervalMs: 60_000, peerSilenceProbeMs: 20_000 },
472
+ });
473
+ try {
474
+ h.client.start();
475
+ @@ -8866,27 +8868,21 @@ describe('peer 级静默探针', () => {
476
+ const socket = h.current();
477
+ socket.ack();
478
+
479
+ - // (1) 空闲回前台:没有在途请求 → 探针超时也不该拆掉健康的共享 socket。
480
+ + // (1) 空闲回前台:没有在等答复 → 探针超时也不该拆掉健康的共享 socket。
481
+ h.client.notifyNetworkChanged({ urgent: true });
482
+ await vi.advanceTimersByTimeAsync(16_000);
483
+ expect(h.sockets).toHaveLength(1);
484
+
485
+ - // (2) 有 pending 但**还没有任何超时证据** → 也不重建:长执行通道运行期间
486
+ - // 被控端本来就可能几十秒不发帧,那种请求不该被探针打成失败。
487
+ - const inFlight = h.client.invoke('dev-b', { channel: 'x', args: [] }, 60_000);
488
+ - void inFlight.catch(() => {});
489
+ + // (2) 长执行通道在跑(分钟级超时)→ 不算「在等答复」,不拆 socket。
490
+ + const longRun = h.client.invoke('dev-b', { channel: 'desktop-cmd:run', args: [] }, 600_000);
491
+ + void longRun.catch(() => {});
492
+ h.client.notifyNetworkChanged({ urgent: true });
493
+ await vi.advanceTimersByTimeAsync(16_000);
494
+ expect(h.sockets).toHaveLength(1);
495
+
496
+ - // (3) 已经有过一次「等不到对端而超时」+ 仍在等回包 → 探针超时后重建。
497
+ - const timedOut = h.client.invoke('dev-b', { channel: 'x', args: [] }, 20);
498
+ - const timedOutAssertion = expect(timedOut).rejects
499
+ - .toMatchObject({ code: 'INVOKE_TIMEOUT' });
500
+ - await vi.advanceTimersByTimeAsync(25);
501
+ - await timedOutAssertion;
502
+ - expect(h.sockets).toHaveLength(1);
503
+ -
504
+ + // (3) 有短超时请求在等回包 + 探针已证明「这 15 秒一个对端帧都没来」→ 重建。
505
+ + const prompt = h.client.invoke('dev-b', { channel: 'x', args: [] }, 25_000);
506
+ + void prompt.catch(() => {});
507
+ h.client.notifyNetworkChanged({ urgent: true });
508
+ await vi.advanceTimersByTimeAsync(16_000);
509
+ expect(h.sockets.length).toBeGreaterThan(1);
510
+ diff --git a/packages/device-link/src/client.ts b/packages/device-link/src/client.ts
511
+ index 169005ceb..049c1d99a 100644
512
+ --- a/packages/device-link/src/client.ts
513
+ +++ b/packages/device-link/src/client.ts
514
+ @@ -502,6 +502,11 @@ interface PendingRequest {
515
+ reliableDst?: string;
516
+ /** 请求真正发出时所属的 peer link 代次,供迟到 relay-error 归属。 */
517
+ linkGeneration?: number;
518
+ + /**
519
+ + * 本次请求的生效超时。peer 级静默探针据此区分「App 在等一个答复」与
520
+ + * 「长执行通道在跑」——后者的超时被放宽到分钟级,不能拿来当重建证据。
521
+ + */
522
+ + timeoutMs?: number;
523
+ /** peer reset 时让 host 快速结束本次等待,由 host 重开后决定是否重试。 */
524
+ retryAfterPeerReset?: () => void;
525
+ }
526
+ @@ -904,13 +909,17 @@ export class DeviceLinkClient {
527
+ if (this.stopped || this.connEpoch !== epoch || this.ws !== socket) return;
528
+ this.log.info(`network probe timed out (elapsedMs=${this.monotonicNow() - startedAt})`);
529
+ const now = this.monotonicNow();
530
+ - // peer 级探针开启时,只有「本机确实在等对端回包、且对端一直没说话」才重建:
531
+ - // 空闲回前台本来就没有对端帧,为此重建共享 socket 只会带来无谓抖动。
532
+ + // peer 级探针开启时:探针本身已经证明「提示之后一个对端帧都没来」,只要此刻
533
+ + // 确实有**短超时**请求在等回包就重建;空闲回前台(没有在等答复)不重建,
534
+ + // 长执行通道(分钟级超时)也不算证据。
535
+ if (
536
+ this.peerSilenceProbeEnabled()
537
+ - && !this.shouldRebuildForPeerSilence(now, undefined, { requirePending: true })
538
+ + && !this.shouldRebuildForPeerSilence(now, undefined, {
539
+ + requirePromptRequest: true,
540
+ + silenceProven: true,
541
+ + })
542
+ ) {
543
+ - this.log.debug('network probe timed out without peer silence evidence; retaining socket');
544
+ + this.log.debug('network probe timed out without a prompt request awaiting the peer; retaining socket');
545
+ return;
546
+ }
547
+ if (this.peerSilenceProbeEnabled()) this.peerSilenceRebuildAt = now;
548
+ @@ -944,11 +953,18 @@ export class DeviceLinkClient {
549
+ *
550
+ * 刻意保守:relay 已判定该 peer 离线时不动手(DEVICE_OFFLINE 是路由事实,不是
551
+ * socket 问题);同一窗口内最多重建一次,避免重连风暴。
552
+ + *
553
+ + * @param opts.requirePromptRequest 只认「**短超时**业务请求在等回包」这条证据。
554
+ + * 回前台探针用它。2026-09-18 现场修正:原先探针还要求「先有过一次请求超时」,
555
+ + * 而回前台后 App 往往只发一轮请求(每个只超时一次),连续超时计数永远到不了 2,
556
+ + * 于是**探针永远不会重建** —— 这正是「装了带修复的包仍然不恢复」的原因。
557
+ + * @param opts.silenceProven 探针本身已证明「提示之后一个对端帧都没来」,不必再等满
558
+ + * 静默窗口(探针只等一个握手窗口,通常短于该窗口)。
559
+ */
560
+ private shouldRebuildForPeerSilence(
561
+ now: number,
562
+ dst: string | undefined,
563
+ - opts?: { requirePending?: boolean },
564
+ + opts?: { requirePromptRequest?: boolean; silenceProven?: boolean },
565
+ ): boolean {
566
+ const window = this.timing.peerSilenceProbeMs;
567
+ if (!(window > 0)) return false;
568
+ @@ -956,24 +972,25 @@ export class DeviceLinkClient {
569
+ // 0 = 本次生命周期内还没重建过(单调时钟的绝对值可能很小,不能只比差值)。
570
+ if (this.peerSilenceRebuildAt !== 0 && now - this.peerSilenceRebuildAt < window) return false;
571
+ if (dst !== undefined && this.peerOfflineNotifiedGeneration.has(dst)) return false;
572
+ - if (opts?.requirePending === true && !this.hasPeerTimeoutEvidence()) return false;
573
+ + if (opts?.requirePromptRequest === true && !this.hasPromptAwaitingReply()) return false;
574
+ + if (opts?.silenceProven === true) return true;
575
+ return now - this.lastPeerInboundAt >= window;
576
+ }
577
+
578
+ /**
579
+ - * 是否已经有「请求等不到对端而超时」的证据,且此刻仍有请求在等回包。
580
+ + * 是否有**短超时**业务请求在等回包 —— 即「App 此刻真的在等一个答复」。
581
+ *
582
+ - * 回前台探针只凭「有 pending」还不足以拆共享 socket:长执行通道
583
+ - * (desktop-cmd:run / worktree:create 等按通道放宽超时的请求)运行期间被控端
584
+ - * 本来就可能几十秒不发一个帧,那时重建会把**正在执行**的命令打成失败。
585
+ - * 超时证据天然排除这种请求——它们的超时被放宽到分钟级,不会先超时。
586
+ + * 长执行通道(`desktop-cmd:run` / `worktree:create` / `schedule` 等)的超时按通道
587
+ + * 放宽到分钟级,运行期间被控端本来就可能几十秒不发帧;把它们算成证据会让回前台
588
+ + * 探针把**正在执行**的命令打成失败。用请求自己的生效超时区分最直接。
589
+ */
590
+ - private hasPeerTimeoutEvidence(): boolean {
591
+ - if (this.pending.size === 0) return false;
592
+ - for (const [deviceId, streak] of this.peerTimeoutStreak) {
593
+ - if (streak < 1) continue;
594
+ - if (this.peerOfflineNotifiedGeneration.has(deviceId)) continue;
595
+ - return true;
596
+ + private hasPromptAwaitingReply(): boolean {
597
+ + const window = this.timing.peerSilenceProbeMs;
598
+ + if (!(window > 0)) return false;
599
+ + for (const request of this.pending.values()) {
600
+ + if (request.dst !== undefined && this.peerOfflineNotifiedGeneration.has(request.dst)) continue;
601
+ + const requestTimeout = request.timeoutMs ?? this.timing.requestTimeoutMs;
602
+ + if (requestTimeout <= window * 2) return true;
603
+ }
604
+ return false;
605
+ }
606
+ @@ -1619,6 +1636,7 @@ export class DeviceLinkClient {
607
+ timer,
608
+ expectKind,
609
+ dst: env.dst,
610
+ + timeoutMs: timeout,
611
+ linkGeneration: env.dst ? this.getPeerLinkGeneration(env.dst) : undefined,
612
+ };
613
+ this.pending.set(id, pendingRequest);
614
+ --
615
+ 2.52.0.windows.1
616
+
617
+
618
+ From bd4ed7e7338d665a0727962ed317039b273ea1d7 Mon Sep 17 00:00:00 2001
619
+ From: Yop <chanyuenpang@hotmail.com>
620
+ Date: Fri, 18 Sep 2026 16:53:07 +0800
621
+ Subject: [PATCH 3/5] =?UTF-8?q?fix(device-link):=20=E5=BF=83=E8=B7=B3?=
622
+ =?UTF-8?q?=E5=91=A8=E6=9C=9F=E4=B9=9F=E6=A3=80=E6=9F=A5=E5=AF=B9=E7=AB=AF?=
623
+ =?UTF-8?q?=E9=9D=99=E9=BB=98(App=20=E4=B8=80=E7=9B=B4=E5=BE=85=E5=9C=A8?=
624
+ =?UTF-8?q?=E5=89=8D=E5=8F=B0=E6=97=B6=E5=94=AF=E4=B8=80=E4=BC=9A=E8=B7=91?=
625
+ =?UTF-8?q?=E7=9A=84=E5=88=A4=E6=8D=AE)?=
626
+ MIME-Version: 1.0
627
+ Content-Type: text/plain; charset=UTF-8
628
+ Content-Transfer-Encoding: 8bit
629
+
630
+ 现场(2026-09-18):用户停在前台盯着看,手机上行通、下行死。前两条路都有前提没被满足:
631
+ - 回前台探针只在 AppState 变化时跑 —— 他已经在前台,没有新的前台事件;
632
+ - 超时判据要求「真的完成过一次超时」,而 App 反复重发同一个读时会把上一个在途请求取消
633
+ (实测同一秒连发 4 次 local-db:messages:view、答复字节完全相同),永远不产生超时。
634
+ 于是连接一直不自愈。
635
+
636
+ 补上心跳周期里的兜底检查(手机端 pingIntervalMs=10s,待在前台也照跑),与探针共用同一套
637
+ 护栏:必须在等**短超时**请求的回包、对端静默超过窗口、中继未判该 peer 离线、同一窗口内
638
+ 只重建一次。
639
+
640
+ 新增单测:空闲心跳不重建 / 有短超时请求在等回包+对端静默 → 下一个心跳周期就重建。
641
+ device-link 415/415。
642
+
643
+ Signed-off-by: Yop <chanyuenpang@hotmail.com>
644
+ ---
645
+ .../device-link/src/__tests__/client.test.ts | 32 +++++++++++++++++++
646
+ packages/device-link/src/client.ts | 19 +++++++++++
647
+ 2 files changed, 51 insertions(+)
648
+
649
+ diff --git a/packages/device-link/src/__tests__/client.test.ts b/packages/device-link/src/__tests__/client.test.ts
650
+ index d6890a3c3..b5830390d 100644
651
+ --- a/packages/device-link/src/__tests__/client.test.ts
652
+ +++ b/packages/device-link/src/__tests__/client.test.ts
653
+ @@ -8891,4 +8891,36 @@ describe('peer 级静默探针', () => {
654
+ vi.useRealTimers();
655
+ }
656
+ });
657
+ +
658
+ + it('开启后:App 一直待在前台时,心跳周期也会发现对端静默并重建', async () => {
659
+ + vi.useFakeTimers();
660
+ + // 现场补丁(2026-09-18):回前台探针只在 AppState 变化时跑;超时判据要求「真的完成过
661
+ + // 一次超时」,而 App 反复重发同一个读会把上一个在途请求取消掉 —— 用户停在前台盯着时
662
+ + // 两条路都不跑,连接永远不自愈。这条断言心跳周期里的兜底判据。
663
+ + const h = makeHarness({
664
+ + timing: {
665
+ + pingIntervalMs: 1_000,
666
+ + pongMissLimit: 99, // 摘掉 socket 判死,单独考察 peer 级判据
667
+ + peerSilenceProbeMs: 800,
668
+ + },
669
+ + });
670
+ + try {
671
+ + h.client.start();
672
+ + await vi.advanceTimersByTimeAsync(0);
673
+ + h.current().ack();
674
+ +
675
+ + // (1) 空闲:没有在等答复 → 心跳只发 ping,不重建。
676
+ + await vi.advanceTimersByTimeAsync(3_000);
677
+ + expect(h.sockets).toHaveLength(1);
678
+ +
679
+ + // (2) 有短超时请求在等回包、对端一个帧都没发 → 下一个心跳周期就重建。
680
+ + const waiting = h.client.invoke('dev-b', { channel: 'x', args: [] }, 1_400);
681
+ + void waiting.catch(() => {});
682
+ + await vi.advanceTimersByTimeAsync(1_100);
683
+ + expect(h.sockets.length).toBeGreaterThan(1);
684
+ + } finally {
685
+ + h.client.stop();
686
+ + vi.useRealTimers();
687
+ + }
688
+ + });
689
+ });
690
+ diff --git a/packages/device-link/src/client.ts b/packages/device-link/src/client.ts
691
+ index 049c1d99a..31f3e5745 100644
692
+ --- a/packages/device-link/src/client.ts
693
+ +++ b/packages/device-link/src/client.ts
694
+ @@ -2074,6 +2074,25 @@ export class DeviceLinkClient {
695
+ this.handleDisconnect(1006, 'heartbeat lost');
696
+ return;
697
+ }
698
+ + // peer 级静默探针:心跳周期里也查一次 —— 这是**唯一在"App 一直待在前台"
699
+ + // 时也会跑**的判据。另外两条路都有前提:回前台探针只在 AppState 变化时跑,
700
+ + // 超时判据要求「真的完成过一次超时」(而 App 反复重发同一个读时会把上一个
701
+ + // 在途请求取消掉,永远不产生超时)。现场 2026-09-18:用户停在前台盯着,
702
+ + // 手机上行通、下行死,这两条都没跑到,于是连接一直不自愈。
703
+ + // 护栏不变:必须在等**短超时**请求的回包(长执行通道不算)、对端静默超过窗口、
704
+ + // 中继未判该 peer 离线、且同一窗口内只重建一次。
705
+ + if (
706
+ + this.peerSilenceProbeEnabled()
707
+ + && this.shouldRebuildForPeerSilence(now, undefined, { requirePromptRequest: true })
708
+ + ) {
709
+ + this.peerSilenceRebuildAt = now;
710
+ + this.log.warn(
711
+ + 'peer silence detected on heartbeat, forcing reconnect'
712
+ + + ` (silentForMs=${now - this.lastPeerInboundAt}, pending=${this.pending.size})`,
713
+ + );
714
+ + this.restartConnection('peer-silence');
715
+ + return;
716
+ + }
717
+ try {
718
+ this.sendEnvelope({ v: PROTOCOL_VERSION, kind: 'ping' });
719
+ } catch {
720
+ --
721
+ 2.52.0.windows.1
722
+
723
+
724
+ From b72f15a61c0679244a106a6bb71f50fc477ed137 Mon Sep 17 00:00:00 2001
725
+ From: Yop <chanyuenpang@hotmail.com>
726
+ Date: Fri, 18 Sep 2026 16:58:25 +0800
727
+ Subject: [PATCH 4/5] =?UTF-8?q?fix(device-link):=20peer=20=E7=BA=A7?=
728
+ =?UTF-8?q?=E9=9D=99=E9=BB=98=E6=94=B9=E7=94=A8=E7=B4=AF=E8=AE=A1=E5=88=A4?=
729
+ =?UTF-8?q?=E6=8D=AE=20+=20=E7=BB=8F=E4=B8=8A=E8=A1=8C=E8=87=AA=E6=8A=A5?=
730
+ =?UTF-8?q?=E5=86=85=E9=83=A8=E7=8A=B6=E6=80=81?=
731
+ MIME-Version: 1.0
732
+ Content-Type: text/plain; charset=UTF-8
733
+ Content-Transfer-Encoding: 8bit
734
+
735
+ 现场(2026-09-18 第三轮):用户在外地,手机上行通、下行死,停在会话界面时连接永不
736
+ 自愈。前两条路在真实场景里都没被满足:
737
+ - 回前台探针只在 AppState 变化时跑(用户一直待在前台,没有新事件);
738
+ - 超时判据要求「真的完成过一次超时」,而 App 反复重发同一个读时会把上一个在途请求
739
+ 取消/替换(实测同一秒连发 4 次 local-db:messages:view、答复字节完全相同),
740
+ 永远不产生超时。
741
+
742
+ 改动:
743
+ 1. 心跳周期(手机端 10s,前台也跑)按周期累计「这一周期对端一个帧都没来 + 此刻确实
744
+ 有短超时请求在等回包」的时长,累计满窗口即重建;收到对端帧或没有请求在等就清零。
745
+ 瞬时判据会因 pending 被反复替换而漏,tick 累加不会。
746
+ 2. 新增 sendPeerSilenceDiagnostic():对端静默时经**上行**把内部状态编码进通道名发出
747
+ (`psdiag.s<静默10s桶>.p<pending>.a<是否有短超时请求在等>.c<累计秒>.o<中继是否判离线>`),
748
+ Host 会把它记进 recentRefusals —— 远端真机没 USB、拿不到 logcat 时,这是唯一能读到
749
+ App 内部状态的通道。纯旁路,失败不影响连接。
750
+ 3. 单测:App 反复替换在途请求时,累计判据仍能在心跳里重建(共 7 条 peer 级探针测试)。
751
+
752
+ device-link 416/416。
753
+
754
+ Signed-off-by: Yop <chanyuenpang@hotmail.com>
755
+ ---
756
+ .../device-link/src/__tests__/client.test.ts | 23 +++++
757
+ packages/device-link/src/client.ts | 94 ++++++++++++++++---
758
+ 2 files changed, 102 insertions(+), 15 deletions(-)
759
+
760
+ diff --git a/packages/device-link/src/__tests__/client.test.ts b/packages/device-link/src/__tests__/client.test.ts
761
+ index b5830390d..55bf73725 100644
762
+ --- a/packages/device-link/src/__tests__/client.test.ts
763
+ +++ b/packages/device-link/src/__tests__/client.test.ts
764
+ @@ -8923,4 +8923,27 @@ describe('peer 级静默探针', () => {
765
+ vi.useRealTimers();
766
+ }
767
+ });
768
+ +
769
+ + it('开启后:App 反复替换在途请求时,累计判据也能在心跳里重建', async () => {
770
+ + vi.useFakeTimers();
771
+ + // 现场(2026-09-18):App 反复重发同一个读 → pending 集合不断被替换,任何「此刻必须
772
+ + // 存在 X」的瞬时判据都会在某些 tick 落空。累计口径按心跳周期累加「等回包 + 对端
773
+ + // 静默」的时长,累计满窗口即重建。
774
+ + const h = makeHarness({
775
+ + timing: { pingIntervalMs: 1_000, pongMissLimit: 99, peerSilenceProbeMs: 2_000 },
776
+ + });
777
+ + try {
778
+ + h.client.start();
779
+ + await vi.advanceTimersByTimeAsync(0);
780
+ + h.current().ack();
781
+ + // 短超时(<= 窗口*2)请求一直挂着;两个心跳周期后累计满窗口
782
+ + const waiting = h.client.invoke('dev-b', { channel: 'x', args: [] }, 3_500);
783
+ + void waiting.catch(() => {});
784
+ + await vi.advanceTimersByTimeAsync(2_100);
785
+ + expect(h.sockets.length).toBeGreaterThan(1);
786
+ + } finally {
787
+ + h.client.stop();
788
+ + vi.useRealTimers();
789
+ + }
790
+ + });
791
+ });
792
+ diff --git a/packages/device-link/src/client.ts b/packages/device-link/src/client.ts
793
+ index 31f3e5745..18824127d 100644
794
+ --- a/packages/device-link/src/client.ts
795
+ +++ b/packages/device-link/src/client.ts
796
+ @@ -701,6 +701,13 @@ export class DeviceLinkClient {
797
+ private lastPeerInboundAt = 0;
798
+ /** peer 级静默重建的限速时间戳:同一窗口内最多重建一次,防重连风暴。 */
799
+ private peerSilenceRebuildAt = 0;
800
+ + /**
801
+ + * 「有短超时请求在等回包 + 这一心跳周期内对端一个帧都没来」的累计时长。
802
+ + *
803
+ + * 用**累计**而不是瞬时快照:App 反复重发同一个读时会不断替换 pending 集合,
804
+ + * 「此刻必须存在 X」的瞬时条件会在某些 tick 落空,于是判据永远不成立。
805
+ + */
806
+ + private peerSilenceAccumMs = 0;
807
+ /** 每个 peer 的连续请求超时次数(收到该 peer 的帧即清零)。 */
808
+ private readonly peerTimeoutStreak = new Map<string, number>();
809
+ private networkChangeTimer: ReturnType<typeof setTimeout> | null = null;
810
+ @@ -964,7 +971,7 @@ export class DeviceLinkClient {
811
+ private shouldRebuildForPeerSilence(
812
+ now: number,
813
+ dst: string | undefined,
814
+ - opts?: { requirePromptRequest?: boolean; silenceProven?: boolean },
815
+ + opts?: { requirePromptRequest?: boolean; silenceProven?: boolean; accumulatedSilence?: boolean },
816
+ ): boolean {
817
+ const window = this.timing.peerSilenceProbeMs;
818
+ if (!(window > 0)) return false;
819
+ @@ -974,6 +981,8 @@ export class DeviceLinkClient {
820
+ if (dst !== undefined && this.peerOfflineNotifiedGeneration.has(dst)) return false;
821
+ if (opts?.requirePromptRequest === true && !this.hasPromptAwaitingReply()) return false;
822
+ if (opts?.silenceProven === true) return true;
823
+ + // 累计口径:心跳周期里已累计出「等回包 + 对端静默」超过窗口(现场主路径)。
824
+ + if (opts?.accumulatedSilence === true) return this.peerSilenceAccumMs >= window;
825
+ return now - this.lastPeerInboundAt >= window;
826
+ }
827
+
828
+ @@ -995,6 +1004,45 @@ export class DeviceLinkClient {
829
+ return false;
830
+ }
831
+
832
+ + /**
833
+ + * 把 peer 级静默的内部状态经**上行**通道报给 Host。
834
+ + *
835
+ + * 为什么用通道名承载数据:Host 把未知通道记进 `recentRefusals`
836
+ + * (`{at, channel, src, code}`),而**上行是通的**、下行才是坏的。远端真机、没有
837
+ + * USB、拿不到 logcat 时,这是唯一能读到 App 内部状态的通道。
838
+ + *
839
+ + * 编码(取值都被装进有限桶,避免 refusalTotals 的键无限增长):
840
+ + * `psdiag.s<静默秒:10 秒桶,≥60 记 60>.p<pending 数 0-9>.a<是否有短超时请求在等>.c<累计静默秒 0-99>.o<中继是否判该 peer 离线>`
841
+ + *
842
+ + * 诊断永远是旁路:整段 try/catch,失败不触碰连接状态。
843
+ + */
844
+ + private sendPeerSilenceDiagnostic(now: number): void {
845
+ + try {
846
+ + const dst = this.peerTransport.keys().next().value as string | undefined;
847
+ + if (dst === undefined) return;
848
+ + const silentSeconds = Math.round((now - this.lastPeerInboundAt) / 1000);
849
+ + const silentBucket = silentSeconds >= 60
850
+ + ? '60'
851
+ + : String(Math.floor(silentSeconds / 10) * 10).padStart(2, '0');
852
+ + const pending = Math.min(this.pending.size, 9);
853
+ + const awaiting = this.hasPromptAwaitingReply() ? 1 : 0;
854
+ + const accumulated = Math.min(Math.round(this.peerSilenceAccumMs / 1000), 99);
855
+ + const offlineMarked = this.peerOfflineNotifiedGeneration.size > 0 ? 1 : 0;
856
+ + this.sendEnvelope({
857
+ + v: PROTOCOL_VERSION,
858
+ + kind: 'invoke',
859
+ + id: createRequestId(),
860
+ + dst,
861
+ + payload: {
862
+ + channel: `psdiag.s${silentBucket}.p${pending}.a${awaiting}.c${accumulated}.o${offlineMarked}`,
863
+ + args: [],
864
+ + },
865
+ + });
866
+ + } catch {
867
+ + // 诊断失败不影响主流程
868
+ + }
869
+ + }
870
+ +
871
+ /**
872
+ * 记录一次请求超时,并在「同一 peer 连续两次超时 + 对端持续静默」时重建连接。
873
+ *
874
+ @@ -2047,6 +2095,8 @@ export class DeviceLinkClient {
875
+ this.lastPeerInboundAt = this.lastInboundAt;
876
+ // 新连接:上一代的连续超时计数不再适用(限速时间戳仍保留,防跨代重连风暴)。
877
+ this.peerTimeoutStreak.clear();
878
+ + // 上一个心跳周期的时刻,用于累计「这一周期内对端有没有说过话」。
879
+ + let prevTickAt = this.monotonicNow();
880
+ this.pingTimer = setInterval(() => {
881
+ if (this.status !== 'online') return;
882
+ const now = this.monotonicNow();
883
+ @@ -2078,21 +2128,35 @@ export class DeviceLinkClient {
884
+ // 时也会跑**的判据。另外两条路都有前提:回前台探针只在 AppState 变化时跑,
885
+ // 超时判据要求「真的完成过一次超时」(而 App 反复重发同一个读时会把上一个
886
+ // 在途请求取消掉,永远不产生超时)。现场 2026-09-18:用户停在前台盯着,
887
+ - // 手机上行通、下行死,这两条都没跑到,于是连接一直不自愈。
888
+ - // 护栏不变:必须在等**短超时**请求的回包(长执行通道不算)、对端静默超过窗口、
889
+ - // 中继未判该 peer 离线、且同一窗口内只重建一次。
890
+ - if (
891
+ - this.peerSilenceProbeEnabled()
892
+ - && this.shouldRebuildForPeerSilence(now, undefined, { requirePromptRequest: true })
893
+ - ) {
894
+ - this.peerSilenceRebuildAt = now;
895
+ - this.log.warn(
896
+ - 'peer silence detected on heartbeat, forcing reconnect'
897
+ - + ` (silentForMs=${now - this.lastPeerInboundAt}, pending=${this.pending.size})`,
898
+ - );
899
+ - this.restartConnection('peer-silence');
900
+ - return;
901
+ + // 手机上行通、下行死,这两条都没跑,于是连接一直不自愈。
902
+ + //
903
+ + // 判据用**累计**口径:App 重发读时会不断替换 pending 集合,任何「此刻必须
904
+ + // 存在 X」的瞬时条件都会在某个 tick 落空。这里按心跳周期累计「这一周期对端
905
+ + // 一个帧都没来 + 此刻确实有短超时请求在等回包」的时长,累计满窗口即重建;
906
+ + // 只要有一个周期收到对端帧、或没有请求在等,就清零。
907
+ + if (this.peerSilenceProbeEnabled()) {
908
+ + const peerFrameSincePrevTick = this.lastPeerInboundAt > prevTickAt;
909
+ + if (peerFrameSincePrevTick || !this.hasPromptAwaitingReply()) {
910
+ + this.peerSilenceAccumMs = 0;
911
+ + } else {
912
+ + this.peerSilenceAccumMs += now - prevTickAt;
913
+ + }
914
+ + // 对端静默时把内部状态经上行报给 Host(down 方向坏了,up 方向是好的)。
915
+ + if (now - this.lastPeerInboundAt >= this.timing.pingIntervalMs) {
916
+ + this.sendPeerSilenceDiagnostic(now);
917
+ + }
918
+ + if (this.shouldRebuildForPeerSilence(now, undefined, { accumulatedSilence: true })) {
919
+ + this.peerSilenceRebuildAt = now;
920
+ + this.peerSilenceAccumMs = 0;
921
+ + this.log.warn(
922
+ + 'peer silence detected on heartbeat, forcing reconnect'
923
+ + + ` (silentForMs=${now - this.lastPeerInboundAt}, pending=${this.pending.size})`,
924
+ + );
925
+ + this.restartConnection('peer-silence');
926
+ + return;
927
+ + }
928
+ }
929
+ + prevTickAt = now;
930
+ try {
931
+ this.sendEnvelope({ v: PROTOCOL_VERSION, kind: 'ping' });
932
+ } catch {
933
+ --
934
+ 2.52.0.windows.1
935
+
936
+
937
+ From 94f083331dc8287472902e468dafa1c0012db594 Mon Sep 17 00:00:00 2001
938
+ From: Yop <chanyuenpang@hotmail.com>
939
+ Date: Fri, 18 Sep 2026 18:05:47 +0800
940
+ Subject: [PATCH 5/5] =?UTF-8?q?fix(mobile):=20=E5=AF=B9=E7=AB=AF=E6=B4=BB?=
941
+ =?UTF-8?q?=E6=80=A7=E7=9C=8B=E9=97=A8=E7=8B=97=20=E2=80=94=E2=80=94=20?=
942
+ =?UTF-8?q?=E5=8D=A1=E4=BD=8F=E6=97=B6=E4=B8=BB=E5=8A=A8=E6=8E=A2=E4=B8=80?=
943
+ =?UTF-8?q?=E6=AC=A1,=E8=80=8C=E4=B8=8D=E6=98=AF=E7=AD=89=E3=80=8C?=
944
+ =?UTF-8?q?=E6=9C=89=E5=9C=A8=E9=80=94=E8=AF=B7=E6=B1=82=E3=80=8D?=
945
+ MIME-Version: 1.0
946
+ Content-Type: text/plain; charset=UTF-8
947
+ Content-Transfer-Encoding: 8bit
948
+
949
+ 真机实测(2026-09-18,手机自报 psdiag 共 21 条,全部 p0.a0.c0):手机卡住时 client
950
+ 的 pending 是**空的** —— App 自己不再发请求、也没有在等回包,而中继仍认为它 online。
951
+ 所以此前三条判据都永远不触发:
952
+ - 连续两次请求超时(没有请求);
953
+ - 回前台探针要求「有短超时请求在等回包」(没有);
954
+ - 心跳累计同样要求 pending 非空(没有)。
955
+ socket 级心跳也因为 relay 正常回 pong 而失明。
956
+
957
+ 改为在 App 侧加一个**与页面轮询循环无关**的独立定时器(页面循环停掉它照跑):
958
+ 前台 + client online + 对端静默 ≥20s + 该设备已有已建立的 link
959
+ → 主动发一个最便宜的读(`maker:list-active`,5s 超时)
960
+ → 回包到了 = 对端活着(空闲会话不会被误重建)
961
+ → 没回包 = 下行真的死了 → restartConnection('peer-liveness-probe') + rehydrate。
962
+
963
+ - client 暴露 peerSilenceMs():只有对端帧(带 src)刷新它,relay 的 pong 不算;
964
+ - 看门狗直接用 client.invoke(绕过 App 的熔断/排队门禁),否则门禁关着时它自己也发不出去;
965
+ - 单测:peerSilenceMs 的 pong 不刷新语义;device-link 417/417。
966
+
967
+ Signed-off-by: Yop <chanyuenpang@hotmail.com>
968
+ ---
969
+ .../src/device-link/DeviceLinkContext.tsx | 59 +++++++++++++++++++
970
+ .../device-link/src/__tests__/client.test.ts | 23 ++++++++
971
+ packages/device-link/src/client.ts | 14 +++++
972
+ 3 files changed, 96 insertions(+)
973
+
974
+ diff --git a/apps/mobile/src/device-link/DeviceLinkContext.tsx b/apps/mobile/src/device-link/DeviceLinkContext.tsx
975
+ index 71855eeba..cf093e3ff 100644
976
+ --- a/apps/mobile/src/device-link/DeviceLinkContext.tsx
977
+ +++ b/apps/mobile/src/device-link/DeviceLinkContext.tsx
978
+ @@ -271,6 +271,25 @@ const BACKGROUND_FINAL_UNSUBSCRIBE_WAIT_MS = 1_000;
979
+ */
980
+ const BACKGROUND_SUSPEND_SUSPECT_MS = 10_000;
981
+
982
+ +/**
983
+ + * 对端活性看门狗:卡住时唯一的可靠证据。
984
+ + *
985
+ + * 2026-09-18 真机实测(手机自报 `psdiag` 21 条,全部 `p0.a0.c0`):手机卡住时
986
+ + * client 的 `pending` 是**空的** —— App 自己不再发请求、也没有在等回包,而中继仍认为
987
+ + * 它是 online。所以:
988
+ + * - 「等回包」型判据(超时计数 / 探针要求有在途请求)永远不会触发;
989
+ + * - socket 级心跳也因为 relay 正常回 pong 而失明。
990
+ + * 唯一能自己造出证据的办法是**主动问一次**:静默超过阈值就发一个最便宜的读,
991
+ + * 回包到了 = 对端活着(空闲会话不会被误重建),短超时内没回包 = 下行真的死了。
992
+ + */
993
+ +const PEER_LIVENESS_SILENCE_MS = 20_000;
994
+ +/** 检查周期:要短于静默阈值,否则一次故障要等两个周期才被发现。 */
995
+ +const PEER_LIVENESS_INTERVAL_MS = 10_000;
996
+ +/** 探测本身的超时:远小于正常请求,回包该在毫秒级到。 */
997
+ +const PEER_LIVENESS_TIMEOUT_MS = 5_000;
998
+ +/** 最便宜的被控端读通道(`maker:list-active` = 每个会话一个布尔)。 */
999
+ +const PEER_LIVENESS_CHANNEL = 'maker:list-active';
1000
+ +
1001
+ /** 桌面端 presence 闪断宽限:短暂离线不立刻清空该设备的会话镜像与能力缓存。 */
1002
+ const PRESENCE_OFFLINE_WIPE_GRACE_MS = 5_000;
1003
+
1004
+ @@ -1252,8 +1271,48 @@ export function DeviceLinkProvider({ children }: { children: ReactNode }) {
1005
+ console.warn('[device-link] network listener unavailable; using heartbeat recovery');
1006
+ });
1007
+
1008
+ + // 对端活性看门狗:与页面轮询循环**无关**的独立定时器 —— 页面循环停了它也照跑。
1009
+ + // 触发条件刻意不依赖「有在途请求」(真机实测卡住时 pending 是空的),而是主动问一次。
1010
+ + const livenessTimer = setInterval(() => {
1011
+ + const current = clientRef.current;
1012
+ + if (current === null || current.getStatus() !== 'online') return;
1013
+ + if (AppState.currentState !== 'active') return;
1014
+ + const silenceMs = current.peerSilenceMs();
1015
+ + if (silenceMs < PEER_LIVENESS_SILENCE_MS) return;
1016
+ + // 只探「已经建立了 link」的设备:没有 link 就没有可失去的下行。
1017
+ + let target: string | null = null;
1018
+ + for (const plan of registryRef.current.snapshot()) {
1019
+ + if (
1020
+ + typeof plan.deviceId === 'string'
1021
+ + && plan.deviceId !== ''
1022
+ + && current.getPeerLinkGeneration(plan.deviceId) > 0
1023
+ + ) {
1024
+ + target = plan.deviceId;
1025
+ + break;
1026
+ + }
1027
+ + }
1028
+ + if (target === null) return;
1029
+ + const probedDevice = target;
1030
+ + mobileDebugLog('debug', 'device-link', 'peer liveness probe', {
1031
+ + deviceId: probedDevice,
1032
+ + silenceMs: Math.round(silenceMs),
1033
+ + });
1034
+ + void current
1035
+ + .invoke(probedDevice, { channel: PEER_LIVENESS_CHANNEL, args: [] }, PEER_LIVENESS_TIMEOUT_MS)
1036
+ + .catch((error: unknown) => {
1037
+ + const code = (error as { code?: string } | null)?.code;
1038
+ + mobileDebugLog('warn', 'device-link', 'peer liveness probe failed; rebuilding connection', {
1039
+ + deviceId: probedDevice,
1040
+ + code: code ?? String((error as { message?: string } | null)?.message ?? error),
1041
+ + });
1042
+ + current.restartConnection('peer-liveness-probe');
1043
+ + void rehydrateWithClient(current);
1044
+ + });
1045
+ + }, PEER_LIVENESS_INTERVAL_MS);
1046
+ +
1047
+ return () => {
1048
+ disposed = true;
1049
+ + clearInterval(livenessTimer);
1050
+ diagnostics.background();
1051
+ recoveryDiagnostics.delete(client);
1052
+ networkSubscription?.remove();
1053
+ diff --git a/packages/device-link/src/__tests__/client.test.ts b/packages/device-link/src/__tests__/client.test.ts
1054
+ index 55bf73725..575a155ca 100644
1055
+ --- a/packages/device-link/src/__tests__/client.test.ts
1056
+ +++ b/packages/device-link/src/__tests__/client.test.ts
1057
+ @@ -8946,4 +8946,27 @@ describe('peer 级静默探针', () => {
1058
+ vi.useRealTimers();
1059
+ }
1060
+ });
1061
+ +
1062
+ + it('peerSilenceMs:只有对端帧刷新它,relay 自己回的 pong 不算', async () => {
1063
+ + // 控制器用这个读数决定「静默够久,该主动探一次了」。若 pong 也能刷新它,
1064
+ + // relay 每 10s 的 pong 会让它永远为 0,看门狗就永远不会开火。
1065
+ + const h = makeHarness({ timing: { pingIntervalMs: 60_000, peerSilenceProbeMs: 20_000 } });
1066
+ + h.client.start();
1067
+ + await tick();
1068
+ + const socket = h.current();
1069
+ + socket.ack();
1070
+ + await tick(25);
1071
+ + const afterQuiet = h.client.peerSilenceMs();
1072
+ + expect(afterQuiet).toBeGreaterThan(0);
1073
+ + socket.push({ v: PROTOCOL_VERSION, kind: 'pong' });
1074
+ + expect(h.client.peerSilenceMs()).toBeGreaterThan(0);
1075
+ + socket.push({
1076
+ + v: PROTOCOL_VERSION,
1077
+ + kind: 'push',
1078
+ + src: 'dev-b',
1079
+ + payload: { channel: 'demo', payload: {} },
1080
+ + });
1081
+ + expect(h.client.peerSilenceMs()).toBeLessThan(afterQuiet);
1082
+ + h.client.stop();
1083
+ + });
1084
+ });
1085
+ diff --git a/packages/device-link/src/client.ts b/packages/device-link/src/client.ts
1086
+ index 18824127d..502409d99 100644
1087
+ --- a/packages/device-link/src/client.ts
1088
+ +++ b/packages/device-link/src/client.ts
1089
+ @@ -1217,6 +1217,20 @@ export class DeviceLinkClient {
1090
+ return this.peerTransport.get(deviceId)?.linkGeneration ?? 0;
1091
+ }
1092
+
1093
+ + /**
1094
+ + * 距离最近一次收到**对端帧**(带 relay 注入的 `src`)过了多久,毫秒。
1095
+ + *
1096
+ + * 控制器用它做「主动活性探测」的触发条件。2026-09-18 真机实测:手机卡住时
1097
+ + * `pending` 是空的(它自己不再发请求、也没有在等回包),所以任何「等回包」型判据
1098
+ + * 都不可能触发 —— 必须在静默超时后**自己发一个问题**去问对端。
1099
+ + *
1100
+ + * 从未收到过对端帧时返回 0(免得刚连上就被判定静默)。
1101
+ + */
1102
+ + peerSilenceMs(): number {
1103
+ + if (this.lastPeerInboundAt === 0) return 0;
1104
+ + return Math.max(0, this.monotonicNow() - this.lastPeerInboundAt);
1105
+ + }
1106
+ +
1107
+ /** Subscribe to typed peer route lifecycle changes (currently authoritative offline). */
1108
+ onPeerRouteStateChanged(
1109
+ cb: (change: DeviceLinkPeerRouteStateChanged) => void,
1110
+ --
1111
+ 2.52.0.windows.1
1112
+