Skip to content

Commit 53a9c9a

Browse files
committed
tool: Add watch-for-rts-spike
1 parent dd3c323 commit 53a9c9a

File tree

1 file changed

+189
-0
lines changed

1 file changed

+189
-0
lines changed

scripts/watch-for-rts-spike.mts

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
#!/usr/bin/env -S deno run --allow-run="ps,pgrep,kill"
2+
3+
// Monitors a Node.js process for high resident memory usage and, once a threshold is exceeded, repeatedly
4+
// sends SIGUSR2 signals at a configured interval so the target process can perform diagnostic actions.
5+
// Designed to help capture data around sudden RealtimeServer memory spikes.
6+
7+
// @ts-ignore Deno provides this module resolution at runtime.
8+
import { parseArgs } from "jsr:@std/cli/parse-args";
9+
10+
// Help IDE.
11+
declare const Deno: any;
12+
13+
interface CliOptions {
14+
thresholdMib: number;
15+
intervalSeconds: number;
16+
}
17+
18+
/** Watches for the RealtimeServer process and while its RSS is above a threshold sends SIGUSR2 with exponential backoff. */
19+
class RtsMon {
20+
private currentIntervalSeconds: number;
21+
22+
constructor(private readonly options: CliOptions) {
23+
this.currentIntervalSeconds = options.intervalSeconds;
24+
}
25+
26+
async monitor(): Promise<void> {
27+
Program.log(
28+
`Monitoring RealtimeServer resource usage. Threshold: ${this.options.thresholdMib} MiB. Starting interval: ${this.options.intervalSeconds} s`
29+
);
30+
while (true) {
31+
await this.delay();
32+
const pid: number | undefined = await this.findRealtimeServerPid();
33+
if (pid == null) {
34+
Program.log(`RealtimeServer not found. Waiting for it to start.`);
35+
this.resetDelay();
36+
continue;
37+
}
38+
39+
const memoryUsageMB: number | undefined = await this.readRssMib(pid);
40+
if (memoryUsageMB == null) {
41+
this.resetDelay();
42+
continue;
43+
}
44+
45+
const aboveThreshold: boolean = memoryUsageMB >= this.options.thresholdMib;
46+
if (aboveThreshold === true) {
47+
await this.sendSignal(pid);
48+
this.currentIntervalSeconds *= 2;
49+
Program.log(
50+
`RSS ${memoryUsageMB.toFixed(1)}MB >= threshold (${this.options.thresholdMib} MiB). Increasing interval to ${
51+
this.currentIntervalSeconds
52+
} s`
53+
);
54+
} else {
55+
if (this.currentIntervalSeconds > this.options.intervalSeconds) {
56+
// Memory usage came back down below the threshold since last check. Collect one more report.
57+
await this.sendSignal(pid);
58+
}
59+
Program.log(`RSS ${memoryUsageMB.toFixed(1)} MiB (below threshold ${this.options.thresholdMib} MiB).`);
60+
this.resetDelay();
61+
}
62+
}
63+
}
64+
65+
private async delay(): Promise<void> {
66+
const ms = this.currentIntervalSeconds * 1000;
67+
await new Promise(resolve => setTimeout(resolve, ms));
68+
}
69+
70+
private resetDelay(): void {
71+
this.currentIntervalSeconds = this.options.intervalSeconds;
72+
}
73+
74+
private async sendSignal(pid: number): Promise<void> {
75+
try {
76+
await this.runCommand("kill", ["-SIGUSR2", String(pid)]);
77+
Program.log(`Sent SIGUSR2 to pid ${pid}`);
78+
} catch (e) {
79+
Program.logError(`Failed to send SIGUSR2 to pid ${pid}: ${(e as Error).message}`);
80+
}
81+
}
82+
83+
private async readRssMib(pid: number): Promise<number | undefined> {
84+
try {
85+
const { code, stdout } = await this.runCommand("ps", ["--quick-pid", String(pid), "--no-headers", "-o", "rss"]);
86+
if (code !== 0) return undefined;
87+
const text: string = new TextDecoder().decode(stdout).trim();
88+
const kib: number = Number.parseInt(text, 10);
89+
if (Number.isNaN(kib)) return undefined;
90+
return kib / 1024; // convert to MiB
91+
} catch {
92+
return undefined;
93+
}
94+
}
95+
96+
private async findRealtimeServerPid(): Promise<number | undefined> {
97+
try {
98+
const { code, stdout } = await this.runCommand("pgrep", ["--full", "--", "node .* --port 5002"]);
99+
if (code !== 0) return undefined;
100+
const text: string = new TextDecoder().decode(stdout).trim();
101+
const lines: string[] = text.split(/\n+/);
102+
if (lines.length === 0) return undefined;
103+
const pid: number = Number.parseInt(lines[0], 10);
104+
if (Number.isNaN(pid)) return undefined;
105+
if (lines.length > 1) {
106+
Program.log(`Warning: Multiple RealtimeServer processes found. Picking one of them.`);
107+
}
108+
return pid;
109+
} catch {
110+
return undefined;
111+
}
112+
}
113+
private async runCommand(
114+
cmd: string,
115+
args: string[]
116+
): Promise<{ code: number; stdout: Uint8Array; stderr: Uint8Array }> {
117+
const command = new Deno.Command(cmd, { args });
118+
return await command.output();
119+
}
120+
}
121+
122+
/** Handles running the program. */
123+
class Program {
124+
static programName: string = "rtsmon";
125+
126+
async main(): Promise<void> {
127+
try {
128+
const options: CliOptions = this.parse(Deno.args);
129+
const watcher: RtsMon = new RtsMon(options);
130+
Deno.addSignalListener("SIGINT", () => {
131+
Program.log("Received SIGINT. Exiting.");
132+
Deno.exit(0);
133+
});
134+
await watcher.monitor();
135+
} catch (e) {
136+
Program.logError((e as Error).message);
137+
Deno.exit(1);
138+
}
139+
}
140+
141+
static log(message: string): void {
142+
const timestamp: string = new Date().toISOString();
143+
console.log(`${timestamp} ${Program.programName}: ${message}`);
144+
}
145+
146+
static logError(message: string): void {
147+
const timestamp: string = new Date().toISOString();
148+
console.error(`${timestamp} ${Program.programName}: ${message}`);
149+
}
150+
151+
private parse(args: string[]): CliOptions {
152+
const parseOptions = {
153+
boolean: ["help"],
154+
default: { "threshold-mib": 1.5 * 1024, "interval-seconds": 10 }
155+
};
156+
const parsed = parseArgs(args, parseOptions);
157+
const allowed: Set<string> = new Set(["threshold-mib", "interval-seconds", "help", "_"]);
158+
for (const key of Object.keys(parsed)) {
159+
if (allowed.has(key) === false) {
160+
Program.logError(`Unexpected argument: ${key}`);
161+
Deno.exit(1);
162+
}
163+
}
164+
if (parsed._.length > 0) {
165+
Program.logError(`Unexpected arguments: ${parsed._.join(", ")}`);
166+
Deno.exit(1);
167+
}
168+
if (parsed.help === true) {
169+
Program.log(`Usage: watch-for-rts-spike.mts [--threshold-mib N] [--interval-seconds N]`);
170+
Program.log(`Defaults: ${JSON.stringify(parseOptions.default)}`);
171+
Deno.exit(0);
172+
}
173+
if (Array.isArray(parsed._) && parsed._.length > 0) {
174+
Program.logError(`Unexpected positional arguments: ${parsed._.join(", ")}`);
175+
Deno.exit(1);
176+
}
177+
178+
const thresholdMib: number = this.toNumber(parsed["threshold-mib"], "threshold-mib");
179+
const intervalSeconds: number = this.toNumber(parsed["interval-seconds"], "interval-seconds");
180+
return { thresholdMib, intervalSeconds };
181+
}
182+
183+
private toNumber(value: unknown, name: string): number {
184+
if (typeof value === "number") return value;
185+
throw new Error(`${name} must be a number`);
186+
}
187+
}
188+
189+
await new Program().main();

0 commit comments

Comments
 (0)