@slack/radar-mcp 1.8.0 → 1.9.1

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.
@@ -230,6 +230,53 @@ export function stripPngPrefix(raw) {
230
230
  const off = raw.indexOf(PNG_MAGIC);
231
231
  return off > 0 ? raw.subarray(off) : raw;
232
232
  }
233
+ // Bounds for a recording/live-cast frame rate. Client-controlled (?fps=), so clampFps holds
234
+ // the range in one place. MAX 60 = the device panel refresh ceiling (screenrecord cannot feed
235
+ // faster); MIN 1 = a valid non-degenerate rate; DEFAULT 20 = the measured sweet spot the UI
236
+ // uses and the fallback for any out-of-range/non-finite request.
237
+ const MIN_FPS = 1;
238
+ const MAX_FPS = 60;
239
+ const DEFAULT_FPS = 20;
240
+ /**
241
+ * Clamp a requested frame rate to a safe positive integer for use in an ffmpeg `fps=` filter.
242
+ * The rate ultimately comes from a client-controlled `?fps=` query param, and a raw number
243
+ * threaded into `fps=${n}` is a footgun: a negative or Infinity value makes ffmpeg write a
244
+ * 0-byte file (silent no-recording), a fractional value produces a stuttering near-empty clip,
245
+ * and a huge value hangs ffmpeg (a leaked, never-exiting encoder). Round to an integer and bound
246
+ * to [MIN_FPS, MAX_FPS]; anything non-finite or out of range falls back to DEFAULT_FPS.
247
+ */
248
+ export function clampFps(fps) {
249
+ const n = Math.round(fps);
250
+ return Number.isFinite(n) && n >= MIN_FPS && n <= MAX_FPS ? n : DEFAULT_FPS;
251
+ }
252
+ /**
253
+ * ffmpeg args for the record-to-mp4 pipeline: the live cast's concatenated JPEG stream on
254
+ * stdin -> a wall-clock-timed, constant-fps H.264 mp4 at `out`. Pure + exported so the arg
255
+ * SHAPE is unit-testable: the recording is a spawned subprocess with no device-free way to
256
+ * assert timing, and two arg bugs have hidden here — a fixed input `-r` (stamped every frame
257
+ * at 1/fps, slow motion) and `-use_wallclock_as_timestamps` paired with `-f image2pipe`
258
+ * (which ignores the flag and re-stamps a synthetic rate). The invariants the test pins:
259
+ * no `-r` before `-i`; the wallclock flag paired with the mjpeg demuxer that honors it (NOT
260
+ * image2pipe); and an output `fps` resample so playback is real-time and CFR. fps is clamped
261
+ * defensively here too, so the filter string is always a safe integer even if a caller skips
262
+ * the boundary clamp.
263
+ */
264
+ export function recordingArgs(fps, out) {
265
+ return [
266
+ "-hide_banner", "-loglevel", "error", "-y",
267
+ // Raw MJPEG demuxer (not image2pipe): only this demuxer honors
268
+ // -use_wallclock_as_timestamps on a piped JPEG stream, stamping each frame by its true
269
+ // arrival time regardless of frame size. image2pipe stamps a synthetic fixed rate.
270
+ "-f", "mjpeg",
271
+ "-use_wallclock_as_timestamps", "1",
272
+ "-i", "pipe:0",
273
+ // Resample the real arrival timeline to a constant fps so the mp4 is CFR (smooth/seekable
274
+ // everywhere) and its duration matches wall-clock.
275
+ "-vf", `fps=${clampFps(fps)}`,
276
+ "-c:v", "libx264", "-pix_fmt", "yuv420p", "-preset", "veryfast",
277
+ out,
278
+ ];
279
+ }
233
280
  // ---- active-display detection ------------------------------------------------
234
281
  let scDisplay = null;
235
282
  let scDetected = false;
@@ -275,6 +322,95 @@ export function captureScreenshot() {
275
322
  });
276
323
  }
277
324
  let scLive = null;
325
+ // When the last viewer leaves while a recording is running, wait this long for a clean
326
+ // /screen/record/stop before treating the recording as orphaned (tab closed/crashed
327
+ // before its stop POST landed). The clean stop POST arrives in well under a second; this
328
+ // window comfortably clears it while bounding an orphaned cast to a few seconds instead
329
+ // of looping screenrecord until its next self-exit (or forever, via relaunch).
330
+ const ORPHAN_GRACE_MS = 8000;
331
+ /**
332
+ * Is this viewer's socket still open and writable? A closed/destroyed response is dead.
333
+ * Exported for unit testing: this predicate decides whether pushFrame prunes a viewer,
334
+ * so a regression here would either leak (never prune) or drop live viewers (prune too eagerly).
335
+ */
336
+ export function viewerAlive(r) {
337
+ const w = r;
338
+ return w.writable !== false && w.writableEnded !== true && w.destroyed !== true;
339
+ }
340
+ /**
341
+ * Arm the orphan-grace timer: if no viewer returns and no clean stop arrives, finalize the
342
+ * orphaned recording (saved to disk) and reap the cast so screenrecord stops looping. Idempotent
343
+ * — a second call while armed is a no-op. unref'd so it never keeps the process alive on its own.
344
+ */
345
+ function armOrphanGrace(L) {
346
+ if (L.orphanTimer)
347
+ return;
348
+ L.orphanTimer = setTimeout(() => {
349
+ L.orphanTimer = null;
350
+ if (scLive === L && !L.viewers.size && L.recording) {
351
+ // .catch is defensive: stopRecording never rejects and reapLiveIfIdle is sync/guarded
352
+ // today, but this runs in a bare timer callback where an unhandled rejection would be
353
+ // process-wide, so a future edit that lets either throw must not escape here.
354
+ void stopRecording(L)
355
+ .then(() => reapLiveIfIdle())
356
+ .catch(() => {
357
+ /* best-effort orphan reap; nothing to recover to */
358
+ });
359
+ }
360
+ }, ORPHAN_GRACE_MS);
361
+ if (typeof L.orphanTimer.unref === "function")
362
+ L.orphanTimer.unref();
363
+ }
364
+ function clearOrphanGrace(L) {
365
+ if (L.orphanTimer) {
366
+ clearTimeout(L.orphanTimer);
367
+ L.orphanTimer = null;
368
+ }
369
+ }
370
+ /**
371
+ * Pure decision for what to do once a viewer is gone, given how many viewers remain and
372
+ * whether a recording is running. Extracted from afterViewerRemoved so the loop-closing
373
+ * logic is unit-testable without spawning: this is the gate that decides between leaking
374
+ * (keep the cast) and reaping. "watched" = a viewer is still attached, keep the cast as-is;
375
+ * "arm-grace" = unwatched but recording, hold briefly for a clean stop; "reap" = nothing
376
+ * left, tear down now.
377
+ */
378
+ export function nextViewerAction(viewers, recording) {
379
+ if (viewers > 0)
380
+ return "watched";
381
+ return recording ? "arm-grace" : "reap";
382
+ }
383
+ /**
384
+ * The literal respawn root cause, as a pure predicate: on a screenrecord/ffmpeg exit, relaunch
385
+ * a fresh cast ONLY while a viewer is still watching. This is the exact gate onExit turns on
386
+ * ("if (viewers.size || recording) relaunch" was the bug — a recording alone respawned forever
387
+ * with no viewer). Extracted pure so the root-cause line is unit-testable, not only device-verified.
388
+ * Note the asymmetry with nextViewerAction: a recording does NOT keep the cast alive here (onExit
389
+ * lets cleanupLive finalize the clip + reap); it only defers reaping in the viewer-removal path
390
+ * via the orphan grace. So relaunch depends on viewers ALONE.
391
+ */
392
+ export function shouldRelaunch(viewers) {
393
+ return viewers > 0;
394
+ }
395
+ /**
396
+ * Called after a viewer is removed (leave or dead-viewer prune). If the cast is now
397
+ * unwatched, either arm the orphan grace (a recording is still running) or reap it outright
398
+ * (nothing left to keep it alive). This is the single "is anyone still watching?" gate.
399
+ */
400
+ function afterViewerRemoved() {
401
+ if (!scLive)
402
+ return;
403
+ switch (nextViewerAction(scLive.viewers.size, scLive.recording !== null)) {
404
+ case "watched":
405
+ return;
406
+ case "arm-grace":
407
+ armOrphanGrace(scLive);
408
+ return;
409
+ case "reap":
410
+ reapLiveIfIdle();
411
+ return;
412
+ }
413
+ }
278
414
  function screenrecordArgs() {
279
415
  const a = ["exec-out", "screenrecord", "--output-format=h264", "--size", SC_SIZE, "--bit-rate", SC_BITRATE];
280
416
  if (scDisplay)
@@ -300,16 +436,32 @@ async function primeFrame(ffmpeg) {
300
436
  function pushFrame(L, frame) {
301
437
  const head = Buffer.from(`--${SC_BOUNDARY}\r\nContent-Type: image/jpeg\r\nContent-Length: ${frame.length}\r\n\r\n`);
302
438
  const tail = Buffer.from("\r\n");
439
+ // Prune viewers whose socket died without firing req.on("close") — a half-open TCP
440
+ // connection (host sleep, network drop, killed browser) leaves the response object in
441
+ // the set but not writable. Without pruning, a dead viewer keeps the cast "watched"
442
+ // forever and relaunch respawns screenrecord on every self-exit. This frame loop is the
443
+ // heartbeat that detects the dead socket. Collect first, mutate the set after iterating.
444
+ let dead = null;
303
445
  for (const r of L.viewers) {
446
+ if (!viewerAlive(r)) {
447
+ (dead ??= []).push(r);
448
+ continue;
449
+ }
304
450
  try {
305
451
  r.write(head);
306
452
  r.write(frame);
307
453
  r.write(tail);
308
454
  }
309
455
  catch {
310
- // viewer disconnected; harmless
456
+ // write threw on a socket that looked alive; treat it as dead too
457
+ (dead ??= []).push(r);
311
458
  }
312
459
  }
460
+ if (dead) {
461
+ for (const r of dead)
462
+ L.viewers.delete(r);
463
+ afterViewerRemoved();
464
+ }
313
465
  if (L.recording) {
314
466
  try {
315
467
  L.recording.proc.stdin?.write(frame);
@@ -358,6 +510,7 @@ function reapCastProcesses(L) {
358
510
  clearInterval(L.keepalive);
359
511
  L.keepalive = null;
360
512
  }
513
+ clearOrphanGrace(L);
361
514
  L.lastFrame = null;
362
515
  try {
363
516
  L.sr.kill("SIGKILL");
@@ -383,10 +536,14 @@ export function startLive(fps) {
383
536
  const ffmpeg = resolveFfmpeg();
384
537
  if (!ffmpeg)
385
538
  return null;
539
+ // Clamp once at the boundary: fps comes from a client-controlled ?fps= param, and it is used
540
+ // both in the live filter below and (via scLive.fps) by the recording. A negative/fractional/
541
+ // huge value would break either pipeline. Store and use only the clamped value.
542
+ const safeFps = clampFps(fps);
386
543
  const sr = spawn(adb(), screenrecordArgs(), { stdio: ["ignore", "pipe", "ignore"] });
387
544
  const ff = spawn(ffmpeg, [
388
545
  "-hide_banner", "-loglevel", "error", "-f", "h264", "-probesize", "64k", "-i", "pipe:0",
389
- "-vf", `format=yuvj420p,fps=${fps || 20}`, "-q:v", "6", "-flush_packets", "1",
546
+ "-vf", `format=yuvj420p,fps=${safeFps}`, "-q:v", "6", "-flush_packets", "1",
390
547
  "-f", "image2pipe", "-c:v", "mjpeg", "pipe:1",
391
548
  ], { stdio: ["pipe", "pipe", "ignore"] });
392
549
  sr.stdout?.pipe(ff.stdin);
@@ -397,8 +554,9 @@ export function startLive(fps) {
397
554
  lastFrame: null,
398
555
  lastFrameTs: 0,
399
556
  recording: null,
400
- fps: fps || 20,
557
+ fps: safeFps,
401
558
  keepalive: null,
559
+ orphanTimer: null,
402
560
  };
403
561
  let acc = Buffer.alloc(0);
404
562
  ff.stdout?.on("data", (chunk) => {
@@ -426,7 +584,21 @@ export function startLive(fps) {
426
584
  const onExit = () => {
427
585
  if (scLive === L) {
428
586
  scLive = null;
429
- if (L.viewers.size || L.recording)
587
+ // Relaunch ONLY while a viewer is actually watching (shouldRelaunch). A recording with
588
+ // no viewers must NOT relaunch: that is the orphaned-recording respawn loop (a tab closed
589
+ // mid-record before its stop POST landed kept screenrecord self-exiting and relaunching
590
+ // forever). With no viewers, cleanupLive finalizes the in-progress recording (the clip is
591
+ // saved, not lost) and reaps the pipeline. The orphan-grace timer normally reaps this
592
+ // within a few seconds, before screenrecord even self-exits.
593
+ //
594
+ // Edge (dead-viewer + fully static screen): if the only remaining viewer is a half-open
595
+ // dead socket, viewers.size is still 1 here, so we relaunch. The dead-viewer prune runs
596
+ // inside pushFrame, so it needs at least one frame from the fresh cast to fire. A newly
597
+ // spawned screenrecord emits a startup keyframe even on a static screen (see the module
598
+ // header's DOZE-panel note), so the fresh cast produces a frame, pushFrame prunes the dead
599
+ // viewer, and afterViewerRemoved reaps. Worst case is a single bounded relaunch cycle, not
600
+ // an unbounded loop.
601
+ if (shouldRelaunch(L.viewers.size))
430
602
  relaunch(L);
431
603
  else
432
604
  cleanupLive(L);
@@ -455,13 +627,16 @@ export function startLive(fps) {
455
627
  }
456
628
  /** Attach an HTTP response as a viewer of the live cast (after the multipart headers are written). */
457
629
  export function addViewer(L, res) {
630
+ // A viewer returned; if the orphan-grace timer was counting down (last viewer had left
631
+ // while a recording ran), cancel it — the cast is watched again.
632
+ clearOrphanGrace(L);
458
633
  L.viewers.add(res);
459
634
  }
460
635
  export function removeViewer(res) {
461
636
  if (!scLive)
462
637
  return;
463
638
  scLive.viewers.delete(res);
464
- reapLiveIfIdle();
639
+ afterViewerRemoved();
465
640
  }
466
641
  /**
467
642
  * Tear down the live cast when nothing is keeping it alive: no viewers AND no active
@@ -521,7 +696,20 @@ export function startRecording() {
521
696
  }
522
697
  const safe = new Date().toISOString().replace(/[:.]/g, "-");
523
698
  const out = path.join(SC_TMP, `radar_screen_${safe}.mp4`);
524
- const rec = spawn(ffmpeg, ["-hide_banner", "-loglevel", "error", "-y", "-f", "image2pipe", "-r", String(scLive.fps), "-c:v", "mjpeg", "-i", "pipe:0", "-c:v", "libx264", "-pix_fmt", "yuv420p", "-preset", "veryfast", out], { stdio: ["pipe", "ignore", "ignore"] });
699
+ // Time the recording by real frame arrival, not a fixed input rate (the arg shape + the
700
+ // reasons it must be exactly this are in recordingArgs). The old args stamped every frame
701
+ // at a fixed 1/fps, so a clip recorded over N real seconds played back as frames/fps
702
+ // seconds — slow motion by the real-fps/target ratio.
703
+ const rec = spawn(ffmpeg, recordingArgs(scLive.fps, out), { stdio: ["pipe", "ignore", "ignore"] });
704
+ // A ChildProcess with no 'error' listener throws an unhandled 'error' (crashing the process)
705
+ // if the spawn itself fails — e.g. ffmpeg was removed between resolveFfmpeg() and here, or an
706
+ // EAGAIN under load. resolveFfmpeg() just succeeded so this is unlikely, but a recording is a
707
+ // best-effort debug convenience and must never take the server down. Swallow it; the frame
708
+ // tee in pushFrame already tolerates a dead stdin, and the user simply gets no clip.
709
+ rec.on("error", () => {
710
+ if (scLive?.recording?.proc === rec)
711
+ scLive.recording = null;
712
+ });
525
713
  scLive.recording = { proc: rec, path: out };
526
714
  return out;
527
715
  }
@@ -530,6 +718,7 @@ export function stopRecording(L = scLive) {
530
718
  return Promise.resolve(null);
531
719
  const r = L.recording;
532
720
  L.recording = null;
721
+ clearOrphanGrace(L);
533
722
  return new Promise((resolve) => {
534
723
  let done = false;
535
724
  const fin = () => {
@@ -6,11 +6,15 @@ export interface RadarEvent {
6
6
  [key: string]: unknown;
7
7
  }
8
8
  export interface ParsedSSE {
9
- type: "network" | "rtm" | "clog" | "log" | "trace";
9
+ type: "network" | "rtm" | "clog" | "log" | "trace" | `${string}_detail`;
10
10
  event: RadarEvent;
11
11
  }
12
12
  export declare const localNetworkBuffer: RadarEvent[];
13
13
  export declare const localRtmBuffer: RadarEvent[];
14
+ /** Set the reconnect hook. Called once after each successful reconnect. Pass null to clear it. */
15
+ export declare function setOnReconnect(fn: (() => void) | null): void;
16
+ export declare function noteStreamConnected(): void;
17
+ export declare function _resetReconnectStateForTest(): void;
14
18
  export declare function isStreamConnected(): boolean;
15
19
  export declare function clearLocalBuffers(): void;
16
20
  export declare function getStreamConnection(): http.ClientRequest | null;
@@ -1,5 +1,6 @@
1
1
  import http from "http";
2
2
  import { RADAR_HOST, RADAR_PORT, MAX_LOCAL_BUFFER } from "./constants.js";
3
+ import { writeFrame } from "./event-spool.js";
3
4
  let transport = null;
4
5
  /** Set the transport used to check forwarding state. Call once at startup. */
5
6
  export function setTransport(t) {
@@ -10,6 +11,37 @@ export const localRtmBuffer = [];
10
11
  let streamConnection = null;
11
12
  let streamConnected = false;
12
13
  const eventWaiters = [];
14
+ // Fires once after a RECONNECT (not the first connect), so the owner can backfill the device ring
15
+ // rows the ~2s gap dropped. index.ts sets it to the backfill; unset it is a no-op, so the web
16
+ // server (which shares this module) and tests are unaffected. hasConnectedOnce gates it to a true
17
+ // reconnect: the enable path already backfills the first connect, so firing here too would double
18
+ // the work (dedup would drop it, but the extra device pulls are wasted).
19
+ let onReconnect = null;
20
+ let hasConnectedOnce = false;
21
+ /** Set the reconnect hook. Called once after each successful reconnect. Pass null to clear it. */
22
+ export function setOnReconnect(fn) {
23
+ onReconnect = fn;
24
+ }
25
+ // Run on every successful stream connect. Fires the reconnect hook only on a true reconnect (a
26
+ // prior connect happened), never on the first connect (the enable path backfills that one). The
27
+ // hook is guarded so it can never throw into the stream. Exported for tests so the fires-on-
28
+ // reconnect / no-op-when-unset logic is checkable without a live socket.
29
+ export function noteStreamConnected() {
30
+ if (hasConnectedOnce && onReconnect) {
31
+ try {
32
+ onReconnect();
33
+ }
34
+ catch {
35
+ // A hook failure must not break the stream read loop.
36
+ }
37
+ }
38
+ hasConnectedOnce = true;
39
+ }
40
+ // Test-only: clear the connect/hook state so a test starts from a known first-connect baseline.
41
+ export function _resetReconnectStateForTest() {
42
+ onReconnect = null;
43
+ hasConnectedOnce = false;
44
+ }
13
45
  export function isStreamConnected() {
14
46
  return streamConnected;
15
47
  }
@@ -42,6 +74,10 @@ export function bufferEvent(parsed) {
42
74
  if (localRtmBuffer.length > MAX_LOCAL_BUFFER)
43
75
  localRtmBuffer.shift();
44
76
  }
77
+ // Spool every frame to the session file. writeFrame swallows its own errors
78
+ // and no-ops when no spool is open, so this cannot break the buffer or the
79
+ // waiter-notify path below.
80
+ writeFrame(parsed);
45
81
  notifyWaiters(parsed);
46
82
  }
47
83
  function notifyWaiters(parsed) {
@@ -100,6 +136,9 @@ export function connectStream() {
100
136
  return;
101
137
  const req = http.get({ host: RADAR_HOST, port: RADAR_PORT, path: "/api/stream", timeout: 0 }, (res) => {
102
138
  streamConnected = true;
139
+ // Fire the reconnect hook on a true reconnect (not the first connect). Kept in its own fn so
140
+ // the logic is unit-testable without a live socket.
141
+ noteStreamConnected();
103
142
  let partial = "";
104
143
  res.on("data", (chunk) => {
105
144
  partial += chunk.toString();
@@ -47,6 +47,12 @@ export class LogSession {
47
47
  }
48
48
  this.child = child;
49
49
  this.lineBuf = "";
50
+ // A background diagnostic child must not keep the host process alive on its
51
+ // own. The listening dashboard socket is what holds the event loop open in
52
+ // production; when that closes (a test, or a clean shutdown) node should be
53
+ // free to exit even if a logcat capture is still running. registerCleanup
54
+ // still kills the child on exit, so unref only removes the loop-blocking ref.
55
+ child.unref?.();
50
56
  child.stdout?.on("data", (c) => this.onData(c));
51
57
  // A crashed/killed logcat (device unplugged, adb server restart) clears the
52
58
  // child so the next connect() can restart it. The retained ring survives so
@@ -762,6 +762,13 @@ function handleScreen(req, res) {
762
762
  return;
763
763
  }
764
764
  const fps = Number(new URL(url, `http://localhost:${WEB_PORT}`).searchParams.get("fps")) || 20;
765
+ // Register the disconnect handler BEFORE the await. A client that aborts during
766
+ // detectDisplay() would otherwise become a phantom viewer whose close never fires, and
767
+ // on a static screen (no frames flowing) the frame-loop dead-viewer prune cannot catch
768
+ // it either — so it would hold the cast and respawn screenrecord. removeViewer is safe
769
+ // to call whether or not the response ever became a viewer (delete on an absent member
770
+ // is a no-op), so wiring it early costs nothing when the stream fails to start.
771
+ req.on("close", () => removeViewer(res));
765
772
  void (async () => {
766
773
  await detectDisplay();
767
774
  const L = startLive(fps);
@@ -769,13 +776,20 @@ function handleScreen(req, res) {
769
776
  sendJson(res, 501, { error: "ffmpeg not installed", installHint: ffmpegInstallHint() });
770
777
  return;
771
778
  }
779
+ // The client may have aborted during the await above (its close handler, wired before
780
+ // the await, already ran). Do not resurrect a dead socket as a viewer — that would be
781
+ // the very phantom viewer this reordering prevents. Reap in case startLive spun a cast
782
+ // up for a viewer that is already gone.
783
+ if (res.writableEnded || res.destroyed) {
784
+ reapLiveIfIdle();
785
+ return;
786
+ }
772
787
  res.writeHead(200, {
773
788
  "Content-Type": `multipart/x-mixed-replace; boundary=${SCREEN_BOUNDARY}`,
774
789
  "Cache-Control": "no-cache",
775
790
  Connection: "keep-alive",
776
791
  });
777
792
  addViewer(L, res);
778
- req.on("close", () => removeViewer(res));
779
793
  void primeIfNeeded();
780
794
  })();
781
795
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@slack/radar-mcp",
3
- "version": "1.8.0",
3
+ "version": "1.9.1",
4
4
  "description": "MCP server and web dashboard for on-device debugging of the Slack Android app via ADB",
5
5
  "type": "module",
6
6
  "main": "dist/mcp/index.js",
@@ -22,7 +22,8 @@
22
22
  "start": "node dist/mcp/index.js",
23
23
  "start:web": "node dist/web/bin.js",
24
24
  "watch": "tsc --watch",
25
- "test": "npm run build && node --test tests/*.test.mjs",
25
+ "test": "npm run build && node tests/run.mjs",
26
+ "test:tap": "npm run build && node --test --test-force-exit tests/*.test.mjs",
26
27
  "test:smoke": "npm run build && bash tests/smoke.sh",
27
28
  "test:all": "npm run test && npm run test:smoke",
28
29
  "prepare": "npm run build"