dsh-mobile-use 1.0.0 → 1.0.3

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/stream.js CHANGED
@@ -211,6 +211,8 @@ function containsIdr(chunk) {
211
211
 
212
212
  /** 最后一个观众走后,录屏再活这么久:侧栏重载/切页回来时能立刻出画面,不用等 2 秒起流。 */
213
213
  const IDLE_GRACE_MS = 20000
214
+ /** 本进程的随机标识:设备端 pid 文件名带上它,多开/重启不会互相认领对方的录屏。 */
215
+ const RUN_TAG = Math.random().toString(36).slice(2, 8)
214
216
 
215
217
  export class LiveStream {
216
218
  /**
@@ -224,10 +226,14 @@ export class LiveStream {
224
226
  this.height = options.height ?? 1280
225
227
  this.bitrate = options.bitrate ?? 4_000_000
226
228
  this.recorder = null
229
+ /* 设备端 pid 文件名:停止时只按这个 pid 精确收尾,绝不 pkill 别人在录的屏。 */
230
+ this.recorderPidFile = ''
227
231
  this.remuxer = null
228
232
  this.stopping = false
229
233
  this.restartTimer = null
230
234
  this.clients = new Set()
235
+ /* 由 mirror 装上:问一句「镜像那边还有人在看吗」,空闲回收时用。 */
236
+ this.stillWanted = null
231
237
  this.init = null
232
238
  this.pending = Buffer.alloc(0)
233
239
  /** 待拼的 box 字节。 @type {Buffer} */
@@ -307,11 +313,17 @@ export class LiveStream {
307
313
  /** 有人要看 JPEG 帧时才起解码器:不看不花 CPU。 @returns {boolean} 是否可用(解码器起来后 300 ms 内有帧)。 */
308
314
  enableJpeg() {
309
315
  this.jpegWantedAt = Date.now()
310
- if (this.jpegChild !== null) return true
316
+ /* 只看帧(/frame 轮询)也会起录屏:没人再来取帧就按同一套宽限停掉,否则设备一直在录。
317
+ 必须排在这里之后:start() 会清掉空闲定时器(它以为有人回来了)。 */
318
+ if (this.jpegChild !== null) {
319
+ this.scheduleIdleStop()
320
+ return true
321
+ }
311
322
  if (this.stopping) return false
312
323
  if (Date.now() - this.jpegOpenAt < 2000) return false
313
324
  this.jpegOpenAt = Date.now()
314
325
  this.start()
326
+ this.scheduleIdleStop()
315
327
  LiveStream.reapJpeg(this.serial)
316
328
  const child = spawn('ffmpeg', [
317
329
  '-hide_banner', '-loglevel', 'error',
@@ -330,7 +342,7 @@ export class LiveStream {
330
342
  if (!(globalThis.__dshAdbJpeg.get(this.serial) instanceof Set)) globalThis.__dshAdbJpeg.set(this.serial, new Set())
331
343
  globalThis.__dshAdbJpeg.get(this.serial).add(child)
332
344
  child.on('exit', () => globalThis.__dshAdbJpeg.get(this.serial)?.delete(child))
333
- child.stdout.on('data', (chunk) => this.feedJpeg(chunk))
345
+ child.stdout.on('data', (chunk) => this.guard('feed-jpeg', () => this.feedJpeg(chunk)))
334
346
  child.stderr.on('data', (buf) => {
335
347
  const text = String(buf).trim()
336
348
  if (text !== '') this.error = text.slice(-300)
@@ -394,12 +406,24 @@ export class LiveStream {
394
406
  }
395
407
 
396
408
  /** 观众都走了:留一段宽限再停,重开侧栏就不用重新起流。 @returns {void} */
397
- scheduleIdleStop() {
409
+ scheduleIdleStop(delayMs = IDLE_GRACE_MS) {
398
410
  if (this.idleTimer !== null) return
399
- this.idleTimer = setTimeout(() => {
411
+ this.idleTimer = setTimeout(() => this.guard('idle-stop', () => {
400
412
  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)
413
+ if (this.clients.size > 0 || this.waiting.size > 0) return
414
+ /* 只看帧的人每隔一秒来一次,取帧时间戳会被不断刷新;差多少补多少,不重复排满宽限。 */
415
+ /* 镜像那边还有常连观看者(/live)时不能停:它不走 clients,只靠这个回调说话。 */
416
+ if (typeof this.stillWanted === 'function' && this.stillWanted()) {
417
+ this.scheduleIdleStop()
418
+ return
419
+ }
420
+ const left = IDLE_GRACE_MS - (Date.now() - this.jpegWantedAt)
421
+ if (left > 250) {
422
+ this.scheduleIdleStop(left)
423
+ return
424
+ }
425
+ this.stop()
426
+ }), Math.max(0, delayMs))
403
427
  this.idleTimer.unref?.()
404
428
  }
405
429
 
@@ -429,6 +453,8 @@ export class LiveStream {
429
453
  this.intentional = true
430
454
  const child = this.recorder
431
455
  this.recorder = null
456
+ /* 换 IDR 前先收掉自己这次的设备端录屏,只按 pid 杀,不碰别人的。 */
457
+ this.killOwnRecorder()
432
458
  this.init = null
433
459
  this.error = ''
434
460
  this.lastKeyChunks = []
@@ -445,17 +471,62 @@ export class LiveStream {
445
471
  }
446
472
 
447
473
  /**
448
- * 设备端兜底清理:本地 adb 被杀掉后,设备上的 screenrecord 还会活到 time-limit,
449
- * 占着编码器让下一次录屏直接失败(another recording is running)。
474
+ * 设备端精确收尾:按启动时写下的 pid 文件找到「自己这一次」的 screenrecord,
475
+ * 核对进程名还是 screenrecord 再 kill。pid 拿不到就不杀(只记日志),
476
+ * 让它按 time-limit 自己结束,也不会误伤别人的录屏。
477
+ * @param {string} [pidFile] - pid 文件;默认用当前这次录屏的。
450
478
  * @returns {void}
451
479
  */
452
- killStale() {
480
+ killOwnRecorder(pidFile = this.recorderPidFile) {
481
+ const safe = String(pidFile).replace(/[^\w./-]/g, '')
482
+ if (safe === '') {
483
+ this.debug('kill-device skipped: no pid file')
484
+ return
485
+ }
486
+ if (this.recorderPidFile === pidFile) this.recorderPidFile = ''
487
+ 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
488
  try {
454
- const child = spawn(this.adbPath, ['-s', this.serial, 'shell', 'pkill -f screenrecord'], { stdio: 'ignore' })
489
+ const child = spawn(this.adbPath, ['-s', this.serial, 'shell', script], { stdio: ['ignore', 'pipe', 'ignore'] })
490
+ let out = ''
491
+ child.stdout?.on('data', (chunk) => { out = (out + String(chunk)).slice(-120) })
492
+ child.on('exit', () => this.debug('kill-device ' + (out.trim() || 'no-reply')))
493
+ child.on('error', () => this.debug('kill-device spawn-error'))
494
+ child.unref?.()
495
+ } catch {
496
+ this.debug('kill-device threw')
497
+ }
498
+ }
499
+
500
+ /**
501
+ * 正常到时长退出的清理:只删自己的 pid 文件,不 kill(pid 可能已被别人复用)。
502
+ * @returns {void}
503
+ */
504
+ dropRecorderPidFile() {
505
+ const pidFile = this.recorderPidFile
506
+ this.recorderPidFile = ''
507
+ const safe = String(pidFile).replace(/[^\w./-]/g, '')
508
+ if (safe === '') return
509
+ try {
510
+ const child = spawn(this.adbPath, ['-s', this.serial, 'shell', 'rm', '-f', safe], { stdio: 'ignore' })
455
511
  child.on('error', () => {})
456
512
  child.unref?.()
457
513
  } catch {
458
- /* 清理失败不影响主流程 */
514
+ /* 清不掉就留着,几十字节,不影响主流程 */
515
+ }
516
+ }
517
+
518
+ /**
519
+ * 定时器与子进程回调的兜底:这里抛出的异常会冒到 host 顶层,整个 DSH 以 1 退出
520
+ * (2026-10-27 `this.killStale` 两次事故),所以宁可这一步不做也要写日志。
521
+ * @param {string} where - 出问题的路径,写进 stream-debug.log。
522
+ * @param {() => void} run - 真正要做的事。
523
+ * @returns {void}
524
+ */
525
+ guard(where, run) {
526
+ try {
527
+ run()
528
+ } catch (error) {
529
+ this.debug(`guard ${where}: ${String(error?.stack ?? error).slice(0, 400)}`)
459
530
  }
460
531
  }
461
532
 
@@ -475,30 +546,28 @@ export class LiveStream {
475
546
  spawnRecorder() {
476
547
  if (this.stopping) return
477
548
  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
- ]
549
+ this.sessions += 1
550
+ const safeSerial = this.serial.replace(/[^\w.-]/g, '_')
551
+ const pidFile = `/data/local/tmp/dsh-mobile-use-stream-${safeSerial}-${RUN_TAG}-${this.sessions}.pid`
552
+ this.recorderPidFile = pidFile
553
+ /* exec 让 shell 的 pid 就是 screenrecord 的 pid:停止时按这个 pid 精确 kill。 */
554
+ const command = `sh -c 'echo $$ > ${pidFile}; exec screenrecord --output-format=h264 --time-limit=${RECORD_LIMIT} --size=${size} --bit-rate=${this.bitrate} -'`
555
+ const args = ['-s', this.serial, 'exec-out', command]
486
556
  const child = spawn(this.adbPath, args, { stdio: ['ignore', 'pipe', 'pipe'] })
487
557
  this.recorder = child
488
- this.sessions += 1
489
558
  this.debug('spawn')
490
559
  let stderr = ''
491
560
  child.stderr.setEncoding('utf8')
492
561
  child.stderr.on('data', (chunk) => {
493
562
  stderr = (stderr + chunk).slice(-2000)
494
563
  })
495
- child.stdout.on('data', (chunk) => this.feedRaw(chunk))
496
- child.on('error', (error) => {
564
+ child.stdout.on('data', (chunk) => this.guard('feed-raw', () => this.feedRaw(chunk)))
565
+ child.on('error', (error) => this.guard('recorder-error', () => {
497
566
  this.error = String(error?.message ?? error)
498
567
  this.recorder = null
499
568
  this.scheduleRestart()
500
- })
501
- child.on('exit', (code) => {
569
+ }))
570
+ child.on('exit', (code) => this.guard('recorder-exit', () => {
502
571
  if (this.recorder === child) this.recorder = null
503
572
  this.closeRemuxer()
504
573
  if (this.stopping) return
@@ -512,23 +581,25 @@ export class LiveStream {
512
581
  }
513
582
  /* 正常到时长或编码器重启都走这里:换一份 moov,前端按 INIT 帧重建 MediaSource。 */
514
583
  if (this.clients.size > 0) this.broadcast(packFrame(FRAME_END, Buffer.alloc(0)))
584
+ /* 录满时长正常退出:screenrecord 已经不在了,只删自己的 pid 文件(不 kill,免 pid 复用误伤)。 */
585
+ if (code === 0) this.dropRecorderPidFile()
515
586
  if (wasIntentional && this.stopping) return
516
587
  this.scheduleRestart()
517
- })
588
+ }))
518
589
  }
519
590
 
520
591
  /** 到点自动续上,避免中断超过半秒。 @returns {void} */
521
592
  scheduleRestart() {
522
593
  if (this.stopping || this.restartTimer !== null) return
523
594
  if (this.clients.size === 0 && this.waiting.size === 0) return
524
- this.restartTimer = setTimeout(() => {
595
+ this.restartTimer = setTimeout(() => this.guard('restart', () => {
525
596
  this.restartTimer = null
526
597
  if (!this.stopping && (this.clients.size > 0 || this.waiting.size > 0)) {
527
- /* 上一轮可能是被 SIGKILL 的 adb:先清掉设备端残留,再开新录屏。 */
528
- if (this.sessions > 0) this.killStale()
598
+ /* 上一轮可能是被 SIGKILL 的 adb:先按自己的 pid 清掉设备端残留,再开新录屏。 */
599
+ this.killOwnRecorder()
529
600
  this.spawnRecorder()
530
601
  }
531
- }, 120)
602
+ }), 120)
532
603
  this.restartTimer.unref?.()
533
604
  }
534
605
 
@@ -587,7 +658,7 @@ export class LiveStream {
587
658
  '-f', 'mp4', 'pipe:1',
588
659
  ], { stdio: ['pipe', 'pipe', 'pipe'] })
589
660
  this.remuxer = child
590
- child.stdout.on('data', (chunk) => this.feedMp4(chunk))
661
+ child.stdout.on('data', (chunk) => this.guard('feed-mp4', () => this.feedMp4(chunk)))
591
662
  child.stderr.on('data', (buf) => {
592
663
  const text = String(buf).trim()
593
664
  if (text !== '') this.error = text.slice(-300)
@@ -776,6 +847,8 @@ export class LiveStream {
776
847
  this.closeJpeg()
777
848
  const child = this.recorder
778
849
  this.recorder = null
850
+ /* 本地杀进程不等于设备端停了:先按自己记下的 pid 收掉设备端录屏,否则下一次开流会撞上。 */
851
+ this.killOwnRecorder()
779
852
  if (child !== null) {
780
853
  try {
781
854
  child.kill('SIGKILL')
@@ -785,8 +858,6 @@ export class LiveStream {
785
858
  }
786
859
  this.intentional = true
787
860
  this.stopping = false
788
- /* 本地杀进程不等于设备端停了:显式收掉,否则下一次开流会撞上另一个录屏在跑。 */
789
- if (this.sessions > 0) this.killStale()
790
861
  this.init = null
791
862
  this.lastKeyChunks = []
792
863
  this.waiting.clear()
@@ -0,0 +1,129 @@
1
+ /**
2
+ * 坐标换算回归:从 client.js 里切出真实的 pictureRect / turned / devicePoint 源码,
3
+ * 在 node 里用假 DOM 矩形跑。侧栏在右边时坐标曾经被减两次(视觉中心点成 0,600),
4
+ * 这条测试就是那份审阅复现的固化版:改坏就红。
5
+ *
6
+ * node test/coords.test.mjs
7
+ */
8
+
9
+ import fs from 'node:fs'
10
+ import assert from 'node:assert/strict'
11
+ import path from 'node:path'
12
+ import { fileURLToPath } from 'node:url'
13
+
14
+ const here = path.dirname(fileURLToPath(import.meta.url))
15
+ const source = fs.readFileSync(path.join(here, '..', 'client.js'), 'utf8')
16
+
17
+ const start = source.indexOf('/* 画面元素:MSE 用 video')
18
+ const end = source.indexOf('/* 平移边界')
19
+ assert.ok(start > 0 && end > start, '没找到几何换算那一段源码')
20
+
21
+ const geometry = source.slice(start, end)
22
+ const factory = new Function(
23
+ 'engine', 'videoRef', 'imgRef', 'canvasRef', 'screen', 'frameSize', 'Math',
24
+ `${geometry}\n return { devicePoint, pictureRect, turned }`,
25
+ )
26
+
27
+ /**
28
+ * 造一个用给定矩形假装的 DOM 元素。
29
+ * @param {number} left - 视口左边缘。
30
+ * @param {number} top - 视口上边缘。
31
+ * @param {number} width - 宽。
32
+ * @param {number} height - 高。
33
+ * @returns {{ getBoundingClientRect: () => any }} 元素桩。
34
+ */
35
+ const box = (left, top, width, height) => ({ getBoundingClientRect: () => ({ left, top, width, height, right: left + width, bottom: top + height }) })
36
+
37
+ /**
38
+ * 造一台设备状态下的换算器。
39
+ * @param {{ device: {w: number, h: number}, frame: {w: number, h: number}, canvas: any, media: any, engine?: string }} config - 现场。
40
+ * @returns {(point: {x: number, y: number}) => {x: number, y: number}} 换算函数。
41
+ */
42
+ const make = ({ device, frame, canvas, media, engine = 'jpeg' }) => {
43
+ const { devicePoint } = factory(engine, { current: null }, { current: media }, { current: canvas }, device, frame, Math)
44
+ return devicePoint
45
+ }
46
+
47
+ let pass = 0
48
+ /**
49
+ * 断言一点。
50
+ * @param {string} label - 检查项。
51
+ * @param {() => void} run - 断言体。
52
+ * @returns {void}
53
+ */
54
+ const it = (label, run) => {
55
+ try {
56
+ run()
57
+ pass += 1
58
+ console.log(` ✅ ${label}`)
59
+ } catch (error) {
60
+ console.log(` ❌ ${label}`)
61
+ throw error
62
+ }
63
+ }
64
+
65
+ console.log('\n坐标换算(源码级)\n')
66
+
67
+ /* 审阅给的复现:右栏里画面框 left=1000/top=100,显示 300x533.33,设备 1080x1920。
68
+ 取画面正中心,正确结果是 (540,960);旧代码会算成 (0,600)。 */
69
+ it('右栏里点画面正中心 = 设备正中心', () => {
70
+ const toDevice = make({
71
+ device: { w: 1080, h: 1920 },
72
+ frame: { w: 300, h: 533.333 },
73
+ canvas: box(1000, 100, 300, 533.333),
74
+ media: box(1000, 100, 300, 533.333),
75
+ })
76
+ assert.deepEqual(toDevice({ x: 150, y: 266.666 }), { x: 540, y: 960 })
77
+ })
78
+
79
+ it('画面有上下留边时按画面矩形换算(不是画布)', () => {
80
+ const toDevice = make({
81
+ device: { w: 1080, h: 1350 },
82
+ frame: { w: 400, h: 500 },
83
+ canvas: box(20, 40, 400, 600),
84
+ media: box(20, 90, 400, 500),
85
+ })
86
+ assert.deepEqual(toDevice({ x: 200, y: 250 }), { x: 540, y: 540 })
87
+ })
88
+
89
+ it('画面有左右留边时按画面矩形换算', () => {
90
+ const toDevice = make({
91
+ device: { w: 1080, h: 1080 },
92
+ frame: { w: 400, h: 400 },
93
+ canvas: box(0, 0, 500, 400),
94
+ media: box(50, 0, 400, 400),
95
+ })
96
+ assert.deepEqual(toDevice({ x: 250, y: 200 }), { x: 540, y: 540 })
97
+ })
98
+
99
+ it('画面外沿夹到边界,不会溢成负数', () => {
100
+ const toDevice = make({
101
+ device: { w: 1080, h: 1920 },
102
+ frame: { w: 300, h: 533 },
103
+ canvas: box(1000, 100, 300, 533),
104
+ media: box(1000, 100, 300, 533),
105
+ })
106
+ assert.deepEqual(toDevice({ x: -50, y: 900 }), { x: 0, y: 1920 })
107
+ })
108
+
109
+ it('横屏画面 + 竖屏设备(转过 90°)映射到长边', () => {
110
+ const toDevice = make({
111
+ device: { w: 2136, h: 3200 },
112
+ frame: { w: 2400, h: 1080 },
113
+ canvas: box(0, 0, 2400, 1080),
114
+ media: box(0, 0, 2400, 1080),
115
+ })
116
+ assert.deepEqual(toDevice({ x: 1200, y: 540 }), { x: 1068, y: 1600 })
117
+ })
118
+
119
+ it('MSE 走 video 元素,抓帧走 img,两条路都拿画面矩形', () => {
120
+ const canvas = box(0, 0, 300, 500)
121
+ const video = box(0, 0, 300, 500)
122
+ const img = box(10, 20, 280, 460)
123
+ const mse = factory('mse', { current: video }, { current: img }, { current: canvas }, { w: 1080, h: 1800 }, { w: 300, h: 500 }, Math)
124
+ assert.deepEqual(mse.devicePoint({ x: 150, y: 250 }), { x: 540, y: 900 })
125
+ const jpeg = factory('jpeg', { current: video }, { current: img }, { current: canvas }, { w: 1080, h: 1800 }, { w: 300, h: 500 }, Math)
126
+ assert.deepEqual(jpeg.devicePoint({ x: 150, y: 250 }), { x: 540, y: 900 })
127
+ })
128
+
129
+ console.log(`\n${pass} 项通过\n`)
@@ -0,0 +1,73 @@
1
+ /**
2
+ * 悬空 this 调用扫描:rename 后残留 `this.oldName()` 会让插件在回调里抛 TypeError,
3
+ * 而宿主不隔离插件异常 —— 一次空转定时器就能打死整个 DSH(2026-10-27 的 killStale 事故)。
4
+ * 只做一件事:文件里每个 `this.foo(` 都要能找到定义(方法简写或 this.foo = 赋值)。
5
+ */
6
+ import { readFileSync } from 'node:fs'
7
+
8
+ const FILES = ['stream.js', 'mirror.js', 'index.js', 'client.js']
9
+
10
+ /** 从源码里收集「哪些名字在这份文件里是可调用的 this 成员」。 */
11
+ function knownMembers(source) {
12
+ const names = new Set()
13
+ /* this.foo = ... 的赋值(含 this.foo = function / 箭头函数)。 */
14
+ for (const m of source.matchAll(/this\.([A-Za-z_$][\w$]*)\s*=(?!=)/g)) names.add(m[1])
15
+ /* 方法简写 / 类字段箭头函数:行首缩进后的 `foo(...) {` 或 `foo = (...) =>`。 */
16
+ for (const m of source.matchAll(/^\s{2,}(?:async\s+)?(?:get\s+|set\s+)?([A-Za-z_$][\w$]*)\s*\([^)]*\)\s*\{/gm)) names.add(m[1])
17
+ for (const m of source.matchAll(/^\s{2,}([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?\(/gm)) names.add(m[1])
18
+ /* 对象字面量里的方法(route 表这类)也给过。 */
19
+ for (const m of source.matchAll(/([A-Za-z_$][\w$]*)\s*:\s*(?:async\s*)?(?:function|\()/g)) names.add(m[1])
20
+ return names
21
+ }
22
+
23
+ /** 常见内置/外部注入成员,不要求在文件内定义。 */
24
+ const BUILTIN = new Set([
25
+ 'constructor', 'then', 'catch', 'finally', 'apply', 'call', 'bind', 'toString', 'valueOf',
26
+ 'hasOwnProperty', 'push', 'pop', 'shift', 'unshift', 'slice', 'splice', 'map', 'filter',
27
+ 'forEach', 'find', 'findIndex', 'some', 'every', 'reduce', 'join', 'split', 'trim',
28
+ 'replace', 'includes', 'startsWith', 'endsWith', 'padStart', 'padEnd', 'toFixed',
29
+ 'getTime', 'toISOString', 'keys', 'values', 'entries', 'set', 'get', 'delete', 'has',
30
+ 'clear', 'add', 'size', 'length', 'emit', 'on', 'once', 'off', 'removeListener', 'end',
31
+ 'write', 'pipe', 'read', 'kill', 'ref', 'unref', 'stdin', 'stdout', 'stderr',
32
+ ])
33
+
34
+ let failures = 0
35
+ const it = (label, run) => {
36
+ try {
37
+ run()
38
+ console.log(` ✅ ${label}`)
39
+ } catch (error) {
40
+ failures += 1
41
+ console.log(` ❌ ${label}\n ${String(error?.message ?? error)}`)
42
+ }
43
+ }
44
+ const assert = (cond, msg) => {
45
+ if (!cond) throw new Error(msg)
46
+ }
47
+
48
+ for (const file of FILES) {
49
+ it(`${file}:this.* 调用都有定义`, () => {
50
+ const source = readFileSync(new URL(`../${file}`, import.meta.url), 'utf8')
51
+ const known = knownMembers(source)
52
+ const dangling = new Set()
53
+ for (const m of source.matchAll(/this\.([A-Za-z_$][\w$]*)\s*\(/g)) {
54
+ const name = m[1]
55
+ if (known.has(name) || BUILTIN.has(name)) continue
56
+ dangling.add(name)
57
+ }
58
+ assert(dangling.size === 0, `悬空调用:${[...dangling].join(', ')}(改名后残留会让插件回调抛 TypeError,host 直接退出)`)
59
+ })
60
+ }
61
+
62
+ /* 自检:故意造一份带残留调用的源码,确认扫描器抓得到,别写成永远绿的摆设。 */
63
+ it('扫描器能抓到悬空调用(自检)', () => {
64
+ const broken = 'class A {\n killOwn() {}\n stop() {\n if (this.n > 0) this.killStale()\n }\n}\n'
65
+ const known = knownMembers(broken)
66
+ const dangling = [...broken.matchAll(/this\.([A-Za-z_$][\w$]*)\s*\(/g)]
67
+ .map((m) => m[1])
68
+ .filter((name) => !known.has(name) && !BUILTIN.has(name))
69
+ assert(dangling.includes('killStale'), '扫描器没抓到 killStale,说明这个测试本身失效了')
70
+ })
71
+
72
+ console.log(failures === 0 ? `\n=== 全部通过 ===` : `\n=== ${failures} 项失败 ===`)
73
+ process.exitCode = failures === 0 ? 0 : 1
@@ -0,0 +1,60 @@
1
+ /**
2
+ * 定时器兜底扫描:宿主不隔离插件异常,定时器回调里任何一个 TypeError 都会冒到顶层,
3
+ * 整个 DSH 以 1 退出(2026-10-27 `this.killStale` 两次事故就是空闲回收定时器 → stop())。
4
+ * 规则:host 侧所有节拍器/回收定时器的回调都要经过 guard(),单拍出错只记日志。
5
+ */
6
+ import { readFileSync } from 'node:fs'
7
+
8
+ /** 以 this 成员形式挂出去的定时器(节拍器与空闲/续录回收)。 */
9
+ const PATTERN = /this\.(timer|pushTimer|idleTimer|restartTimer)\s*=\s*(?:setInterval|setTimeout)\(/g
10
+
11
+ /** 从注册点往后看多远算「同一个回调」。 */
12
+ const WINDOW = 160
13
+
14
+ /** 找出没走 guard 的定时器注册。 @param {string} source - 文件源码。 @returns {string[]} 违规点。 */
15
+ function unguarded(source) {
16
+ const bad = []
17
+ for (const m of source.matchAll(PATTERN)) {
18
+ const tail = source.slice(m.index, m.index + WINDOW)
19
+ if (!tail.includes('guard(')) bad.push(source.slice(m.index, m.index + 60).split('\n')[0])
20
+ }
21
+ return bad
22
+ }
23
+
24
+ let failures = 0
25
+ const it = (label, run) => {
26
+ try {
27
+ run()
28
+ console.log(` ✅ ${label}`)
29
+ } catch (error) {
30
+ failures += 1
31
+ console.log(` ❌ ${label}\n ${String(error?.message ?? error)}`)
32
+ }
33
+ }
34
+ const assert = (cond, msg) => {
35
+ if (!cond) throw new Error(msg)
36
+ }
37
+
38
+ for (const file of ['stream.js', 'mirror.js']) {
39
+ it(`${file}:定时器回调都经过 guard`, () => {
40
+ const bad = unguarded(readFileSync(new URL(`../${file}`, import.meta.url), 'utf8'))
41
+ assert(bad.length === 0, `未兜底的定时器:${bad.join(' | ')}`)
42
+ })
43
+ }
44
+
45
+ it('自检:没兜底的定时器会被抓出来', () => {
46
+ const fake = 'this.idleTimer = setTimeout(() => { this.stop() }, 20000)'
47
+ assert(unguarded(fake).length === 1, '扫描放过了一个裸定时器')
48
+ })
49
+
50
+ it('自检:兜底后的定时器不误报', () => {
51
+ const fake = "this.idleTimer = setTimeout(() => guard('idle-stop', () => { this.stop() }), 20000)"
52
+ assert(unguarded(fake).length === 0, '兜底写法被误判')
53
+ })
54
+
55
+ if (failures > 0) {
56
+ console.log(`=== ${failures} 项失败 ===`)
57
+ process.exitCode = 1
58
+ } else {
59
+ console.log('=== 全部通过 ===')
60
+ }