@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
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import os from "os";
|
|
3
|
+
import path from "path";
|
|
4
|
+
/**
|
|
5
|
+
* Appends every streamed SSE frame (network, rtm, clog, log, trace) to a
|
|
6
|
+
* per-session file so the full session record survives buffer eviction. The
|
|
7
|
+
* device rings and the host push ring both drop the oldest entry once full, so
|
|
8
|
+
* a long session loses its early events; the spool keeps the whole session. It
|
|
9
|
+
* does not replace the rings. The device ring stays the source of truth: it
|
|
10
|
+
* holds full payloads for detail and search, carries history from before the
|
|
11
|
+
* host connected, and serves the dashboard and every /api endpoint. This spool
|
|
12
|
+
* is a downstream summary log.
|
|
13
|
+
*
|
|
14
|
+
* It captures every frame delivered to the host, not every frame the device
|
|
15
|
+
* produced. Frames dropped during a transient device reconnect (callRadar tears
|
|
16
|
+
* the stream down and reconnects) never reach bufferEvent, so they are not
|
|
17
|
+
* spooled. Capture starts when the stream connects; there is no device backfill.
|
|
18
|
+
*
|
|
19
|
+
* A profile switch mid-session (activate_for_user) keeps appending to the same
|
|
20
|
+
* file, so two profiles' frames merge with no per-frame profile tag. Fine while
|
|
21
|
+
* nothing reads the spool; splitting by profile is for whoever adds the reader.
|
|
22
|
+
*
|
|
23
|
+
* Modeled on logcat-capture.ts: a session-scoped host file with a time-based GC,
|
|
24
|
+
* exit-handler cleanup, and safe rename-not-truncate rotation. One deliberate
|
|
25
|
+
* difference: no idle timeout. logcat idles out because a read can come long
|
|
26
|
+
* after the last write; this spool must hold the entire session, so it stops
|
|
27
|
+
* only on explicit disable or process exit.
|
|
28
|
+
*
|
|
29
|
+
* Lifecycle: startSpool when the session is enabled; stopSpool (which closes the
|
|
30
|
+
* fd but keeps the file) on explicit disable and on process exit, so a just-ended
|
|
31
|
+
* session stays readable until the GC reclaims it. Never on a transient device
|
|
32
|
+
* error. callRadar clears the enabled flag on a device blip; closing the spool
|
|
33
|
+
* there would cut the record short on a momentary drop.
|
|
34
|
+
*/
|
|
35
|
+
// Under os.tmpdir() (not ~/.slack-radar) so the OS reclaims it if the process
|
|
36
|
+
// dies unclean (crash, kill -9, no disable). Our own stopSpool + exit handlers +
|
|
37
|
+
// the GC still run first; the temp dir is the backstop for the unclean case.
|
|
38
|
+
// Same choice db.ts makes for its pulled-DB scratch, and same reason.
|
|
39
|
+
//
|
|
40
|
+
// Pid-scoped like db.ts's DB_TMP: every MCP shares one RADAR_PORT, so a fixed
|
|
41
|
+
// dir lets two concurrent MCP processes build the same events-<now>.jsonl path
|
|
42
|
+
// and interleave sessions, and one process's gcOldSpools would walk the other's
|
|
43
|
+
// live files. Scoping by pid gives each process its own dir. initializeSpoolDir
|
|
44
|
+
// runs at startup; the default is the unscoped path so unit tests that never
|
|
45
|
+
// call it still get a usable location.
|
|
46
|
+
let SPOOL_DIR = path.join(os.tmpdir(), "slack-radar-events");
|
|
47
|
+
let ROTATION_BYTES = 100 * 1024 * 1024;
|
|
48
|
+
const GC_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
49
|
+
// Cheap-cadence rotation check: run needsRotation only every Nth write so the
|
|
50
|
+
// hot path is a plain fd append almost always.
|
|
51
|
+
const ROTATION_CHECK_EVERY = 1000;
|
|
52
|
+
const state = {
|
|
53
|
+
fd: null,
|
|
54
|
+
filePath: null,
|
|
55
|
+
startedAt: null,
|
|
56
|
+
writeCount: 0,
|
|
57
|
+
};
|
|
58
|
+
// One-time breadcrumb latch: if writeFrame ever fails, log once so a spool that
|
|
59
|
+
// dies mid-session (ENOSPC/EACCES/EBADF) is diagnosable, without spamming a line
|
|
60
|
+
// per frame for the rest of the session. Reset on each startSpool.
|
|
61
|
+
let writeErrorLogged = false;
|
|
62
|
+
/**
|
|
63
|
+
* Point the spool dir at a per-process-unique path. Call once at startup with
|
|
64
|
+
* process.pid. Mirrors db.ts initializeDatabasePath: every MCP shares one port,
|
|
65
|
+
* so a pid key is the collision-free scope.
|
|
66
|
+
*/
|
|
67
|
+
export function initializeSpoolDir(scopeKey) {
|
|
68
|
+
SPOOL_DIR = path.join(os.tmpdir(), `slack-radar-events-${scopeKey}`);
|
|
69
|
+
}
|
|
70
|
+
function ensureSpoolDir() {
|
|
71
|
+
if (!fs.existsSync(SPOOL_DIR)) {
|
|
72
|
+
// 0700: the spool holds raw bodies, auth headers, and message content under
|
|
73
|
+
// a world-traversable tmpdir, so scope it to the owner. Same choice db.ts
|
|
74
|
+
// makes for its pulled-DB scratch in the same location.
|
|
75
|
+
fs.mkdirSync(SPOOL_DIR, { recursive: true, mode: 0o700 });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Delete spool files older than GC_TTL_MS. Runs at session enable. Bounds disk
|
|
80
|
+
* usage without losing recent session records. dir + now injectable for tests.
|
|
81
|
+
* Mirrors gcOldCaptures (logcat-capture.ts). Returns the count removed.
|
|
82
|
+
*/
|
|
83
|
+
export function gcOldSpools(dir = SPOOL_DIR, now = Date.now()) {
|
|
84
|
+
if (!fs.existsSync(dir))
|
|
85
|
+
return 0;
|
|
86
|
+
let removed = 0;
|
|
87
|
+
for (const name of fs.readdirSync(dir)) {
|
|
88
|
+
// Match the live files and their rotated archives.
|
|
89
|
+
if (!name.startsWith("events-") || !name.includes(".jsonl"))
|
|
90
|
+
continue;
|
|
91
|
+
const full = path.join(dir, name);
|
|
92
|
+
try {
|
|
93
|
+
const stat = fs.statSync(full);
|
|
94
|
+
if (now - stat.mtimeMs > GC_TTL_MS) {
|
|
95
|
+
fs.unlinkSync(full);
|
|
96
|
+
removed += 1;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
// ignore; file may have vanished between readdir and stat
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return removed;
|
|
104
|
+
}
|
|
105
|
+
export function buildSpoolFilePath(now = Date.now()) {
|
|
106
|
+
return path.join(SPOOL_DIR, `events-${now}.jsonl`);
|
|
107
|
+
}
|
|
108
|
+
// The epoch that pins a query_session cursor to one spool file: the <ts> in events-<ts>.jsonl. It
|
|
109
|
+
// changes on every rotation and re-enable, so a cursor minted against one file is detectably stale
|
|
110
|
+
// against another. Pure inverse of buildSpoolFilePath's naming; a null path (no spool) yields "".
|
|
111
|
+
// Owned here next to the name it strips so the mint and the check can never drift on the affixes.
|
|
112
|
+
export function epochFromSpoolPath(filePath) {
|
|
113
|
+
if (filePath === null)
|
|
114
|
+
return "";
|
|
115
|
+
return path.basename(filePath).replace(/^events-/, "").replace(/\.jsonl$/, "");
|
|
116
|
+
}
|
|
117
|
+
export function isSpooling() {
|
|
118
|
+
return state.fd !== null;
|
|
119
|
+
}
|
|
120
|
+
export function getSpoolFilePath() {
|
|
121
|
+
return state.filePath;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Read the current session spool back into parsed frames, oldest first (append
|
|
125
|
+
* order). Separate from the write path; reads the live file by its own handle,
|
|
126
|
+
* never touching the writer's fd or state. Returns [] when no spool is open, the
|
|
127
|
+
* file is gone, or it cannot be read. Never throws into the caller: a query over
|
|
128
|
+
* a missing or damaged spool must degrade to empty, not fail the tool.
|
|
129
|
+
*
|
|
130
|
+
* Reads the whole file and lets the query shaper's byte cap bound the OUTPUT,
|
|
131
|
+
* rather than tailing here: a filter can match an old frame near the top, so a
|
|
132
|
+
* byte-tail read could drop a frame the caller asked for. Rotation already caps
|
|
133
|
+
* the live file at a normal-session size, so a whole read is safe.
|
|
134
|
+
*
|
|
135
|
+
* A torn final line (the process died mid-append, before the newline) is skipped
|
|
136
|
+
* so one bad tail line never sinks the whole read. Any line that will not parse
|
|
137
|
+
* is dropped the same way.
|
|
138
|
+
*/
|
|
139
|
+
export function readSpool() {
|
|
140
|
+
const filePath = state.filePath;
|
|
141
|
+
if (filePath === null)
|
|
142
|
+
return [];
|
|
143
|
+
let raw;
|
|
144
|
+
try {
|
|
145
|
+
raw = fs.readFileSync(filePath, "utf-8");
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return [];
|
|
149
|
+
}
|
|
150
|
+
const frames = [];
|
|
151
|
+
for (const line of raw.split("\n")) {
|
|
152
|
+
if (line.length === 0)
|
|
153
|
+
continue;
|
|
154
|
+
try {
|
|
155
|
+
const parsed = JSON.parse(line);
|
|
156
|
+
// Only a well-formed frame: an object with a string `type` and an `event` object. A bare
|
|
157
|
+
// null/number/string (valid JSON, wrong shape) is dropped so a reader can dereference
|
|
158
|
+
// frame.event and frame.type without a guard on every field.
|
|
159
|
+
if (typeof parsed === "object" &&
|
|
160
|
+
parsed !== null &&
|
|
161
|
+
typeof parsed.type === "string" &&
|
|
162
|
+
typeof parsed.event === "object" &&
|
|
163
|
+
parsed.event !== null) {
|
|
164
|
+
frames.push(parsed);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
// Torn or malformed line: skip it, keep the rest.
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return frames;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Open a fresh spool file for append and set module state. Ensures the dir,
|
|
175
|
+
* runs gcOldSpools first, returns the path (or null on failure). Idempotent: a
|
|
176
|
+
* second call while a spool is open returns the current path without reopening.
|
|
177
|
+
*/
|
|
178
|
+
export function startSpool(now = Date.now()) {
|
|
179
|
+
if (isSpooling())
|
|
180
|
+
return state.filePath;
|
|
181
|
+
try {
|
|
182
|
+
ensureSpoolDir();
|
|
183
|
+
gcOldSpools();
|
|
184
|
+
const filePath = buildSpoolFilePath(now);
|
|
185
|
+
// 0600 for the same reason as the 0700 dir: the file is raw captured
|
|
186
|
+
// traffic, readable only by the owner. Matches db.ts's pulled-DB scratch.
|
|
187
|
+
const fd = fs.openSync(filePath, "a", 0o600);
|
|
188
|
+
state.fd = fd;
|
|
189
|
+
state.filePath = filePath;
|
|
190
|
+
state.startedAt = now;
|
|
191
|
+
state.writeCount = 0;
|
|
192
|
+
writeErrorLogged = false;
|
|
193
|
+
return filePath;
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
// Failed to open the spool. Leave state clean so writeFrame no-ops.
|
|
197
|
+
state.fd = null;
|
|
198
|
+
state.filePath = null;
|
|
199
|
+
state.startedAt = null;
|
|
200
|
+
state.writeCount = 0;
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Append one frame as a single JSONL line. On the hot path (every streamed
|
|
206
|
+
* event) and inside the wait_for_events notify path. Synchronous on purpose:
|
|
207
|
+
* a sync append keeps frames in arrival order with no interleave, which an async
|
|
208
|
+
* write queue would not guarantee. It is also cheap and never lets an error
|
|
209
|
+
* reach the caller, so a spool write failure can never break bufferEvent or
|
|
210
|
+
* waiter delivery. No-op if no spool is open. Errors are swallowed after one
|
|
211
|
+
* breadcrumb.
|
|
212
|
+
*/
|
|
213
|
+
export function writeFrame(frame) {
|
|
214
|
+
if (state.fd === null)
|
|
215
|
+
return;
|
|
216
|
+
try {
|
|
217
|
+
// Loop until the whole buffer is written. fs.writeSync can short-write on a
|
|
218
|
+
// signal or a near-full disk; a partial line with no newline would fuse
|
|
219
|
+
// onto the next frame and break the JSONL boundary a reader would need.
|
|
220
|
+
const buf = Buffer.from(JSON.stringify(frame) + "\n");
|
|
221
|
+
let offset = 0;
|
|
222
|
+
while (offset < buf.length) {
|
|
223
|
+
offset += fs.writeSync(state.fd, buf, offset, buf.length - offset);
|
|
224
|
+
}
|
|
225
|
+
state.writeCount += 1;
|
|
226
|
+
if (state.writeCount % ROTATION_CHECK_EVERY === 0) {
|
|
227
|
+
rotateSpoolIfNeeded();
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
catch (err) {
|
|
231
|
+
// Disk full, EACCES, closed fd: swallow so the caller's waiter delivery is
|
|
232
|
+
// never affected. Log once so a spool that quietly died mid-session is
|
|
233
|
+
// diagnosable instead of silent.
|
|
234
|
+
if (!writeErrorLogged) {
|
|
235
|
+
writeErrorLogged = true;
|
|
236
|
+
console.error(`[radar] event spool write failed, spool may be truncated: ${err}`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Close the fd and clear state, but KEEP the file on disk. Called on explicit
|
|
242
|
+
* disable and on process exit. The file survives so a just-ended session is
|
|
243
|
+
* still readable; the GC reclaims it after the TTL. This matches logcat, which
|
|
244
|
+
* also keeps its capture file on disable and lets the GC reclaim it. Never
|
|
245
|
+
* called on the transient device-error path.
|
|
246
|
+
*/
|
|
247
|
+
export function stopSpool() {
|
|
248
|
+
const fd = state.fd;
|
|
249
|
+
state.fd = null;
|
|
250
|
+
state.filePath = null;
|
|
251
|
+
state.startedAt = null;
|
|
252
|
+
state.writeCount = 0;
|
|
253
|
+
if (fd !== null) {
|
|
254
|
+
try {
|
|
255
|
+
fs.closeSync(fd);
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
// already closed
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* If the open file exceeds ROTATION_BYTES, rename it `.rotated-<ts>` (readable
|
|
264
|
+
* until the GC reclaims it) and open a fresh file. Rotation is the one place
|
|
265
|
+
* capture-everything yields to bounded disk: a normal session never rotates,
|
|
266
|
+
* this is only the disk-safety backstop.
|
|
267
|
+
*
|
|
268
|
+
* Safe rename-not-truncate, mirroring rotateCaptureIfNeeded: truncating in
|
|
269
|
+
* place would leave the next append past EOF. No-op if no spool is open or the
|
|
270
|
+
* file is under cap. Returns the new path, or null when nothing rotated.
|
|
271
|
+
*/
|
|
272
|
+
export function rotateSpoolIfNeeded(now = Date.now(), capBytes = ROTATION_BYTES) {
|
|
273
|
+
const fd = state.fd;
|
|
274
|
+
const current = state.filePath;
|
|
275
|
+
if (fd === null || !current)
|
|
276
|
+
return null;
|
|
277
|
+
let size;
|
|
278
|
+
try {
|
|
279
|
+
size = fs.fstatSync(fd).size;
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
if (size <= capBytes)
|
|
285
|
+
return null;
|
|
286
|
+
try {
|
|
287
|
+
fs.closeSync(fd);
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
// already closed
|
|
291
|
+
}
|
|
292
|
+
state.fd = null;
|
|
293
|
+
try {
|
|
294
|
+
fs.renameSync(current, `${current}.rotated-${now}`);
|
|
295
|
+
}
|
|
296
|
+
catch {
|
|
297
|
+
// Rename failure is non-fatal: the file may have vanished. Fall through
|
|
298
|
+
// and open a fresh spool.
|
|
299
|
+
}
|
|
300
|
+
// Reopen a fresh file. startSpool is a no-op while a spool is open, so we
|
|
301
|
+
// cleared state.fd above first.
|
|
302
|
+
const reopened = startSpool(now);
|
|
303
|
+
if (reopened === null) {
|
|
304
|
+
// Reopen failed (disk full is exactly when rotation fires). state.fd stays
|
|
305
|
+
// null, so writeFrame no-ops for the rest of the session. Leave one
|
|
306
|
+
// breadcrumb so a dead-mid-session spool is diagnosable. A reopen-retry is
|
|
307
|
+
// out of scope here.
|
|
308
|
+
console.error("[radar] event spool rotation reopen failed; spool stopped");
|
|
309
|
+
}
|
|
310
|
+
return reopened;
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Install once at process start so the spool file is deleted when the MCP
|
|
314
|
+
* server exits. Copies logcat-capture.ts registerCleanup, including the
|
|
315
|
+
* SIGINT/SIGTERM exit codes 130/143.
|
|
316
|
+
*/
|
|
317
|
+
let cleanupRegistered = false;
|
|
318
|
+
export function registerCleanup() {
|
|
319
|
+
if (cleanupRegistered)
|
|
320
|
+
return;
|
|
321
|
+
cleanupRegistered = true;
|
|
322
|
+
const cleanup = () => stopSpool();
|
|
323
|
+
process.on("exit", cleanup);
|
|
324
|
+
process.on("SIGINT", () => {
|
|
325
|
+
cleanup();
|
|
326
|
+
process.exit(130);
|
|
327
|
+
});
|
|
328
|
+
process.on("SIGTERM", () => {
|
|
329
|
+
cleanup();
|
|
330
|
+
process.exit(143);
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
// Test-only: point the spool at an isolated dir so a test never writes to the
|
|
334
|
+
// real one. Production uses initializeSpoolDir(pid) at startup instead.
|
|
335
|
+
export function _setSpoolDirForTest(dir) {
|
|
336
|
+
SPOOL_DIR = dir;
|
|
337
|
+
}
|
|
338
|
+
// Test-only: lower the rotation cap so the periodic writeFrame check can drive a
|
|
339
|
+
// real rename without writing 100MB. The internal rotate call reads this default.
|
|
340
|
+
export function _setRotationBytesForTest(n) {
|
|
341
|
+
ROTATION_BYTES = n;
|
|
342
|
+
}
|
|
343
|
+
// Test-only: lower the write count so a test can trigger the periodic rotation
|
|
344
|
+
// check without writing ROTATION_CHECK_EVERY frames. Not for production use.
|
|
345
|
+
export function _setWriteCountForTest(n) {
|
|
346
|
+
state.writeCount = n;
|
|
347
|
+
}
|
|
348
|
+
// Test-only: the periodic rotation cadence, so a test can drive writeFrame to
|
|
349
|
+
// the rotation branch without hard-coding the constant.
|
|
350
|
+
export function _getRotationCheckEveryForTest() {
|
|
351
|
+
return ROTATION_CHECK_EVERY;
|
|
352
|
+
}
|
|
353
|
+
// Test-only: close the underlying fd WITHOUT clearing state, so a test can
|
|
354
|
+
// prove writeFrame swallows a write-to-closed-fd error. Production code must
|
|
355
|
+
// never leave a closed fd in state; only stopSpool/rotate close it, and both
|
|
356
|
+
// null the fd.
|
|
357
|
+
export function _closeFdLeavingStateForTest() {
|
|
358
|
+
if (state.fd !== null) {
|
|
359
|
+
try {
|
|
360
|
+
fs.closeSync(state.fd);
|
|
361
|
+
}
|
|
362
|
+
catch {
|
|
363
|
+
// already closed
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
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
|