@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.
- package/README.md +14 -5
- package/dist/mcp/index.js +131 -9
- package/dist/mcp/session-backfill.d.ts +26 -0
- package/dist/mcp/session-backfill.js +156 -0
- package/dist/mcp/session-detail.d.ts +36 -0
- package/dist/mcp/session-detail.js +109 -0
- package/dist/mcp/session-query.d.ts +57 -0
- package/dist/mcp/session-query.js +327 -0
- package/dist/mcp/tools.js +22 -0
- package/dist/shared/constants.d.ts +8 -0
- package/dist/shared/constants.js +8 -0
- package/dist/shared/event-spool.d.ts +75 -0
- package/dist/shared/event-spool.js +366 -0
- package/dist/shared/screen.d.ts +48 -0
- package/dist/shared/screen.js +195 -6
- package/dist/shared/stream.d.ts +5 -1
- package/dist/shared/stream.js +39 -0
- package/dist/web/log-session.js +6 -0
- package/dist/web/server.js +15 -1
- package/package.json +3 -2
package/dist/shared/screen.js
CHANGED
|
@@ -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
|
-
//
|
|
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=${
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = () => {
|
package/dist/shared/stream.d.ts
CHANGED
|
@@ -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;
|
package/dist/shared/stream.js
CHANGED
|
@@ -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();
|
package/dist/web/log-session.js
CHANGED
|
@@ -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
|
package/dist/web/server.js
CHANGED
|
@@ -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.
|
|
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
|
|
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"
|