dsh-plugin-mobile-link 0.4.1 → 0.6.5

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/journal.js ADDED
@@ -0,0 +1,279 @@
1
+ /**
2
+ * 链路与设备的生命周期流水。
3
+ *
4
+ * 为什么需要它(2026-10-12/13 那次"手机连不上"的教训):连接器的日志走宿主
5
+ * `ctx.logger`,而宿主既不落盘也不保留历史,于是"连接器为什么把这条中转链路拆了"
6
+ * 只能靠中转侧日志反推——`lastError` 只留最后一条,中间发生过几次拆链、每次什么原因,
7
+ * 事后一概查不到。这一族事件就是补这个洞。
8
+ *
9
+ * 两条出口,用途不同:
10
+ * * **内存环**(定长,默认 200 条):随 `/mobile-link/status` 一起读,正在跑的时候看;
11
+ * * **JSONL 文件**(可选,默认写在身份文件旁边,超过上限滚动一次):进程重启后还在,
12
+ * 用于事后复盘——重连风暴的现场往往在重启之后就没了。
13
+ *
14
+ * 硬约束:**这里任何一步都不许抛**。它是诊断设施,不能因为磁盘满、权限错、
15
+ * 路径怪就把链路带崩;写失败只记一条 `journal.error` 然后闭嘴。
16
+ */
17
+
18
+ import { appendFileSync, mkdirSync, renameSync, statSync, unlinkSync } from 'node:fs'
19
+ import { dirname, join } from 'node:path'
20
+
21
+ import { defaultStatePath } from './state.js'
22
+
23
+ /** 内存环里保留多少条。 */
24
+ export const DEFAULT_LIMIT = 200
25
+ /** 单个 JSONL 文件的上限;超了就滚动成 `<path>.1`,所以磁盘占用上限约两倍。 */
26
+ export const DEFAULT_MAX_BYTES = 256 * 1024
27
+ /** 单条事件序列化后的上限;见 `#shrink` 的两步处理。 */
28
+ export const MAX_EVENT_BYTES = 2048
29
+
30
+ /**
31
+ * 事件目录(`kind` 词表)——**与契约逐字一致**,由 `npm run check:contracts` 机械核对。
32
+ *
33
+ * 契约:`docs/diagnostics-events.md`(§2 既有 + §3 新增 + 内部)。
34
+ * 为什么把词表放进代码:写入侧需要一个能自检的集合(防止手滑写错 kind 后没人发现),
35
+ * 而插件是独立发布的包,不能运行时去读仓库里的 spec 文件。
36
+ */
37
+ export const EVENT_KINDS = Object.freeze([
38
+ // §2 既有(本次之前就有,语义不变)
39
+ 'link.start',
40
+ 'link.open',
41
+ 'link.close',
42
+ 'link.backoff',
43
+ 'link.failed',
44
+ 'link.unregistered',
45
+ 'link.stop',
46
+ 'watchdog.repull',
47
+ 'watchdog.drop',
48
+ 'dsh.ok',
49
+ 'dsh.unreachable',
50
+ 'device.attach',
51
+ 'device.reattach',
52
+ 'device.attach-again',
53
+ 'device.detach',
54
+ 'device.revoked',
55
+ // §3 本次新增
56
+ 'device.phase',
57
+ 'request.slow',
58
+ 'device.summary',
59
+ 'app.dial',
60
+ 'app.hello',
61
+ 'app.first-request',
62
+ 'app.first-response',
63
+ 'app.request',
64
+ 'app.first-screen',
65
+ 'app.first-render',
66
+ 'app.scene',
67
+ 'app.push',
68
+ // 内部:写失败与超大条目自报(不在契约的对外表里,但同属写入侧词表)
69
+ 'journal.error',
70
+ ])
71
+
72
+ /** 信封字段:detail 里的同名键一律忽略(否则会把时刻/事件名冲掉)。 */
73
+ const PROTECTED_KEYS = new Set(['at', 'ms', 'kind'])
74
+
75
+ /** 单条事件里字符串值的最长保留长度(`#shrink` 用)。 */
76
+ const MAX_STRING_CHARS = 200
77
+
78
+
79
+ /** 默认落在身份文件旁边(`$DSH_HOME/mobile-link/`),测试用临时 DSH_HOME 时会自动隔离。 */
80
+ export function defaultJournalPath() {
81
+ return join(dirname(defaultStatePath()), 'link-events.jsonl')
82
+ }
83
+
84
+ /**
85
+ * 把一条事件序列化成一行 JSON。
86
+ *
87
+ * 同时给 `at`(给人看的 ISO 8601)与 `ms`(给脚本比对的整数),因为这两种读者都存在:
88
+ * 人 grep 时想直接看到时刻,脚本算间隔时不想解析日期。
89
+ */
90
+ function toLine(entry) {
91
+ try {
92
+ return `${JSON.stringify(entry)}\n`
93
+ } catch {
94
+ // 循环引用之类的 detail 不该让整条事件丢掉:退化成只留 kind。
95
+ return `${JSON.stringify({ at: entry.at, ms: entry.ms, kind: entry.kind })}\n`
96
+ }
97
+ }
98
+
99
+ export class LinkJournal {
100
+ /**
101
+ * @param {object} [options]
102
+ * @param {string} [options.path] JSONL 路径;不传=只留内存,不碰磁盘
103
+ * @param {number} [options.limit] 内存环容量
104
+ * @param {number} [options.maxBytes] 单个文件上限
105
+ * @param {() => number} [options.now] 取时刻(测试注入)
106
+ * @param {{ debug?: (message: string) => void }} [options.logger]
107
+ */
108
+ constructor({
109
+ path, limit = DEFAULT_LIMIT, maxBytes = DEFAULT_MAX_BYTES,
110
+ now = Date.now, logger,
111
+ } = {}) {
112
+ this._path = typeof path === 'string' && path.trim() ? path : undefined
113
+ this.maxBytes = Math.max(1024, Number(maxBytes) || DEFAULT_MAX_BYTES)
114
+ this.limit = Math.max(1, Number(limit) || DEFAULT_LIMIT)
115
+ this.now = now
116
+ this.logger = logger
117
+ /** @type {Array<Record<string, unknown>>} */
118
+ this._entries = []
119
+ /** 因为环满而被挤掉的条数:读写两侧都不是无损的,得让读者知道。 */
120
+ this.dropped = 0
121
+ /** 落盘写坏了几次(第一次就停手,避免刷屏)。 */
122
+ this.fileErrors = 0
123
+ this._bytes = undefined
124
+ this._fileBroken = false
125
+ /** 写入过的总条数(含被挤掉的)。 */
126
+ this.recorded = 0
127
+ }
128
+
129
+ /** 落盘路径;`null` 表示这份流水只在内存里。 */
130
+ get path() {
131
+ return this._path ?? null
132
+ }
133
+
134
+ /**
135
+ * 记一条。**永不抛**。
136
+ *
137
+ * @param {string} kind 事件名(`link.open` / `device.detach` …),未知取值允许写入
138
+ * (老读者必须忽略不认识的 kind),但单测会盯住目录漂移
139
+ * @param {Record<string, unknown>} [detail] 该事件自带的字段(`undefined` 一律丢弃)
140
+ * @returns {Record<string, unknown>} 写进去的那条(测试要断言,所以返回)
141
+ */
142
+ record(kind, detail) {
143
+ const entry = this.#build(kind, detail)
144
+ const line = toLine(entry)
145
+ // 两条分支必须返回同一形状:小的直接过,大的走两步降级。
146
+ const sized = Buffer.byteLength(line) <= MAX_EVENT_BYTES ? { entry, line } : this.#shrink(entry)
147
+ this.#push(sized.entry)
148
+ this.#append(sized.line)
149
+ return sized.entry
150
+ }
151
+
152
+ /**
153
+ * 组装条目:信封 + 过滤掉 `undefined` 的 detail。
154
+ *
155
+ * **信封字段受保护**:`at`/`ms`/`kind` 不会被 detail 覆盖。这条是 2026-10-13 踩出来的——
156
+ * 当时 `request.slow`/`app.hello` 的 detail 里也有一个 `ms`(含义是**耗时**),
157
+ * 摊平后把事件的**时刻**冲掉了,时间线上那条事件会跑到 1970 年。耗时一律叫 `elapsedMs`。
158
+ */
159
+ #build(kind, detail) {
160
+ const ms = this.now()
161
+ const entry = { at: new Date(ms).toISOString(), ms, kind }
162
+ if (detail && typeof detail === 'object') {
163
+ for (const [key, value] of Object.entries(detail)) {
164
+ if (value === undefined || PROTECTED_KEYS.has(key)) continue
165
+ entry[key] = value
166
+ }
167
+ }
168
+ return entry
169
+ }
170
+
171
+ /**
172
+ * 记一条**别人钟上**的事件(App 推上来的那些):时刻用给定的值,不用本机时钟。
173
+ *
174
+ * 单独一个入口而不是"让 detail 覆盖 ms":覆盖是隐式的,谁都能不小心改掉时刻;
175
+ * 这里只有显式调用才会保留外部时刻。
176
+ */
177
+ recordForeign(kind, { at, ms, ...detail } = {}) {
178
+ const stamp = Number.isFinite(ms) ? ms : this.now()
179
+ const entry = { at: typeof at === 'string' && at ? at : new Date(stamp).toISOString(), ms: stamp, kind }
180
+ for (const [key, value] of Object.entries(detail)) {
181
+ if (value === undefined || PROTECTED_KEYS.has(key)) continue
182
+ entry[key] = value
183
+ }
184
+ const line = toLine(entry)
185
+ const sized = Buffer.byteLength(line) <= MAX_EVENT_BYTES ? { entry, line } : this.#shrink(entry)
186
+ this.#push(sized.entry)
187
+ this.#append(sized.line)
188
+ return sized.entry
189
+ }
190
+
191
+ /** 记进内存环(磁盘那半由 `record` 负责)。 */
192
+ #push(entry) {
193
+ this._entries.push(entry)
194
+ this.recorded += 1
195
+ while (this._entries.length > this.limit) {
196
+ this._entries.shift()
197
+ this.dropped += 1
198
+ }
199
+ }
200
+
201
+ /**
202
+ * 超大条目的两步降级:先把长字符串截短(保住这一条),仍超限则丢掉它并自报一条 `journal.error`。
203
+ *
204
+ * 为什么不在写入侧直接抛/直接丢:诊断设施最怕"因为自己太胖把现场弄没了"。
205
+ * 截断会留 `truncated: true`,读者知道这条不完整;真的截不动(例如深嵌套对象)才丢,且丢也要留痕。
206
+ */
207
+ #shrink(entry) {
208
+ const shortened = { ...entry }
209
+ for (const [key, value] of Object.entries(shortened)) {
210
+ if (typeof value === 'string' && value.length > MAX_STRING_CHARS) {
211
+ shortened[key] = `${value.slice(0, MAX_STRING_CHARS)}…`
212
+ }
213
+ }
214
+ shortened.truncated = true
215
+ const line = toLine(shortened)
216
+ if (Buffer.byteLength(line) <= MAX_EVENT_BYTES) return { entry: shortened, line }
217
+ const fallback = this.#build('journal.error', {
218
+ // 字段名不能叫 `kind`:`#build` 会把 detail 的键盖到信封上,那样这条自报就冒充成原事件了。
219
+ originalKind: entry.kind,
220
+ bytes: Buffer.byteLength(toLine(entry)),
221
+ reason: 'event too large even after truncation',
222
+ })
223
+ return { entry: fallback, line: toLine(fallback) }
224
+ }
225
+
226
+ /** 最近的 `count` 条,最旧在前。 */
227
+ recent(count = this.limit) {
228
+ const size = Math.max(0, Number(count) || 0)
229
+ return this._entries.slice(Math.max(0, this._entries.length - size))
230
+ }
231
+
232
+ /** 内存环里现有的全部条目(副本)。 */
233
+ entries() {
234
+ return this._entries.slice()
235
+ }
236
+
237
+ #append(line) {
238
+ if (!this._path || this._fileBroken) return
239
+ try {
240
+ if (this._bytes === undefined) {
241
+ mkdirSync(dirname(this._path), { recursive: true, mode: 0o700 })
242
+ this._bytes = this.#sizeOf(this._path)
243
+ }
244
+ const bytes = Buffer.byteLength(line)
245
+ if (this._bytes > 0 && this._bytes + bytes > this.maxBytes) this.#rotate()
246
+ appendFileSync(this._path, line, { mode: 0o600 })
247
+ this._bytes += bytes
248
+ } catch (error) {
249
+ this.fileErrors += 1
250
+ this._fileBroken = true
251
+ this.logger?.debug?.(`mobile-link: link journal disabled (${error?.message ?? error})`)
252
+ }
253
+ }
254
+
255
+ #sizeOf(path) {
256
+ try {
257
+ return statSync(path).size
258
+ } catch {
259
+ return 0
260
+ }
261
+ }
262
+
263
+ /** 滚一次:现文件改名成 `<path>.1`,旧的那份直接丢掉(只保两代)。 */
264
+ #rotate() {
265
+ const archived = `${this._path}.1`
266
+ try {
267
+ renameSync(this._path, archived)
268
+ } catch {
269
+ // Windows 上改名到已存在的目标会失败;删掉目标再试一次。
270
+ try {
271
+ unlinkSync(archived)
272
+ renameSync(this._path, archived)
273
+ } catch (error) {
274
+ throw error
275
+ }
276
+ }
277
+ this._bytes = 0
278
+ }
279
+ }
package/lib/link.js CHANGED
@@ -8,12 +8,23 @@ import { EventEmitter } from 'node:events'
8
8
 
9
9
  import { DshClient } from './dsh-client.js'
10
10
  import { agentEndpoint, decodeFrame, encodeFrame, nextBackoff, normalizeRelayUrl, pongFor } from './dlp.js'
11
+
12
+ /**
13
+ * 进程级登记表:**同一个进程里只允许有一条到中转的 agent 连接**。
14
+ *
15
+ * 为什么需要它:插件热更会重新求值模块、再建一个 Link,而旧的实例还占着那条连接
16
+ * (中转只认一个 agent 会话)。新实例于是连不上、手机那侧"连上了却没有链路"。
17
+ * 2026-10-09 00:00–00:42 真机连不上就是这个:连接器 43 分钟没回来。
18
+ * 挂在 `globalThis` 上是因为模块重载会把模块作用域清掉,只有进程级才留得住。
19
+ */
20
+ const LIVE_AGENT = Symbol.for('dsh.mobile-link.live-agent')
11
21
  import { enrollAgent, enrollCommand, inviteFrom } from './enroll.js'
12
22
  import { handoffFromEndpoint, writeHandoff } from './handoff.js'
23
+ import { LinkJournal, defaultJournalPath } from './journal.js'
13
24
  import { statusSnapshot } from './status.js'
14
25
  import { mintPairCode } from './pairing.js'
15
26
  import {
16
- DEFAULT_EVENTS_BACKLOG,
27
+ DEFAULT_EVENTS_BACKLOG, DEFAULT_SLOW_REQUEST_MS,
17
28
  EVENTS_ENDPOINT, EVENTS_RESULT, DeviceRouter, messageOf,
18
29
  } from './router.js'
19
30
  import { SERVER_VERSION, SERVER_CAPABILITIES } from './hello.js'
@@ -46,7 +57,15 @@ export class MobileLinkAgent extends EventEmitter {
46
57
  relayUrl, agentId, agentSecret, agentName, stateFile, dshUrl,
47
58
  logger = console, heartbeatMs = 20000, pongTimeoutMs = 60000, maxBackoffMs = 30000,
48
59
  random = Math.random, now = Date.now, dshClient, connectImpl = wsConnect, enabled = true,
60
+ watchdogMs = 180000,
61
+ // 慢请求阈值(毫秒);0 = 关闭逐条慢请求记录。见 router.js 的 DEFAULT_SLOW_REQUEST_MS。
62
+ slowRequestMs = DEFAULT_SLOW_REQUEST_MS,
63
+ // 关掉手机推送的接收(回退闸);见 contracts/connector-diagnostics-method.md §4。
64
+ diagnostics = true,
49
65
  inviteCode, fetchImpl = fetch, env = process.env,
66
+ // 生命周期流水(见 journal.js):`journal` 传实例(测试注入),`journalPath`
67
+ // 传路径(`defaultJournalPath()` 之外的位置),false 表示只留内存不落盘。
68
+ journal, journalPath,
50
69
  // 手机不在时替它攒下来的提问条数上限(见 router.js);环境变量给部署用,
51
70
  // 构造参数给测试用。**没有"留多久"这个旋钮**:`$events` 一直留到设备被撤销。
52
71
  eventsBacklog = numberFromEnv(env.DSH_MOBILE_LINK_EVENTS_BACKLOG, DEFAULT_EVENTS_BACKLOG),
@@ -57,6 +76,9 @@ export class MobileLinkAgent extends EventEmitter {
57
76
  this.heartbeatMs = heartbeatMs
58
77
  this.pongTimeoutMs = pongTimeoutMs
59
78
  this.maxBackoffMs = maxBackoffMs
79
+ this.watchdogMs = watchdogMs
80
+ this.slowRequestMs = Number(slowRequestMs) || 0
81
+ this.diagnostics = diagnostics !== false
60
82
  this.random = random
61
83
  this.now = now
62
84
  this.connectImpl = connectImpl
@@ -67,13 +89,29 @@ export class MobileLinkAgent extends EventEmitter {
67
89
  //: to fix, not a transport problem to retry.
68
90
  this.needsEnroll = false
69
91
  this.dsh = dshClient ?? new DshClient({ explicitUrl: dshUrl, logger })
92
+ /** 生命周期流水:内存环 +(默认)一份 JSONL,见 journal.js 的文件头。 */
93
+ this.journal = journal ?? new LinkJournal({
94
+ path: journalPath === false
95
+ ? undefined
96
+ : (typeof journalPath === 'string' && journalPath ? journalPath : defaultJournalPath()),
97
+ now,
98
+ logger,
99
+ })
70
100
  this.router = new DeviceRouter({
71
101
  dsh: this.dsh,
72
102
  logger,
73
103
  now,
74
104
  protocolVersion: PROTOCOL_VERSION,
75
105
  eventsBacklog,
106
+ slowRequestMs: this.slowRequestMs,
107
+ diagnostics: this.diagnostics,
76
108
  send: (deviceId, frame) => this.sendToDevice(deviceId, frame),
109
+ // 设备侧的 attach/detach 与链路侧同源记进流水:事后想把"手机断了"和
110
+ // "连接器拆链"对上时间,两边必须写在同一份文件里。
111
+ // `options.foreign` 表示这条事件用的是**别人的钟**(App 推上来的),必须原样保留时刻。
112
+ onEvent: (kind, detail, options) => (options?.foreign
113
+ ? this.journal.recordForeign(kind, detail)
114
+ : this.journal.record(kind, detail)),
77
115
  // 「跑完了 / 待你回应」到了就报一声(`{t:'notify'}`)。这是 agent 侧控制帧,
78
116
  // **不带 deviceId**:连接器不知道、也不需要知道这台电脑配了哪些手机,
79
117
  // 「该不该推给某台手机」由中转判(它手里才有"这台设备此刻在不在线")。
@@ -85,6 +123,9 @@ export class MobileLinkAgent extends EventEmitter {
85
123
  this.identity = undefined
86
124
  /** Which host form we are running in (`desktop` / `web` / `unknown`); set by the plugin. */
87
125
  this.hostForm = 'unknown'
126
+ /// 历史读取(spec 007):宿主 `sessionQuery` 就绪后由插件挂上;没挂上时
127
+ /// 路由回 `link/unsupported`,手机继续走 `session/page`。
128
+ this.setHistoryService = (service) => { this.router.history = service }
88
129
  this.relay = undefined
89
130
  this.socket = undefined
90
131
  this.state = 'idle'
@@ -93,6 +134,16 @@ export class MobileLinkAgent extends EventEmitter {
93
134
  this.startedAt = undefined
94
135
  this.connectedAt = undefined
95
136
  this.reconnectAttempts = 0
137
+ /** 最近一次拆链的时刻(用来在 `link.open` 里报"距上次断开多久")。 */
138
+ this._lastClosedAt = undefined
139
+ /**
140
+ * socket 上最后一次系统错误(`onError` 的文本)。
141
+ *
142
+ * 为什么要专门留住它:链路被异常关闭时,`close` 回调只给一个代码(实测常见 1006,
143
+ * 含义是"没有关闭帧"),据此无法判断是网络、代理还是对端。系统错误文本是唯一线索,
144
+ * 而它以前只进了 debug 日志、不落流水(2026-10-13 现场诊断时卡在这里)。
145
+ */
146
+ this._socketError = undefined
96
147
 
97
148
  this._stopping = false
98
149
  this._sessionClosed = undefined
@@ -100,6 +151,9 @@ export class MobileLinkAgent extends EventEmitter {
100
151
  this._inbox = Promise.resolve()
101
152
  this._lastPong = 0
102
153
  this._loop = undefined
154
+ this._loopActive = false
155
+ this._watchdog = undefined
156
+ this._lastProgressAt = 0
103
157
  this._abort = new AbortController()
104
158
 
105
159
  // The mux is shared by every device, so its events are wired once here.
@@ -199,22 +253,49 @@ export class MobileLinkAgent extends EventEmitter {
199
253
  this.emit('status')
200
254
  return this
201
255
  }
256
+ // 先把上一个实例停掉(热更留下的那条连接):不停的话两个实例会抢同一个 agent 会话,
257
+ // 表现就是"手机连上中转、却等不到任何回复"。
258
+ const previous = globalThis[LIVE_AGENT]
259
+ if (previous && previous !== this) {
260
+ try {
261
+ await previous.stop()
262
+ } catch {
263
+ /* 旧实例停不掉也不能挡住新的 */
264
+ }
265
+ }
266
+ globalThis[LIVE_AGENT] = this
202
267
  if (this._loop) return this
203
268
  this._stopping = false
204
269
  this._abort = new AbortController()
205
270
  this.startedAt = this.now()
206
271
  this.state = 'connecting'
272
+ this.journal.record('link.start', {
273
+ // 这里只能报**配置值**:真身份(含实际中转地址)要等 `#session()` 里的
274
+ // `resolveIdentity()` 才读到,那条记在 `link.open` 上。名字带 configured,
275
+ // 免得读者把占位符 `wss://relay.example.com/dsh-link` 当成真的在连的地址。
276
+ configuredRelay: this.config.relayUrl,
277
+ agentId: this.config.agentId || undefined,
278
+ attempt: this.reconnectAttempts,
279
+ host: this.hostForm,
280
+ })
207
281
  // 解析器可能在上一次会话里就已经装好;这里补一次本机发现(失败无妨)。
208
282
  void this.refreshLocal()
283
+ this._lastProgressAt = this.now()
284
+ this.#startWatchdog()
285
+ this._loopActive = true
209
286
  this._loop = this.#run().catch((error) => {
210
287
  this.lastError = messageOf(error)
211
288
  this.state = 'failed'
212
289
  this.emit('status')
290
+ }).finally(() => {
291
+ this._loopActive = false
213
292
  })
214
293
  return this
215
294
  }
216
295
 
217
296
  async stop() {
297
+ this.#stopWatchdog()
298
+ if (globalThis[LIVE_AGENT] === this) globalThis[LIVE_AGENT] = undefined
218
299
  this._stopping = true
219
300
  this._abort.abort()
220
301
  this.#teardownSocket('agent stopped')
@@ -226,6 +307,7 @@ export class MobileLinkAgent extends EventEmitter {
226
307
  this._loop = undefined
227
308
  this.dsh.close()
228
309
  this.state = 'stopped'
310
+ this.journal.record('link.stop', {})
229
311
  this.emit('status')
230
312
  }
231
313
 
@@ -242,6 +324,7 @@ export class MobileLinkAgent extends EventEmitter {
242
324
  this.needsEnroll = true
243
325
  this.state = 'unregistered'
244
326
  this.lastError = messageOf(error)
327
+ this.journal.record('link.unregistered', { error: this.lastError })
245
328
  this.logger.error?.(this.lastError)
246
329
  this.emit('status')
247
330
  return
@@ -259,11 +342,13 @@ export class MobileLinkAgent extends EventEmitter {
259
342
  this.needsEnroll = true
260
343
  this.state = 'unregistered'
261
344
  this.lastError = messageOf(error)
345
+ this.journal.record('link.unregistered', { error: this.lastError })
262
346
  this.logger.error?.(`${this.lastError}\n ${this.enrollHint()}`)
263
347
  this.emit('status')
264
348
  return
265
349
  }
266
350
  this.lastError = messageOf(error)
351
+ this.journal.record('link.failed', { error: this.lastError, attempt: this.reconnectAttempts })
267
352
  this.logger.warn?.(`mobile-link: ${this.lastError}`)
268
353
  }
269
354
  if (this._stopping) break
@@ -271,6 +356,7 @@ export class MobileLinkAgent extends EventEmitter {
271
356
  this.emit('status')
272
357
  const delay = nextBackoff(this.reconnectAttempts, { maxMs: this.maxBackoffMs, random: this.random })
273
358
  this.reconnectAttempts += 1
359
+ this.journal.record('link.backoff', { attempt: this.reconnectAttempts, delayMs: Math.round(delay) })
274
360
  await sleep(delay, this._abort.signal)
275
361
  }
276
362
  this.state = 'stopped'
@@ -324,10 +410,17 @@ export class MobileLinkAgent extends EventEmitter {
324
410
  this.socket = socket
325
411
  this.state = 'connected'
326
412
  this.connectedAt = this.now()
413
+ this._lastProgressAt = this.now()
327
414
  this.lastError = undefined
328
415
  this.router.lastError = undefined
329
416
  this._lastPong = this.now()
330
417
  this.logger.info?.(`mobile-link: connected to ${this.relay.wsBase} as ${identity.agentId}`)
418
+ this.journal.record('link.open', {
419
+ relay: this.relay?.wsBase,
420
+ agentId: identity.agentId,
421
+ attempt: this.reconnectAttempts,
422
+ sinceCloseMs: this._lastClosedAt === undefined ? undefined : this.now() - this._lastClosedAt,
423
+ })
331
424
  this.emit('status')
332
425
 
333
426
  socket.onMessage((text) => {
@@ -336,7 +429,10 @@ export class MobileLinkAgent extends EventEmitter {
336
429
  .catch((error) => this.logger.debug?.(`mobile-link: frame handler error: ${messageOf(error)}`))
337
430
  return this._inbox
338
431
  })
339
- socket.onError((error) => this.logger.debug?.(`mobile-link: relay socket error: ${messageOf(error)}`))
432
+ socket.onError((error) => {
433
+ this._socketError = messageOf(error)
434
+ this.logger.debug?.(`mobile-link: relay socket error: ${this._socketError}`)
435
+ })
340
436
  socket.onClose((code, reason) => this.#teardownSocket(`relay closed the socket (${code ?? '?'} ${reason ?? ''})`))
341
437
 
342
438
  this.#startHeartbeat()
@@ -349,9 +445,13 @@ export class MobileLinkAgent extends EventEmitter {
349
445
  // 进程外工具(本机脚本、iOS 集成测试辅助)靠这份交接文件拿带凭据地址;
350
446
  // 写入失败不影响链路(`#writeHandoff` 自己吞异常)。
351
447
  await this.#writeHandoff()
448
+ this.journal.record('dsh.ok', { endpoint: this.dsh?.endpoint?.base })
352
449
  } catch (error) {
353
450
  this.dshError = messageOf(error)
354
451
  this.lastError = this.dshError
452
+ // 这一条很关键:本机 DSH 不可达时,手机那句 `_link/hello`/`sessions` 探针
453
+ // 必然超时,App 会据此重建整条链路——于是"连接器在、手机却连不上"。
454
+ this.journal.record('dsh.unreachable', { error: this.dshError, kind: this.dsh?.errorKind })
355
455
  this.logger.warn?.(`mobile-link: local DSH is not reachable yet: ${this.dshError}`)
356
456
  }
357
457
  this.emit('status')
@@ -362,6 +462,57 @@ export class MobileLinkAgent extends EventEmitter {
362
462
  })
363
463
  }
364
464
 
465
+ /**
466
+ * 看门狗:**与单次会话的心跳无关**的兜底。
467
+ *
468
+ * 心跳只在"连上之后"跑,而且会话卡死时它依赖 `setInterval` 还在(热更把它换掉就没了)。
469
+ * 看门狗每 `heartbeatMs` 看一次"链路最近有没有进展":
470
+ * * 已连上但超过 `pongTimeoutMs` 没动静 ⇒ 强制拆掉这条 socket(会话随即返回、进重连);
471
+ * * **循环已经不在了**(`_loopActive === false`,典型是热更换掉了实例)⇒ 重新拉起循环。
472
+ * 正常重连退避期间(循环还在、状态是 connecting/backoff)它什么都不做,只在
473
+ * 超过 `watchdogMs` 仍无进展时把 socket 拆掉重来一次,避免"半开连接永远挂着"。
474
+ */
475
+ #startWatchdog() {
476
+ this.#stopWatchdog()
477
+ // 检查频率:至少在"判定超时"的窗口里看两次,最多 5 秒一次、最少 1 秒一次。
478
+ // 不能跟着 `heartbeatMs` 走——心跳间隔设得大(例如测试里 60s)会让看门狗也 20s 才醒一次。
479
+ const every = Math.max(1000, Math.min(Math.floor(this.pongTimeoutMs / 2) || 1000, 5000))
480
+ this._watchdog = setInterval(() => {
481
+ if (this._stopping || this.enabled === false) return
482
+ const idle = this.now() - this._lastProgressAt
483
+ if (!this._loopActive) {
484
+ this.lastError = `链路循环不在了(看门狗重拉,空闲 ${Math.round(idle / 1000)}s)`
485
+ this.journal.record('watchdog.repull', { idleMs: Math.round(idle) })
486
+ this.logger.warn?.(`mobile-link: ${this.lastError}`)
487
+ this._loop = undefined
488
+ this._lastProgressAt = this.now()
489
+ void this.start()
490
+ return
491
+ }
492
+ if (this.state === 'connected' && idle > this.pongTimeoutMs) {
493
+ this.lastError = `relay 超过 ${Math.round(idle / 1000)}s 没有动静(看门狗拆连接)`
494
+ this.journal.record('watchdog.drop', { idleMs: Math.round(idle), state: this.state })
495
+ this.logger.warn?.(`mobile-link: ${this.lastError}`)
496
+ this.#teardownSocket(this.lastError)
497
+ this._lastProgressAt = this.now()
498
+ return
499
+ }
500
+ if (this.state !== 'connected' && idle > this.watchdogMs) {
501
+ this.lastError = `链路 ${Math.round(idle / 1000)}s 没有进展(看门狗重建连接)`
502
+ this.journal.record('watchdog.drop', { idleMs: Math.round(idle), state: this.state })
503
+ this.logger.warn?.(`mobile-link: ${this.lastError}`)
504
+ this.#teardownSocket(this.lastError)
505
+ this._lastProgressAt = this.now()
506
+ }
507
+ }, every)
508
+ this._watchdog.unref?.()
509
+ }
510
+
511
+ #stopWatchdog() {
512
+ if (this._watchdog) clearInterval(this._watchdog)
513
+ this._watchdog = undefined
514
+ }
515
+
365
516
  #startHeartbeat() {
366
517
  this.#stopHeartbeat()
367
518
  this._heartbeat = setInterval(() => {
@@ -384,6 +535,7 @@ export class MobileLinkAgent extends EventEmitter {
384
535
  #teardownSocket(reason) {
385
536
  this.#stopHeartbeat()
386
537
  const socket = this.socket
538
+ const from = this.state
387
539
  this.socket = undefined
388
540
  if (socket) {
389
541
  try {
@@ -398,6 +550,18 @@ export class MobileLinkAgent extends EventEmitter {
398
550
  this.dsh.close()
399
551
  if (this.state !== 'stopped' && this.state !== 'disabled') this.state = 'disconnected'
400
552
  this.lastError = this.lastError ?? reason
553
+ this._lastClosedAt = this.now()
554
+ // 「为什么拆的」是这一族事件唯一要回答的问题:reason 来自调用点,
555
+ // from 是拆之前的状态(connected / backoff / connecting),
556
+ // connectedForMs 用来区分"刚连上就掉"和"挂了很久才掉"。
557
+ this.journal.record('link.close', {
558
+ reason,
559
+ from,
560
+ connectedForMs: this.connectedAt === undefined ? undefined : this.now() - this.connectedAt,
561
+ // 有系统错误才带这个字段(正常关闭时不该凭空多一个 null)。
562
+ socketError: this._socketError,
563
+ })
564
+ this._socketError = undefined
401
565
  const resolve = this._sessionClosed
402
566
  this._sessionClosed = undefined
403
567
  this.emit('status')
@@ -407,10 +571,15 @@ export class MobileLinkAgent extends EventEmitter {
407
571
  // ── relay I/O ───────────────────────────────────────────────────────────
408
572
 
409
573
  #rawSend(frame) {
574
+ return this.#sendText(encodeFrame(frame))
575
+ }
576
+
577
+ /** 唯一的发送出口:socket 不在就丢掉,发送异常只记 debug、绝不抛。 */
578
+ #sendText(text) {
410
579
  const socket = this.socket
411
580
  if (!socket || socket.closed) return false
412
581
  try {
413
- socket.send(encodeFrame(frame))
582
+ socket.send(text)
414
583
  return true
415
584
  } catch (error) {
416
585
  this.logger.debug?.(`mobile-link: send failed: ${messageOf(error)}`)
@@ -422,7 +591,10 @@ export class MobileLinkAgent extends EventEmitter {
422
591
  // `deviceId` 为空=这是一条**不带寻址**的帧(`fsPutAck`/`fsPutDone`/`fsErr` 那一族):
423
592
  // relay 靠 `bid` 把回复对回发起的那个 HTTP 请求,多一个 deviceId 反而会让它对不上。
424
593
  if (deviceId === undefined || deviceId === null) return this.#rawSend(frame)
425
- return this.#rawSend({ ...frame, deviceId })
594
+ const text = encodeFrame({ ...frame, deviceId })
595
+ // 记字节给路由层的"慢请求/汇总"用(它按 deviceId 归):只有这里知道真正上线了多少字节。
596
+ this.router.noteSent?.(deviceId, Buffer.byteLength(text))
597
+ return this.#sendText(text)
426
598
  }
427
599
 
428
600
  sendHostStatus() {
@@ -440,9 +612,12 @@ export class MobileLinkAgent extends EventEmitter {
440
612
 
441
613
  /** Route one frame from the relay. */
442
614
  async handleRelayFrame(frame) {
615
+ // 中转来的任何一帧都算"链路有进展"——看门狗据此区分"半开连接"与"正在正常工作"。
616
+ this._lastProgressAt = this.now()
443
617
  if (!frame || typeof frame !== 'object') return
444
618
  if (frame.t === 'pong') {
445
619
  this._lastPong = this.now()
620
+ this._lastProgressAt = this.now()
446
621
  return
447
622
  }
448
623
  if (frame.t === 'ping') {