@slack/radar-mcp 1.9.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/dist/mcp/index.js +1 -1
- package/dist/shared/screen.d.ts +48 -0
- package/dist/shared/screen.js +195 -6
- package/dist/web/server.js +15 -1
- package/package.json +1 -1
package/dist/mcp/index.js
CHANGED
|
@@ -668,7 +668,7 @@ async function runMcpServer() {
|
|
|
668
668
|
}
|
|
669
669
|
}
|
|
670
670
|
// --- MCP Server ---
|
|
671
|
-
const server = new Server({ name: "slack-radar", version: "1.9.
|
|
671
|
+
const server = new Server({ name: "slack-radar", version: "1.9.1" }, { capabilities: { tools: {} }, instructions: SERVER_INSTRUCTIONS });
|
|
672
672
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
673
673
|
tools: TOOL_DEFINITIONS,
|
|
674
674
|
}));
|
package/dist/shared/screen.d.ts
CHANGED
|
@@ -102,6 +102,28 @@ export declare function pickDisplay(displays: DisplayInfo[]): {
|
|
|
102
102
|
};
|
|
103
103
|
/** Strip any text prefix (e.g. a multi-display warning) before the PNG magic header. */
|
|
104
104
|
export declare function stripPngPrefix(raw: Buffer): Buffer;
|
|
105
|
+
/**
|
|
106
|
+
* Clamp a requested frame rate to a safe positive integer for use in an ffmpeg `fps=` filter.
|
|
107
|
+
* The rate ultimately comes from a client-controlled `?fps=` query param, and a raw number
|
|
108
|
+
* threaded into `fps=${n}` is a footgun: a negative or Infinity value makes ffmpeg write a
|
|
109
|
+
* 0-byte file (silent no-recording), a fractional value produces a stuttering near-empty clip,
|
|
110
|
+
* and a huge value hangs ffmpeg (a leaked, never-exiting encoder). Round to an integer and bound
|
|
111
|
+
* to [MIN_FPS, MAX_FPS]; anything non-finite or out of range falls back to DEFAULT_FPS.
|
|
112
|
+
*/
|
|
113
|
+
export declare function clampFps(fps: number): number;
|
|
114
|
+
/**
|
|
115
|
+
* ffmpeg args for the record-to-mp4 pipeline: the live cast's concatenated JPEG stream on
|
|
116
|
+
* stdin -> a wall-clock-timed, constant-fps H.264 mp4 at `out`. Pure + exported so the arg
|
|
117
|
+
* SHAPE is unit-testable: the recording is a spawned subprocess with no device-free way to
|
|
118
|
+
* assert timing, and two arg bugs have hidden here — a fixed input `-r` (stamped every frame
|
|
119
|
+
* at 1/fps, slow motion) and `-use_wallclock_as_timestamps` paired with `-f image2pipe`
|
|
120
|
+
* (which ignores the flag and re-stamps a synthetic rate). The invariants the test pins:
|
|
121
|
+
* no `-r` before `-i`; the wallclock flag paired with the mjpeg demuxer that honors it (NOT
|
|
122
|
+
* image2pipe); and an output `fps` resample so playback is real-time and CFR. fps is clamped
|
|
123
|
+
* defensively here too, so the filter string is always a safe integer even if a caller skips
|
|
124
|
+
* the boundary clamp.
|
|
125
|
+
*/
|
|
126
|
+
export declare function recordingArgs(fps: number, out: string): string[];
|
|
105
127
|
/** Detect (and cache) the active display id. resetScreenState() forces a re-detect. */
|
|
106
128
|
export declare function detectDisplay(): Promise<string | null>;
|
|
107
129
|
export declare function displayNote(): string;
|
|
@@ -120,7 +142,33 @@ interface LiveCast {
|
|
|
120
142
|
recording: Recording | null;
|
|
121
143
|
fps: number;
|
|
122
144
|
keepalive: NodeJS.Timeout | null;
|
|
145
|
+
orphanTimer: NodeJS.Timeout | null;
|
|
123
146
|
}
|
|
147
|
+
/**
|
|
148
|
+
* Is this viewer's socket still open and writable? A closed/destroyed response is dead.
|
|
149
|
+
* Exported for unit testing: this predicate decides whether pushFrame prunes a viewer,
|
|
150
|
+
* so a regression here would either leak (never prune) or drop live viewers (prune too eagerly).
|
|
151
|
+
*/
|
|
152
|
+
export declare function viewerAlive(r: NodeJS.WritableStream): boolean;
|
|
153
|
+
/**
|
|
154
|
+
* Pure decision for what to do once a viewer is gone, given how many viewers remain and
|
|
155
|
+
* whether a recording is running. Extracted from afterViewerRemoved so the loop-closing
|
|
156
|
+
* logic is unit-testable without spawning: this is the gate that decides between leaking
|
|
157
|
+
* (keep the cast) and reaping. "watched" = a viewer is still attached, keep the cast as-is;
|
|
158
|
+
* "arm-grace" = unwatched but recording, hold briefly for a clean stop; "reap" = nothing
|
|
159
|
+
* left, tear down now.
|
|
160
|
+
*/
|
|
161
|
+
export declare function nextViewerAction(viewers: number, recording: boolean): "watched" | "arm-grace" | "reap";
|
|
162
|
+
/**
|
|
163
|
+
* The literal respawn root cause, as a pure predicate: on a screenrecord/ffmpeg exit, relaunch
|
|
164
|
+
* a fresh cast ONLY while a viewer is still watching. This is the exact gate onExit turns on
|
|
165
|
+
* ("if (viewers.size || recording) relaunch" was the bug — a recording alone respawned forever
|
|
166
|
+
* with no viewer). Extracted pure so the root-cause line is unit-testable, not only device-verified.
|
|
167
|
+
* Note the asymmetry with nextViewerAction: a recording does NOT keep the cast alive here (onExit
|
|
168
|
+
* lets cleanupLive finalize the clip + reap); it only defers reaping in the viewer-removal path
|
|
169
|
+
* via the orphan grace. So relaunch depends on viewers ALONE.
|
|
170
|
+
*/
|
|
171
|
+
export declare function shouldRelaunch(viewers: number): boolean;
|
|
124
172
|
/**
|
|
125
173
|
* Start (or return the existing) live cast. Returns null if ffmpeg is unavailable
|
|
126
174
|
* — callers must degrade rather than assume a stream. The boundary string is
|
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/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;
|