-
Notifications
You must be signed in to change notification settings - Fork 14
Expand file tree
/
Copy pathrelay-daemon.ts
More file actions
124 lines (110 loc) · 4.34 KB
/
Copy pathrelay-daemon.ts
File metadata and controls
124 lines (110 loc) · 4.34 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
// dotenv first: when launched by hand (npm run relay) from the repo, .env must
// yield the same OE_MCP_RELAY_PORT the MCP server and the extension use —
// otherwise the daemon binds a port nobody talks to. (When spawned by the
// server, the env is inherited and this import is a no-op.)
import "dotenv/config";
import { readFileSync, unlinkSync, writeFileSync } from "node:fs";
import { ensureConfigDirs, resolveConfig } from "./config.js";
import { evaluateReap } from "./relay-reaper.js";
import { RELAY_VERSION, startRelayServer, type RelayServer } from "./relay-server.js";
/**
* Standalone relay daemon. Owns the relay port and outlives every Claude session
* so any number of MCP servers can funnel requests through the one browser tab
* (see relay-client.ts). Idempotent: if the port is already held (another daemon
* won the race) it logs and exits 0.
*
* Self-reaping: once bound, a daemon would otherwise live forever (nothing sends
* it SIGTERM). A watchdog ticks {@link evaluateReap} so it exits when superseded
* by a newer daemon or left idle past the TTL — that is the garbage collection
* that keeps orphaned daemons from piling up. See relay-reaper.ts.
*/
const WATCHDOG_INTERVAL_MS = 30_000;
function idleTtlMs(): number {
const raw = process.env.OE_MCP_RELAY_IDLE_TTL_MS;
if (raw === undefined) return 600_000; // 10 min: long enough to survive a tab reload
const n = parseInt(raw, 10);
return Number.isFinite(n) && n >= 0 ? n : 600_000;
}
/** Pid recorded in the pidfile and whether it refers to a live *other* process. */
function pidfileState(pidPath: string): { pid: number | null; alive: boolean } {
let pid: number | null = null;
try {
const parsed = parseInt(readFileSync(pidPath, "utf8").trim(), 10);
pid = Number.isInteger(parsed) ? parsed : null;
} catch {
pid = null;
}
let alive = false;
if (pid !== null && pid !== process.pid) {
try {
process.kill(pid, 0); // signal 0 = liveness probe, kills nothing
alive = true;
} catch {
alive = false; // ESRCH (gone) or EPERM (alive but not ours) — treat as not-superseding
}
}
return { pid, alive };
}
async function main(): Promise<void> {
const config = resolveConfig();
ensureConfigDirs(config);
let server: RelayServer;
try {
server = await startRelayServer({
port: config.relayPort,
logger: (m) => console.error(`[relay-daemon] ${m}`),
});
} catch (err) {
// Lost the bind race (EADDRINUSE) — another daemon already owns the port.
console.error(`[relay-daemon] not starting: ${String(err)}`);
process.exit(0);
}
try {
writeFileSync(config.relayPidPath, String(process.pid));
} catch (err) {
console.error(`[relay-daemon] could not write pidfile: ${String(err)}`);
}
console.error(
`[relay-daemon] listening on :${config.relayPort} v${RELAY_VERSION} pid ${process.pid}`,
);
let shuttingDown = false;
let watchdog: ReturnType<typeof setInterval> | null = null;
const shutdown = (signal: string): void => {
if (shuttingDown) return;
shuttingDown = true;
if (watchdog) clearInterval(watchdog);
console.error(`[relay-daemon] ${signal} — shutting down`);
try {
// Only remove the pidfile if it is still *ours* — never clobber a newer
// daemon's registration when stepping aside after being superseded.
const onDisk = parseInt(readFileSync(config.relayPidPath, "utf8").trim(), 10);
if (onDisk === process.pid) unlinkSync(config.relayPidPath);
} catch {
/* nothing to clean up */
}
server.close();
process.exit(0);
};
process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));
// Garbage collection: self-terminate when superseded or idle past the TTL.
const ttl = idleTtlMs();
let idleSince: number | null = null;
watchdog = setInterval(() => {
const { pid, alive } = pidfileState(config.relayPidPath);
const decision = evaluateReap({
connected: server.isConnected(),
pending: server.pending(),
now: Date.now(),
idleSince,
idleTtlMs: ttl,
pidfilePid: pid,
pidfileAlive: alive,
myPid: process.pid,
});
idleSince = decision.idleSince;
if (decision.reap) shutdown(`reap:${decision.reason}`);
}, WATCHDOG_INTERVAL_MS);
watchdog.unref(); // never keep the process alive on the watchdog's account
}
void main();