@slack/radar-mcp 1.4.0 → 1.5.0

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.
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Session-long host-side logcat capture for the web dashboard.
3
+ *
4
+ * The device does not buffer logcat for us (the on-device radar server captures
5
+ * network/RTM/clog into ring buffers, but logcat is a host-side `adb logcat`
6
+ * stream with no replayable device buffer). So when a browser switches dashboard
7
+ * tabs, the SSE `/stream` connection is torn down and reopened, and anything that
8
+ * used to drive logcat off the per-connection lifecycle would lose every line
9
+ * emitted while no stream was attached — the user sees their log count shrink on
10
+ * a tab switch.
11
+ *
12
+ * This module fixes that the way Android Studio does: ONE logcat process per
13
+ * device for the whole server session, feeding a bounded session ring buffer.
14
+ * Each `/stream` connection subscribes as a listener and is first replayed the
15
+ * entire retained buffer, then receives live lines. Switching tabs only
16
+ * adds/removes a listener; the underlying capture and its history are untouched.
17
+ *
18
+ * The class takes its spawn and parse functions by injection so it is unit
19
+ * testable without spawning a real `adb logcat` (the integration path is covered
20
+ * by the smoke test on a real device).
21
+ */
22
+ import type { ChildProcess } from "child_process";
23
+ /** A parsed logcat frame, as delivered to the dashboard over SSE. */
24
+ export interface LogFrame {
25
+ id: number;
26
+ timestamp: number;
27
+ log_ts: string;
28
+ pid: number;
29
+ tid: number;
30
+ level: string;
31
+ tag: string;
32
+ message: string;
33
+ package?: string;
34
+ }
35
+ /** Spawn a process producing logcat lines on stdout. Injectable for tests. */
36
+ export type SpawnLogcat = () => ChildProcess;
37
+ /** Parse one raw logcat line into a frame, or null if it is not a log line. */
38
+ export type ParseLine = (line: string) => LogFrame | null;
39
+ /** Receives one frame. `replay` is true for buffered history, false for live. */
40
+ export type LogListener = (frame: LogFrame, replay: boolean) => void;
41
+ /**
42
+ * A single session-long logcat capture with a bounded ring buffer and listener
43
+ * fan-out. One instance is shared across every dashboard stream connection.
44
+ */
45
+ export declare class LogSession {
46
+ private readonly spawnLogcat;
47
+ private readonly parseLine;
48
+ private readonly cap;
49
+ private child;
50
+ private lineBuf;
51
+ private readonly ring;
52
+ private readonly listeners;
53
+ private cleanupRegistered;
54
+ constructor(spawnLogcat: SpawnLogcat, parseLine: ParseLine, cap?: number);
55
+ /** True while a logcat child is running. */
56
+ isRunning(): boolean;
57
+ /** Number of frames currently retained (for tests / diagnostics). */
58
+ bufferSize(): number;
59
+ /** Number of attached stream listeners (for tests / diagnostics). */
60
+ listenerCount(): number;
61
+ /**
62
+ * Start the capture if it is not already running. Idempotent: a second call
63
+ * while a child is alive is a no-op, so concurrent tab opens cannot fan out
64
+ * duplicate logcat processes. Returns true if a capture is running after the
65
+ * call (already-running counts as success), false if the spawn failed.
66
+ */
67
+ start(): boolean;
68
+ /** Stop the capture and drop the child. The ring buffer is preserved. */
69
+ stop(): void;
70
+ /**
71
+ * Install process-exit handlers (once) so the persistent `adb logcat` child is
72
+ * killed when the server process exits. Without this, the capture orphans when
73
+ * the server is terminated by signal — notably when `open_radar_dashboard`
74
+ * spawns the dashboard as a child and later kills it by PID — and orphaned adb
75
+ * logcat readers accumulate across dashboard open/close cycles, holding the
76
+ * device's logcat stream. Mirrors logcat-capture.ts's registerCleanup().
77
+ */
78
+ registerCleanup(): void;
79
+ /**
80
+ * Subscribe a stream to the session. The listener is FIRST replayed the entire
81
+ * retained ring (replay=true) so a freshly opened tab shows full history, then
82
+ * receives every subsequent live frame (replay=false). Returns an unsubscribe
83
+ * function; call it when the stream closes (this does NOT stop the capture).
84
+ */
85
+ subscribe(listener: LogListener): () => void;
86
+ private onChildGone;
87
+ private onData;
88
+ private push;
89
+ }
@@ -0,0 +1,144 @@
1
+ import { MAX_LOG_SESSION_BUFFER } from "../shared/constants.js";
2
+ /**
3
+ * A single session-long logcat capture with a bounded ring buffer and listener
4
+ * fan-out. One instance is shared across every dashboard stream connection.
5
+ */
6
+ export class LogSession {
7
+ spawnLogcat;
8
+ parseLine;
9
+ cap;
10
+ child = null;
11
+ lineBuf = "";
12
+ ring = [];
13
+ listeners = new Set();
14
+ cleanupRegistered = false;
15
+ constructor(spawnLogcat, parseLine, cap = MAX_LOG_SESSION_BUFFER) {
16
+ this.spawnLogcat = spawnLogcat;
17
+ this.parseLine = parseLine;
18
+ this.cap = cap > 0 ? cap : MAX_LOG_SESSION_BUFFER;
19
+ }
20
+ /** True while a logcat child is running. */
21
+ isRunning() {
22
+ return this.child !== null;
23
+ }
24
+ /** Number of frames currently retained (for tests / diagnostics). */
25
+ bufferSize() {
26
+ return this.ring.length;
27
+ }
28
+ /** Number of attached stream listeners (for tests / diagnostics). */
29
+ listenerCount() {
30
+ return this.listeners.size;
31
+ }
32
+ /**
33
+ * Start the capture if it is not already running. Idempotent: a second call
34
+ * while a child is alive is a no-op, so concurrent tab opens cannot fan out
35
+ * duplicate logcat processes. Returns true if a capture is running after the
36
+ * call (already-running counts as success), false if the spawn failed.
37
+ */
38
+ start() {
39
+ if (this.child)
40
+ return true;
41
+ let child;
42
+ try {
43
+ child = this.spawnLogcat();
44
+ }
45
+ catch {
46
+ return false;
47
+ }
48
+ this.child = child;
49
+ this.lineBuf = "";
50
+ child.stdout?.on("data", (c) => this.onData(c));
51
+ // A crashed/killed logcat (device unplugged, adb server restart) clears the
52
+ // child so the next connect() can restart it. The retained ring survives so
53
+ // history is not lost across a transient device drop.
54
+ child.on("error", () => this.onChildGone(child));
55
+ child.on("exit", () => this.onChildGone(child));
56
+ return true;
57
+ }
58
+ /** Stop the capture and drop the child. The ring buffer is preserved. */
59
+ stop() {
60
+ const child = this.child;
61
+ this.child = null;
62
+ this.lineBuf = "";
63
+ if (child) {
64
+ try {
65
+ child.kill();
66
+ }
67
+ catch {
68
+ // already dead
69
+ }
70
+ }
71
+ }
72
+ /**
73
+ * Install process-exit handlers (once) so the persistent `adb logcat` child is
74
+ * killed when the server process exits. Without this, the capture orphans when
75
+ * the server is terminated by signal — notably when `open_radar_dashboard`
76
+ * spawns the dashboard as a child and later kills it by PID — and orphaned adb
77
+ * logcat readers accumulate across dashboard open/close cycles, holding the
78
+ * device's logcat stream. Mirrors logcat-capture.ts's registerCleanup().
79
+ */
80
+ registerCleanup() {
81
+ if (this.cleanupRegistered)
82
+ return;
83
+ this.cleanupRegistered = true;
84
+ process.on("exit", () => this.stop());
85
+ process.on("SIGINT", () => {
86
+ this.stop();
87
+ process.exit(130);
88
+ });
89
+ process.on("SIGTERM", () => {
90
+ this.stop();
91
+ process.exit(143);
92
+ });
93
+ }
94
+ /**
95
+ * Subscribe a stream to the session. The listener is FIRST replayed the entire
96
+ * retained ring (replay=true) so a freshly opened tab shows full history, then
97
+ * receives every subsequent live frame (replay=false). Returns an unsubscribe
98
+ * function; call it when the stream closes (this does NOT stop the capture).
99
+ */
100
+ subscribe(listener) {
101
+ // Replay a snapshot of the current ring. Snapshot first so a frame arriving
102
+ // mid-replay is not delivered twice (once via replay, once live).
103
+ const snapshot = this.ring.slice();
104
+ for (const frame of snapshot)
105
+ listener(frame, true);
106
+ this.listeners.add(listener);
107
+ return () => {
108
+ this.listeners.delete(listener);
109
+ };
110
+ }
111
+ onChildGone(child) {
112
+ // Guard against a late event from an already-replaced child.
113
+ if (this.child === child) {
114
+ this.child = null;
115
+ this.lineBuf = "";
116
+ }
117
+ }
118
+ onData(chunk) {
119
+ this.lineBuf += chunk.toString();
120
+ let nl;
121
+ while ((nl = this.lineBuf.indexOf("\n")) >= 0) {
122
+ const line = this.lineBuf.slice(0, nl);
123
+ this.lineBuf = this.lineBuf.slice(nl + 1);
124
+ const frame = this.parseLine(line);
125
+ if (frame)
126
+ this.push(frame);
127
+ }
128
+ }
129
+ push(frame) {
130
+ this.ring.push(frame);
131
+ if (this.ring.length > this.cap)
132
+ this.ring.shift();
133
+ for (const listener of this.listeners) {
134
+ // A throwing listener (e.g. a closed response) must not kill the capture
135
+ // or starve the other listeners.
136
+ try {
137
+ listener(frame, false);
138
+ }
139
+ catch {
140
+ // best-effort fan-out
141
+ }
142
+ }
143
+ }
144
+ }