dsh-mobile-use 1.0.3 → 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/client.js CHANGED
@@ -550,6 +550,20 @@ window.__ModuleLoader__.load({
550
550
  let timer
551
551
  let since = 0
552
552
  let misses = 0
553
+ /* 正在显示与正在解码的 Blob URL 各留一个:替换时先释放旧的待解码,
554
+ 卸载时两个都释放。旧写法只靠 onload 回收,换帧快时会漏掉整条 URL。 */
555
+ let shownUrl = ''
556
+ let pendingUrl = ''
557
+ const releaseUrls = () => {
558
+ if (pendingUrl !== '') {
559
+ URL.revokeObjectURL(pendingUrl)
560
+ pendingUrl = ''
561
+ }
562
+ if (shownUrl !== '') {
563
+ URL.revokeObjectURL(shownUrl)
564
+ shownUrl = ''
565
+ }
566
+ }
553
567
  const visible = () => {
554
568
  const node = frameRef.current
555
569
  const host = node === null ? null : (node.parentElement ?? node)
@@ -574,13 +588,21 @@ window.__ModuleLoader__.load({
574
588
  const img = imgRef.current
575
589
  if (img !== null) {
576
590
  const url = URL.createObjectURL(blob)
577
- const prev = img.dataset.url
591
+ if (pendingUrl !== '') URL.revokeObjectURL(pendingUrl)
592
+ pendingUrl = url
578
593
  img.onload = () => {
579
- if (prev !== undefined) URL.revokeObjectURL(prev)
580
- img.dataset.url = url
594
+ if (pendingUrl !== url) return
595
+ pendingUrl = ''
596
+ if (shownUrl !== '') URL.revokeObjectURL(shownUrl)
597
+ shownUrl = url
581
598
  setReady(true)
582
599
  paint()
583
600
  }
601
+ img.onerror = () => {
602
+ if (pendingUrl !== url) return
603
+ pendingUrl = ''
604
+ URL.revokeObjectURL(url)
605
+ }
584
606
  img.src = url
585
607
  }
586
608
  misses = 0
@@ -590,7 +612,7 @@ window.__ModuleLoader__.load({
590
612
  if (live) timer = setTimeout(pull, misses === 0 ? 0 : Math.min(3000, 300 * misses))
591
613
  }
592
614
  void pull()
593
- return () => { live = false; clearTimeout(timer) }
615
+ return () => { live = false; clearTimeout(timer); releaseUrls() }
594
616
  }, [serial, paint, engine])
595
617
 
596
618
  /* 画面尺寸与是否在直播:标注坐标要换算到设备像素,必须知道这两个数。 */
package/client.rev CHANGED
@@ -1 +1 @@
1
- 152
1
+ 153
package/index.js CHANGED
@@ -22,8 +22,8 @@ import { homedir, tmpdir } from 'node:os'
22
22
  import { dirname, join } from 'node:path'
23
23
  import { fileURLToPath } from 'node:url'
24
24
 
25
- import { setupMirror, frameSize as jpegSize } from './mirror.js?v=169'
26
- import { ocrAvailable, ocrImage, stopOcr, toRects } from './ocr.js?v=116'
25
+ import { setupMirror, frameSize as jpegSize } from './mirror.js?v=172'
26
+ import { ocrAvailable, ocrImage, stopOcr, toRects } from './ocr.js?v=117'
27
27
 
28
28
  /** Plugin name,与 bundle patch 里的行 id 对应。 */
29
29
  export const name = 'adb'
package/mirror.js CHANGED
@@ -20,7 +20,7 @@ import { mkdir, readdir, rm, stat, writeFile } from 'node:fs/promises'
20
20
  import { dirname, join } from 'node:path'
21
21
  import { homedir } from 'node:os'
22
22
 
23
- import { LiveStream, packFrame } from './stream.js?v=146'
23
+ import { LiveStream, packFrame } from './stream.js?v=149'
24
24
 
25
25
  /** 客户端与宿主共用的路由前缀。 */
26
26
  export const ROUTE = '/dsh-mobile-use'
@@ -248,6 +248,9 @@ function pngToJpeg(png) {
248
248
  })
249
249
  }
250
250
 
251
+ /** 单个 MJPEG 连接的待写上限:慢客户端不能把宿主进程的堆吃光。 */
252
+ const MAX_LIVE_QUEUE = 2 * 1024 * 1024
253
+
251
254
  /**
252
255
  * 往一个 MJPEG 连接写一帧。
253
256
  * @param {import('node:http').ServerResponse} res - 连接。
@@ -257,6 +260,12 @@ function pngToJpeg(png) {
257
260
  */
258
261
  function writeFrame(res, frame, drop) {
259
262
  try {
263
+ /* 慢客户端只许拖垮自己:待写超过上限就直接断连,让它重连,别在宿主里攒队列。 */
264
+ if (res.writableLength > MAX_LIVE_QUEUE) {
265
+ drop()
266
+ res.destroy()
267
+ return
268
+ }
260
269
  res.write(`--frame\r\ncontent-type: image/jpeg\r\ncontent-length: ${frame.length}\r\n\r\n`)
261
270
  res.write(frame)
262
271
  res.write('\r\n')
@@ -282,6 +291,8 @@ export class Mirror {
282
291
  this.height = 0
283
292
  this.watchers = new Set()
284
293
  this.lastWatchAt = 0
294
+ /* 并发 start 的单飞句柄:见 start()。 */
295
+ this.startPromise = null
285
296
  this.dropped = 0
286
297
  this.seq = 0
287
298
  this.waiters = []
@@ -321,20 +332,31 @@ export class Mirror {
321
332
 
322
333
  async start() {
323
334
  if (this.timer !== null || this.stopping) return
324
- await mkdir(this.dir, { recursive: true })
325
- this.startedAt = Date.now()
326
- this.error = ''
327
- const size = await this.adb(['-s', this.serial, 'shell', 'wm', 'size'], { timeout: 6000 })
328
- const found = /(\d+)x(\d+)/.exec(String(size.out ?? ''))
329
- if (found !== null) this.physical = { w: Number(found[1]), h: Number(found[2]) }
330
- await this.snapshot()
331
- this.alignScreen()
332
- this.timer = setInterval(() => guard('pump', () => this.pump()), 200)
333
- this.timer.unref?.()
334
- this.pushTimer = setInterval(() => guard('push', () => this.push()), PUSH_TICK_MS)
335
- this.pushTimer.unref?.()
336
- /* 录制跟观看者对齐:起服务时没人看就先不录,等 /frame 或 /live 把人带来再开。 */
337
- this.ensureRecorder()
335
+ /* 并发 start(页面重载 + /frame 同时到)只许跑一次:起两套定时器后,停的时候必漏一套。 */
336
+ if (this.startPromise !== null) return this.startPromise
337
+ this.startPromise = (async () => {
338
+ await mkdir(this.dir, { recursive: true })
339
+ this.startedAt = Date.now()
340
+ this.error = ''
341
+ const size = await this.adb(['-s', this.serial, 'shell', 'wm', 'size'], { timeout: 6000 })
342
+ const found = /(\d+)x(\d+)/.exec(String(size.out ?? ''))
343
+ if (found !== null) this.physical = { w: Number(found[1]), h: Number(found[2]) }
344
+ await this.snapshot()
345
+ this.alignScreen()
346
+ /* 等到这里再判一次:await 期间可能已经有人 stop 了。 */
347
+ if (this.stopping) return
348
+ this.timer = setInterval(() => guard('pump', () => this.pump()), 200)
349
+ this.timer.unref?.()
350
+ this.pushTimer = setInterval(() => guard('push', () => this.push()), PUSH_TICK_MS)
351
+ this.pushTimer.unref?.()
352
+ /* 录制跟观看者对齐:起服务时没人看就先不录,等 /frame 或 /live 把人带来再开。 */
353
+ this.ensureRecorder()
354
+ })().catch((error) => {
355
+ this.error = String(error?.message ?? error)
356
+ }).finally(() => {
357
+ this.startPromise = null
358
+ })
359
+ return this.startPromise
338
360
  }
339
361
 
340
362
  /**
@@ -1621,7 +1643,9 @@ export function setupMirror(ctx, tools) {
1621
1643
  })
1622
1644
  res.flushHeaders()
1623
1645
  let closed = false
1624
- const unsubscribe = stream.subscribe((bytes) => {
1646
+ /* 先占位再订阅:subscribe 可能同步补发初始化段,那时 unsubscribe 还没赋值。 */
1647
+ let unsubscribe = () => {}
1648
+ unsubscribe = stream.subscribe((bytes) => {
1625
1649
  if (closed) return
1626
1650
  /* 积压超过 2 MB 就断开重连:宁可重连一次,也不要让延迟越滚越大。 */
1627
1651
  if (res.writableLength > 2 << 20) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mobile-use",
3
- "version": "1.0.3",
3
+ "version": "1.0.4",
4
4
  "private": false,
5
5
  "description": "真机调试插件:界面文本树、动效数据、实时画面侧栏与圈选标注",
6
6
  "type": "module",
package/stream.js CHANGED
@@ -211,6 +211,12 @@ 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
214
220
  /** 本进程的随机标识:设备端 pid 文件名带上它,多开/重启不会互相认领对方的录屏。 */
215
221
  const RUN_TAG = Math.random().toString(36).slice(2, 8)
216
222
 
@@ -267,6 +273,15 @@ export class LiveStream {
267
273
  this.jpegOpenAt = 0
268
274
  /** 最近一次有人要 JPEG 的时间:JPEG 观看者也该把推流钉住,否则每轮轮询都要重开录屏。 */
269
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
270
285
  }
271
286
 
272
287
  /**
@@ -342,6 +357,11 @@ export class LiveStream {
342
357
  if (!(globalThis.__dshAdbJpeg.get(this.serial) instanceof Set)) globalThis.__dshAdbJpeg.set(this.serial, new Set())
343
358
  globalThis.__dshAdbJpeg.get(this.serial).add(child)
344
359
  child.on('exit', () => globalThis.__dshAdbJpeg.get(this.serial)?.delete(child))
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
+ }))
345
365
  child.stdout.on('data', (chunk) => this.guard('feed-jpeg', () => this.feedJpeg(chunk)))
346
366
  child.stderr.on('data', (buf) => {
347
367
  const text = String(buf).trim()
@@ -536,7 +556,6 @@ export class LiveStream {
536
556
  try {
537
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'
538
558
  mkdirSync(join(homedir(), '.dsh', 'adb-mirror'), { recursive: true })
539
- mkdirSync(join(homedir(), '.dsh', 'adb-mirror'), { recursive: true })
540
559
  appendFileSync(join(homedir(), '.dsh', 'adb-mirror', 'stream-debug.log'), line)
541
560
  } catch {
542
561
  /* 调试失败不影响推流 */
@@ -555,7 +574,10 @@ export class LiveStream {
555
574
  const args = ['-s', this.serial, 'exec-out', command]
556
575
  const child = spawn(this.adbPath, args, { stdio: ['ignore', 'pipe', 'pipe'] })
557
576
  this.recorder = child
558
- this.debug('spawn')
577
+ /* 新进程意味着读端是新的:上一轮为背压按下的暂停状态必须清掉。 */
578
+ this.recorderPaused = false
579
+ const gen = this.generation
580
+ this.debug('spawn gen=' + String(gen) + ' session=' + String(this.sessions))
559
581
  let stderr = ''
560
582
  child.stderr.setEncoding('utf8')
561
583
  child.stderr.on('data', (chunk) => {
@@ -563,12 +585,15 @@ export class LiveStream {
563
585
  })
564
586
  child.stdout.on('data', (chunk) => this.guard('feed-raw', () => this.feedRaw(chunk)))
565
587
  child.on('error', (error) => this.guard('recorder-error', () => {
588
+ if (gen !== this.generation) return
566
589
  this.error = String(error?.message ?? error)
567
590
  this.recorder = null
568
591
  this.scheduleRestart()
569
592
  }))
570
593
  child.on('exit', (code) => this.guard('recorder-exit', () => {
571
594
  if (this.recorder === child) this.recorder = null
595
+ /* stop() 之后的旧进程回调一律作废:否则「关掉页面 180 ms 后又自己开始录」。 */
596
+ if (gen !== this.generation) return
572
597
  this.closeRemuxer()
573
598
  if (this.stopping) return
574
599
  const wasIntentional = this.intentional
@@ -619,22 +644,87 @@ export class LiveStream {
619
644
  }
620
645
  const jpegChild = this.jpegChild
621
646
  if (jpegChild !== null) {
622
- try {
623
- jpegChild.stdin.write(chunk)
624
- } catch {
625
- /* 桥接断了就丢了,主链路重开时会重建 */
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
+ }
626
658
  }
627
659
  }
628
660
  this.ensureRemuxer()
629
661
  const remuxer = this.remuxer
630
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
+ }
631
671
  try {
632
- remuxer.stdin.write(chunk)
672
+ if (mp4In.write(chunk) === false) this.pauseForRemuxer(remuxer)
633
673
  } catch {
634
674
  this.closeRemuxer()
635
675
  }
636
676
  }
637
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
+
638
728
  /**
639
729
  * 起 ffmpeg:裸流重排成分片 MP4。
640
730
  * @returns {void}
@@ -658,6 +748,11 @@ export class LiveStream {
658
748
  '-f', 'mp4', 'pipe:1',
659
749
  ], { stdio: ['pipe', 'pipe', 'pipe'] })
660
750
  this.remuxer = child
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
+ }))
661
756
  child.stdout.on('data', (chunk) => this.guard('feed-mp4', () => this.feedMp4(chunk)))
662
757
  child.stderr.on('data', (buf) => {
663
758
  const text = String(buf).trim()
@@ -834,6 +929,8 @@ export class LiveStream {
834
929
 
835
930
  /** 没人看了就停,别占着设备编码器。 @returns {void} */
836
931
  stop() {
932
+ /* 换代:之前所有 in-flight 的退出/重启回调从此作废,停止之后不会自己复活。 */
933
+ this.generation += 1
837
934
  if (this.idleTimer !== null) {
838
935
  clearTimeout(this.idleTimer)
839
936
  this.idleTimer = null
@@ -885,6 +982,13 @@ export class LiveStream {
885
982
  jpegAt: this.jpegAt,
886
983
  jpegLive: this.jpegChild !== null,
887
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,
888
992
  }
889
993
  }
890
994
  }