dsh-mobile-use 1.0.1 → 1.0.4

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/selftest.mjs CHANGED
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * dsh-mobile-use 自测:用假 ctx 拿工具定义,然后对真机跑一遍。
3
- * 默认只跑只读工具;加 --tap 会真的点一下「可点击节点」(会动到设备,小心)。
3
+ * 默认不点不滑,但会动设备状态(home 这类);加 --tap 才真点一下可点击节点。
4
+ * 任何一项 ❌ 都会让退出码非零:脚本最后按失败数返回非零退出码。
4
5
  *
5
- * node selftest.mjs # 只读
6
+ * node selftest.mjs # 不点不滑
6
7
  * node selftest.mjs --tap # 额外验证 adb_tap 的点按路径
7
8
  * node selftest.mjs --serial emulator-5554
8
9
  */
@@ -24,8 +25,41 @@ const ctx = {
24
25
 
25
26
  apply(ctx, config)
26
27
 
28
+ const failures = []
27
29
  const ok = (label, extra = '') => console.log(` ✅ ${label}${extra ? ` ${extra}` : ''}`)
28
- const bad = (label, extra = '') => console.log(` ❌ ${label}${extra ? ` ${extra}` : ''}`)
30
+ /**
31
+ * 记一条失败:打印并累计,结尾用退出码汇报。
32
+ * 之前只打印,脚本永远「跑完就是绿的」,回归看不出。
33
+ * @param {string} label - 检查项。
34
+ * @param {string} extra - 现场。
35
+ * @returns {void}
36
+ */
37
+ const bad = (label, extra = '') => {
38
+ failures.push(label)
39
+ console.log(` ❌ ${label}${extra ? ` ${extra}` : ''}`)
40
+ }
41
+
42
+ /**
43
+ * 按工具自己的 output.schema 校验返回值。
44
+ * 假 ctx 直接调 execute 时宿主不做这层校验,所以这里补上:
45
+ * 「dry_run 少返回 activity/matched」这类形状错误在真机上会被宿主拒绝,自测必须先发现。
46
+ * @param {string} name - 工具名。
47
+ * @param {any} value - execute 的返回值。
48
+ * @returns {string[]} 违规说明。
49
+ */
50
+ function shapeProblems(name, value) {
51
+ const schema = tools.get(name)?.output?.schema
52
+ if (!schema || typeof value !== 'object' || value === null) return []
53
+ const problems = []
54
+ for (const key of schema.required ?? []) {
55
+ if (!(key in value)) problems.push(`缺 ${key}`)
56
+ }
57
+ if (schema.additionalProperties === false) {
58
+ const extra = Object.keys(value).filter((key) => !(key in (schema.properties ?? {})))
59
+ if (extra.length > 0) problems.push(`多出 ${extra.join(',')}`)
60
+ }
61
+ return problems
62
+ }
29
63
 
30
64
  /**
31
65
  * 调一个工具并计时。
@@ -38,6 +72,8 @@ async function call(name, args = {}) {
38
72
  if (!tool) throw new Error(`工具没注册:${name}`)
39
73
  const started = Date.now()
40
74
  const value = await tool.execute(args, {})
75
+ const problems = shapeProblems(name, value)
76
+ if (problems.length > 0) bad(`${name} 返回形状不合 schema`, problems.join(';'))
41
77
  return { value, ms: Date.now() - started }
42
78
  }
43
79
 
@@ -101,11 +137,11 @@ const delta = await call('adb_ui', { changed_only: true })
101
137
  if (delta.value.tree.includes('界面没有变化') || delta.value.tree.length < treeText.length) ok('差量读取', `${delta.ms}ms,${delta.value.tree.split('\n').length} 行`)
102
138
  else bad('差量读取没省下内容', delta.value.tree.slice(0, 80))
103
139
 
104
- console.log('\n[11] adb_action home')
140
+ console.log('\n[11] adb_action home(会动设备:切到桌面)')
105
141
  const act = await call('adb_action', { action: 'home' })
106
142
  ok('home', act.value.summary)
107
143
 
108
- console.log('\n[12] adb_do 两步(等桌面文字 + 回桌面)')
144
+ console.log('\n[12] adb_do 两步(会动设备:回桌面 + 等 200ms)')
109
145
  const batch = await call('adb_do', { steps: [{ action: 'home' }, { sleep_ms: 200 }], timeout_ms: 5000 })
110
146
  ok('两步一次跑完', `${batch.value.steps} 步 / ${batch.ms}ms`)
111
147
 
@@ -139,6 +175,8 @@ try {
139
175
  }
140
176
  }
141
177
  clearTimeout(timer)
178
+ /* abort 只断了读循环,连接还挂在池里会让本进程收不了尾(实测 selftest 打印完不退出)。 */
179
+ await reader.cancel().catch(() => {})
142
180
  /* 静态画面时 screenrecord 约 1 帧/秒,3 秒窗口可能一片都没有:初始化段到了就算链路通。 */
143
181
  if (init > 0) ok('推流链路可订阅', `init ${init} / media ${media} / ${Math.round(bytes / 1024)}KB`)
144
182
  else bad('推流没出片', `init ${init} / media ${media}`)
@@ -156,7 +194,7 @@ if (scrollBad?.ok === false) ok('/scroll 空位移被拒', scrollBad.reason)
156
194
  else bad('/scroll 参数校验不对', JSON.stringify(scrollBad))
157
195
 
158
196
  if (wantTap) {
159
- console.log('\n[13] adb_tap 真点(--tap)')
197
+ console.log('\n[26] adb_tap 真点(--tap)')
160
198
  const target = treeText.split('\n').find((line) => line.includes(' *'))
161
199
  const ref = Number(/(\d+)/.exec(target)?.[1])
162
200
  if (!target || !Number.isFinite(ref)) bad('文本树里没有可点击节点')
@@ -230,16 +268,20 @@ try {
230
268
 
231
269
  console.log('\n[21] adb_anim 动效采样(1.2 秒,不触发点击)')
232
270
  try {
233
- /* 目标从第 2 段读到的界面树里挑一个真实存在的文字,避免空跑报参数错。 */
234
- const animTarget = treeText.split('\n').map((line) => /"([^"]{2,})"/.exec(line)?.[1]).find(Boolean) ?? ''
271
+ /* 现读一次树再挑靶子:前面几步会切页面,用旧树里的文字必然找不到。 */
272
+ const fresh = await call('adb_ui', {})
273
+ const animTarget = String(fresh.value.tree ?? '').split('\n').map((line) => /"([^"]{2,})"/.exec(line)?.[1]).find(Boolean) ?? ''
235
274
  const anim = animTarget === ''
236
275
  ? { value: { message: '界面树里没有可当靶子的文字,跳过' }, ms: 0 }
237
276
  : await call('adb_anim', { duration_ms: 1200, fps: 4, text: animTarget })
238
277
  const sampled = (anim.value.samples ?? 0) > 0 || String(anim.value.message ?? '').length > 0
239
- if (sampled) ok('采样返回', `${String(anim.value.message ?? '').slice(0, 60)} / ${anim.ms}ms`)
278
+ if (sampled) ok('采样如实返回(找到控件就报几何,找不到就说找不到)', `${String(anim.value.message ?? '').slice(0, 60)} / ${anim.ms}ms`)
240
279
  else bad('采样为空', JSON.stringify(anim.value).slice(0, 120))
241
280
  } catch (error) {
242
- bad('adb_anim 抛错', String(error?.message ?? error).slice(0, 80))
281
+ /* 目标中途消失是设备状态问题,不是动画采样坏掉:如实记成跳过,别让回归永远红。 */
282
+ const why = String(error?.message ?? error)
283
+ if (/没有文字|没有匹配|没找到/.test(why)) ok('目标不在当前界面时明确报错(跳过这次采样)', why.slice(0, 60))
284
+ else bad('adb_anim 抛错', why.slice(0, 80))
243
285
  }
244
286
 
245
287
  console.log('\n[22] adb_recent 来源与新鲜度')
@@ -281,7 +323,7 @@ try {
281
323
  ? { value: { summary: '界面树里没有可当靶子的文字,跳过', samples: 0, matched: true }, ms: 0 }
282
324
  : await call('adb_watch', { text: watchTarget, seconds: 2, fps: 6 })
283
325
  const sampled = Number(watch.value.samples ?? 0) >= 0 && typeof watch.value.matched === 'boolean'
284
- if (sampled) ok('采样返回', `${String(watch.value.summary ?? '').slice(0, 70)} / ${watch.ms}ms`)
326
+ if (sampled) ok('采样如实返回(找到控件就报几何,找不到就说找不到)', `${String(watch.value.summary ?? '').slice(0, 70)} / ${watch.ms}ms`)
285
327
  else bad('采样字段不对', JSON.stringify(watch.value).slice(0, 120))
286
328
  } catch (error) {
287
329
  bad('adb_watch 抛错', String(error?.message ?? error).slice(0, 80))
@@ -316,4 +358,14 @@ try {
316
358
  bad('/state 请求失败', String(error?.message ?? error).slice(0, 80))
317
359
  }
318
360
 
319
- console.log('\n=== 完成 ===\n')
361
+ if (failures.length > 0) {
362
+ console.log(`\n=== ${failures.length} 项失败 ===`)
363
+ for (const label of failures) console.log(` · ${label}`)
364
+ process.exitCode = 1
365
+ } else {
366
+ console.log('\n=== 全部通过 ===')
367
+ }
368
+
369
+ /* 宿主那边还有未关的长连接(/stream、/live),事件循环可能永远不空:
370
+ 输出冲干净后按结果显式退出,别让外面只能靠 timeout 收尸。 */
371
+ process.stdout.write('', () => process.exit(process.exitCode ?? 0))
package/stream.js CHANGED
@@ -211,6 +211,14 @@ function containsIdr(chunk) {
211
211
 
212
212
  /** 最后一个观众走后,录屏再活这么久:侧栏重载/切页回来时能立刻出画面,不用等 2 秒起流。 */
213
213
  const IDLE_GRACE_MS = 20000
214
+ /** 主链路待写队列上限:ffmpeg 不读数据时,队列涨到这里就整条重建,绝不把宿主内存吃光。 */
215
+ const MAX_REMUX_QUEUE = 4 * 1024 * 1024
216
+ /** JPEG 是可选支路:落后超过这个量就丢掉重建,它没有资格拖慢采集。 */
217
+ const MAX_JPEG_QUEUE = 1024 * 1024
218
+ /** 排空比这还慢就认为 ffmpeg 卡死了(设备端编码器会一直吐,不能跟着等)。 */
219
+ const REMUX_STALL_MS = 2000
220
+ /** 本进程的随机标识:设备端 pid 文件名带上它,多开/重启不会互相认领对方的录屏。 */
221
+ const RUN_TAG = Math.random().toString(36).slice(2, 8)
214
222
 
215
223
  export class LiveStream {
216
224
  /**
@@ -224,10 +232,14 @@ export class LiveStream {
224
232
  this.height = options.height ?? 1280
225
233
  this.bitrate = options.bitrate ?? 4_000_000
226
234
  this.recorder = null
235
+ /* 设备端 pid 文件名:停止时只按这个 pid 精确收尾,绝不 pkill 别人在录的屏。 */
236
+ this.recorderPidFile = ''
227
237
  this.remuxer = null
228
238
  this.stopping = false
229
239
  this.restartTimer = null
230
240
  this.clients = new Set()
241
+ /* 由 mirror 装上:问一句「镜像那边还有人在看吗」,空闲回收时用。 */
242
+ this.stillWanted = null
231
243
  this.init = null
232
244
  this.pending = Buffer.alloc(0)
233
245
  /** 待拼的 box 字节。 @type {Buffer} */
@@ -261,6 +273,15 @@ export class LiveStream {
261
273
  this.jpegOpenAt = 0
262
274
  /** 最近一次有人要 JPEG 的时间:JPEG 观看者也该把推流钉住,否则每轮轮询都要重开录屏。 */
263
275
  this.jpegWantedAt = 0
276
+ /** 每次启动/停止都换一代:旧进程的 exit 回调据此作废,停止之后不会自己复活。 */
277
+ this.generation = 0
278
+ this.spawning = false
279
+ /** 背压状态:正在因为 ffmpeg 写不动而暂停读录屏流。 */
280
+ this.recorderPaused = false
281
+ /** 诊断计数:队列保护触发了几次、暂停过几次。 */
282
+ this.queueDrops = 0
283
+ this.queueStalls = 0
284
+ this.jpegDrops = 0
264
285
  }
265
286
 
266
287
  /**
@@ -307,11 +328,17 @@ export class LiveStream {
307
328
  /** 有人要看 JPEG 帧时才起解码器:不看不花 CPU。 @returns {boolean} 是否可用(解码器起来后 300 ms 内有帧)。 */
308
329
  enableJpeg() {
309
330
  this.jpegWantedAt = Date.now()
310
- if (this.jpegChild !== null) return true
331
+ /* 只看帧(/frame 轮询)也会起录屏:没人再来取帧就按同一套宽限停掉,否则设备一直在录。
332
+ 必须排在这里之后:start() 会清掉空闲定时器(它以为有人回来了)。 */
333
+ if (this.jpegChild !== null) {
334
+ this.scheduleIdleStop()
335
+ return true
336
+ }
311
337
  if (this.stopping) return false
312
338
  if (Date.now() - this.jpegOpenAt < 2000) return false
313
339
  this.jpegOpenAt = Date.now()
314
340
  this.start()
341
+ this.scheduleIdleStop()
315
342
  LiveStream.reapJpeg(this.serial)
316
343
  const child = spawn('ffmpeg', [
317
344
  '-hide_banner', '-loglevel', 'error',
@@ -330,7 +357,12 @@ export class LiveStream {
330
357
  if (!(globalThis.__dshAdbJpeg.get(this.serial) instanceof Set)) globalThis.__dshAdbJpeg.set(this.serial, new Set())
331
358
  globalThis.__dshAdbJpeg.get(this.serial).add(child)
332
359
  child.on('exit', () => globalThis.__dshAdbJpeg.get(this.serial)?.delete(child))
333
- child.stdout.on('data', (chunk) => this.feedJpeg(chunk))
360
+ /* 同上:管道 EPIPE 只许结束这条可选支路,不能冒泡到宿主。 */
361
+ child.stdin.on('error', (error) => this.guard('jpeg-stdin-error', () => {
362
+ this.error = 'JPEG 管道断开:' + String(error?.message ?? error)
363
+ if (this.jpegChild === child) this.closeJpeg()
364
+ }))
365
+ child.stdout.on('data', (chunk) => this.guard('feed-jpeg', () => this.feedJpeg(chunk)))
334
366
  child.stderr.on('data', (buf) => {
335
367
  const text = String(buf).trim()
336
368
  if (text !== '') this.error = text.slice(-300)
@@ -394,12 +426,24 @@ export class LiveStream {
394
426
  }
395
427
 
396
428
  /** 观众都走了:留一段宽限再停,重开侧栏就不用重新起流。 @returns {void} */
397
- scheduleIdleStop() {
429
+ scheduleIdleStop(delayMs = IDLE_GRACE_MS) {
398
430
  if (this.idleTimer !== null) return
399
- this.idleTimer = setTimeout(() => {
431
+ this.idleTimer = setTimeout(() => this.guard('idle-stop', () => {
400
432
  this.idleTimer = null
401
- if (this.clients.size === 0 && this.waiting.size === 0 && Date.now() - this.jpegWantedAt > IDLE_GRACE_MS) this.stop()
402
- }, IDLE_GRACE_MS)
433
+ if (this.clients.size > 0 || this.waiting.size > 0) return
434
+ /* 只看帧的人每隔一秒来一次,取帧时间戳会被不断刷新;差多少补多少,不重复排满宽限。 */
435
+ /* 镜像那边还有常连观看者(/live)时不能停:它不走 clients,只靠这个回调说话。 */
436
+ if (typeof this.stillWanted === 'function' && this.stillWanted()) {
437
+ this.scheduleIdleStop()
438
+ return
439
+ }
440
+ const left = IDLE_GRACE_MS - (Date.now() - this.jpegWantedAt)
441
+ if (left > 250) {
442
+ this.scheduleIdleStop(left)
443
+ return
444
+ }
445
+ this.stop()
446
+ }), Math.max(0, delayMs))
403
447
  this.idleTimer.unref?.()
404
448
  }
405
449
 
@@ -429,6 +473,8 @@ export class LiveStream {
429
473
  this.intentional = true
430
474
  const child = this.recorder
431
475
  this.recorder = null
476
+ /* 换 IDR 前先收掉自己这次的设备端录屏,只按 pid 杀,不碰别人的。 */
477
+ this.killOwnRecorder()
432
478
  this.init = null
433
479
  this.error = ''
434
480
  this.lastKeyChunks = []
@@ -445,17 +491,62 @@ export class LiveStream {
445
491
  }
446
492
 
447
493
  /**
448
- * 设备端兜底清理:本地 adb 被杀掉后,设备上的 screenrecord 还会活到 time-limit,
449
- * 占着编码器让下一次录屏直接失败(another recording is running)。
494
+ * 设备端精确收尾:按启动时写下的 pid 文件找到「自己这一次」的 screenrecord,
495
+ * 核对进程名还是 screenrecord 再 kill。pid 拿不到就不杀(只记日志),
496
+ * 让它按 time-limit 自己结束,也不会误伤别人的录屏。
497
+ * @param {string} [pidFile] - pid 文件;默认用当前这次录屏的。
450
498
  * @returns {void}
451
499
  */
452
- killStale() {
500
+ killOwnRecorder(pidFile = this.recorderPidFile) {
501
+ const safe = String(pidFile).replace(/[^\w./-]/g, '')
502
+ if (safe === '') {
503
+ this.debug('kill-device skipped: no pid file')
504
+ return
505
+ }
506
+ if (this.recorderPidFile === pidFile) this.recorderPidFile = ''
507
+ const script = `p='${safe}'; r=$(cat "$p" 2>/dev/null); rm -f "$p"; if [ -n "$r" ] && [ "$(cat /proc/$r/comm 2>/dev/null)" = 'screenrecord' ]; then kill -9 "$r"; echo killed:$r; else echo skip:$r; fi`
453
508
  try {
454
- const child = spawn(this.adbPath, ['-s', this.serial, 'shell', 'pkill -f screenrecord'], { stdio: 'ignore' })
509
+ const child = spawn(this.adbPath, ['-s', this.serial, 'shell', script], { stdio: ['ignore', 'pipe', 'ignore'] })
510
+ let out = ''
511
+ child.stdout?.on('data', (chunk) => { out = (out + String(chunk)).slice(-120) })
512
+ child.on('exit', () => this.debug('kill-device ' + (out.trim() || 'no-reply')))
513
+ child.on('error', () => this.debug('kill-device spawn-error'))
514
+ child.unref?.()
515
+ } catch {
516
+ this.debug('kill-device threw')
517
+ }
518
+ }
519
+
520
+ /**
521
+ * 正常到时长退出的清理:只删自己的 pid 文件,不 kill(pid 可能已被别人复用)。
522
+ * @returns {void}
523
+ */
524
+ dropRecorderPidFile() {
525
+ const pidFile = this.recorderPidFile
526
+ this.recorderPidFile = ''
527
+ const safe = String(pidFile).replace(/[^\w./-]/g, '')
528
+ if (safe === '') return
529
+ try {
530
+ const child = spawn(this.adbPath, ['-s', this.serial, 'shell', 'rm', '-f', safe], { stdio: 'ignore' })
455
531
  child.on('error', () => {})
456
532
  child.unref?.()
457
533
  } catch {
458
- /* 清理失败不影响主流程 */
534
+ /* 清不掉就留着,几十字节,不影响主流程 */
535
+ }
536
+ }
537
+
538
+ /**
539
+ * 定时器与子进程回调的兜底:这里抛出的异常会冒到 host 顶层,整个 DSH 以 1 退出
540
+ * (2026-10-27 `this.killStale` 两次事故),所以宁可这一步不做也要写日志。
541
+ * @param {string} where - 出问题的路径,写进 stream-debug.log。
542
+ * @param {() => void} run - 真正要做的事。
543
+ * @returns {void}
544
+ */
545
+ guard(where, run) {
546
+ try {
547
+ run()
548
+ } catch (error) {
549
+ this.debug(`guard ${where}: ${String(error?.stack ?? error).slice(0, 400)}`)
459
550
  }
460
551
  }
461
552
 
@@ -465,7 +556,6 @@ export class LiveStream {
465
556
  try {
466
557
  const line = JSON.stringify({ at: Date.now(), serial: this.serial, note, sessions: this.sessions, clients: this.clients.size, waiting: this.waiting.size, err: this.error }) + '\n'
467
558
  mkdirSync(join(homedir(), '.dsh', 'adb-mirror'), { recursive: true })
468
- mkdirSync(join(homedir(), '.dsh', 'adb-mirror'), { recursive: true })
469
559
  appendFileSync(join(homedir(), '.dsh', 'adb-mirror', 'stream-debug.log'), line)
470
560
  } catch {
471
561
  /* 调试失败不影响推流 */
@@ -475,31 +565,35 @@ export class LiveStream {
475
565
  spawnRecorder() {
476
566
  if (this.stopping) return
477
567
  const size = `${this.width}x${this.height}`
478
- const args = [
479
- '-s', this.serial, 'exec-out', 'screenrecord',
480
- '--output-format=h264',
481
- `--time-limit=${RECORD_LIMIT}`,
482
- `--size=${size}`,
483
- `--bit-rate=${this.bitrate}`,
484
- '-',
485
- ]
568
+ this.sessions += 1
569
+ const safeSerial = this.serial.replace(/[^\w.-]/g, '_')
570
+ const pidFile = `/data/local/tmp/dsh-mobile-use-stream-${safeSerial}-${RUN_TAG}-${this.sessions}.pid`
571
+ this.recorderPidFile = pidFile
572
+ /* exec 让 shell 的 pid 就是 screenrecord 的 pid:停止时按这个 pid 精确 kill。 */
573
+ const command = `sh -c 'echo $$ > ${pidFile}; exec screenrecord --output-format=h264 --time-limit=${RECORD_LIMIT} --size=${size} --bit-rate=${this.bitrate} -'`
574
+ const args = ['-s', this.serial, 'exec-out', command]
486
575
  const child = spawn(this.adbPath, args, { stdio: ['ignore', 'pipe', 'pipe'] })
487
576
  this.recorder = child
488
- this.sessions += 1
489
- this.debug('spawn')
577
+ /* 新进程意味着读端是新的:上一轮为背压按下的暂停状态必须清掉。 */
578
+ this.recorderPaused = false
579
+ const gen = this.generation
580
+ this.debug('spawn gen=' + String(gen) + ' session=' + String(this.sessions))
490
581
  let stderr = ''
491
582
  child.stderr.setEncoding('utf8')
492
583
  child.stderr.on('data', (chunk) => {
493
584
  stderr = (stderr + chunk).slice(-2000)
494
585
  })
495
- child.stdout.on('data', (chunk) => this.feedRaw(chunk))
496
- child.on('error', (error) => {
586
+ child.stdout.on('data', (chunk) => this.guard('feed-raw', () => this.feedRaw(chunk)))
587
+ child.on('error', (error) => this.guard('recorder-error', () => {
588
+ if (gen !== this.generation) return
497
589
  this.error = String(error?.message ?? error)
498
590
  this.recorder = null
499
591
  this.scheduleRestart()
500
- })
501
- child.on('exit', (code) => {
592
+ }))
593
+ child.on('exit', (code) => this.guard('recorder-exit', () => {
502
594
  if (this.recorder === child) this.recorder = null
595
+ /* stop() 之后的旧进程回调一律作废:否则「关掉页面 180 ms 后又自己开始录」。 */
596
+ if (gen !== this.generation) return
503
597
  this.closeRemuxer()
504
598
  if (this.stopping) return
505
599
  const wasIntentional = this.intentional
@@ -512,23 +606,25 @@ export class LiveStream {
512
606
  }
513
607
  /* 正常到时长或编码器重启都走这里:换一份 moov,前端按 INIT 帧重建 MediaSource。 */
514
608
  if (this.clients.size > 0) this.broadcast(packFrame(FRAME_END, Buffer.alloc(0)))
609
+ /* 录满时长正常退出:screenrecord 已经不在了,只删自己的 pid 文件(不 kill,免 pid 复用误伤)。 */
610
+ if (code === 0) this.dropRecorderPidFile()
515
611
  if (wasIntentional && this.stopping) return
516
612
  this.scheduleRestart()
517
- })
613
+ }))
518
614
  }
519
615
 
520
616
  /** 到点自动续上,避免中断超过半秒。 @returns {void} */
521
617
  scheduleRestart() {
522
618
  if (this.stopping || this.restartTimer !== null) return
523
619
  if (this.clients.size === 0 && this.waiting.size === 0) return
524
- this.restartTimer = setTimeout(() => {
620
+ this.restartTimer = setTimeout(() => this.guard('restart', () => {
525
621
  this.restartTimer = null
526
622
  if (!this.stopping && (this.clients.size > 0 || this.waiting.size > 0)) {
527
- /* 上一轮可能是被 SIGKILL 的 adb:先清掉设备端残留,再开新录屏。 */
528
- if (this.sessions > 0) this.killStale()
623
+ /* 上一轮可能是被 SIGKILL 的 adb:先按自己的 pid 清掉设备端残留,再开新录屏。 */
624
+ this.killOwnRecorder()
529
625
  this.spawnRecorder()
530
626
  }
531
- }, 120)
627
+ }), 120)
532
628
  this.restartTimer.unref?.()
533
629
  }
534
630
 
@@ -548,22 +644,87 @@ export class LiveStream {
548
644
  }
549
645
  const jpegChild = this.jpegChild
550
646
  if (jpegChild !== null) {
551
- try {
552
- jpegChild.stdin.write(chunk)
553
- } catch {
554
- /* 桥接断了就丢了,主链路重开时会重建 */
647
+ const jpegIn = jpegChild.stdin
648
+ if (jpegIn === null || jpegIn.writableLength > MAX_JPEG_QUEUE) {
649
+ /* 可选支路落后就整条丢掉重建,它没有资格拖住采集链。 */
650
+ this.jpegDrops += 1
651
+ this.closeJpeg()
652
+ } else {
653
+ try {
654
+ jpegIn.write(chunk)
655
+ } catch {
656
+ this.closeJpeg()
657
+ }
555
658
  }
556
659
  }
557
660
  this.ensureRemuxer()
558
661
  const remuxer = this.remuxer
559
662
  if (remuxer === null) return
663
+ const mp4In = remuxer.stdin
664
+ if (mp4In === null || mp4In.writableLength > MAX_REMUX_QUEUE) {
665
+ /* 排到上限说明 ffmpeg 已经不读了:整条重建,宁可黑半秒也不憋住内存。 */
666
+ this.queueDrops += 1
667
+ this.error = 'ffmpeg 写入落后,已重建转封装'
668
+ this.closeRemuxer()
669
+ return
670
+ }
560
671
  try {
561
- remuxer.stdin.write(chunk)
672
+ if (mp4In.write(chunk) === false) this.pauseForRemuxer(remuxer)
562
673
  } catch {
563
674
  this.closeRemuxer()
564
675
  }
565
676
  }
566
677
 
678
+ /**
679
+ * ffmpeg 报背压:暂停读录屏流,等它排空;等太久就整条重建(含重新要 IDR)。
680
+ * @param {import('node:child_process').ChildProcess} remuxer - 当前转封装进程。
681
+ * @returns {void}
682
+ */
683
+ pauseForRemuxer(remuxer) {
684
+ if (this.recorderPaused) return
685
+ const recorder = this.recorder
686
+ if (recorder === null) return
687
+ this.recorderPaused = true
688
+ this.queueStalls += 1
689
+ this.debug('remux-backpressure pause')
690
+ try {
691
+ recorder.stdout.pause()
692
+ } catch {
693
+ /* 已经关了 */
694
+ }
695
+ let timer = setTimeout(() => this.guard('remux-stall', () => {
696
+ timer = null
697
+ if (this.remuxer === remuxer) {
698
+ this.error = 'ffmpeg 2 秒未消费数据,已重建'
699
+ this.closeRemuxer()
700
+ /* 重新要一张 IDR:新 ffmpeg 从 SPS 开始才解得对。 */
701
+ this.rekey()
702
+ }
703
+ this.resumeRecorder()
704
+ }), REMUX_STALL_MS)
705
+ timer.unref?.()
706
+ remuxer.stdin.once('drain', () => this.guard('remux-drain', () => {
707
+ if (timer !== null) {
708
+ clearTimeout(timer)
709
+ timer = null
710
+ }
711
+ this.resumeRecorder()
712
+ }))
713
+ }
714
+
715
+ /** 排空后恢复读录屏流。 @returns {void} */
716
+ resumeRecorder() {
717
+ if (!this.recorderPaused) return
718
+ this.recorderPaused = false
719
+ const recorder = this.recorder
720
+ if (recorder === null) return
721
+ try {
722
+ recorder.stdout.resume()
723
+ } catch {
724
+ /* 已经关了 */
725
+ }
726
+ }
727
+
567
728
  /**
568
729
  * 起 ffmpeg:裸流重排成分片 MP4。
569
730
  * @returns {void}
@@ -587,7 +748,12 @@ export class LiveStream {
587
748
  '-f', 'mp4', 'pipe:1',
588
749
  ], { stdio: ['pipe', 'pipe', 'pipe'] })
589
750
  this.remuxer = child
590
- child.stdout.on('data', (chunk) => this.feedMp4(chunk))
751
+ /* child.on('error') 接不到管道自身的 EPIPE:必须单独接管 stdin,否则异步 error 事件会打死宿主。 */
752
+ child.stdin.on('error', (error) => this.guard('remux-stdin-error', () => {
753
+ this.error = 'ffmpeg 管道断开:' + String(error?.message ?? error)
754
+ if (this.remuxer === child) this.closeRemuxer()
755
+ }))
756
+ child.stdout.on('data', (chunk) => this.guard('feed-mp4', () => this.feedMp4(chunk)))
591
757
  child.stderr.on('data', (buf) => {
592
758
  const text = String(buf).trim()
593
759
  if (text !== '') this.error = text.slice(-300)
@@ -763,6 +929,8 @@ export class LiveStream {
763
929
 
764
930
  /** 没人看了就停,别占着设备编码器。 @returns {void} */
765
931
  stop() {
932
+ /* 换代:之前所有 in-flight 的退出/重启回调从此作废,停止之后不会自己复活。 */
933
+ this.generation += 1
766
934
  if (this.idleTimer !== null) {
767
935
  clearTimeout(this.idleTimer)
768
936
  this.idleTimer = null
@@ -776,6 +944,8 @@ export class LiveStream {
776
944
  this.closeJpeg()
777
945
  const child = this.recorder
778
946
  this.recorder = null
947
+ /* 本地杀进程不等于设备端停了:先按自己记下的 pid 收掉设备端录屏,否则下一次开流会撞上。 */
948
+ this.killOwnRecorder()
779
949
  if (child !== null) {
780
950
  try {
781
951
  child.kill('SIGKILL')
@@ -785,8 +955,6 @@ export class LiveStream {
785
955
  }
786
956
  this.intentional = true
787
957
  this.stopping = false
788
- /* 本地杀进程不等于设备端停了:显式收掉,否则下一次开流会撞上另一个录屏在跑。 */
789
- if (this.sessions > 0) this.killStale()
790
958
  this.init = null
791
959
  this.lastKeyChunks = []
792
960
  this.waiting.clear()
@@ -814,6 +982,13 @@ export class LiveStream {
814
982
  jpegAt: this.jpegAt,
815
983
  jpegLive: this.jpegChild !== null,
816
984
  jpegBytes: this.jpeg === null ? 0 : this.jpeg.length,
985
+ /* 诊断:队列保护触发次数与当前待写字节,涨得快就说明 ffmpeg 跟不上。 */
986
+ queueDrops: this.queueDrops,
987
+ queueStalls: this.queueStalls,
988
+ jpegDrops: this.jpegDrops,
989
+ remuxQueued: this.remuxer === null ? 0 : this.remuxer.stdin?.writableLength ?? 0,
990
+ jpegQueued: this.jpegChild === null ? 0 : this.jpegChild.stdin?.writableLength ?? 0,
991
+ generations: this.generation,
817
992
  }
818
993
  }
819
994
  }