Skip to content

Commit 06463c9

Browse files
committed
chore: implement daemon client
1 parent ff7ac7c commit 06463c9

6 files changed

Lines changed: 258 additions & 100 deletions

File tree

src/daemon/client.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import {spawn} from 'node:child_process';
8+
import fs from 'node:fs';
9+
import net from 'node:net';
10+
11+
import {logger} from '../logger.js';
12+
import {PipeTransport} from '../third_party/index.js';
13+
14+
import type {DaemonMessage} from './types.js';
15+
import {
16+
DAEMON_SCRIPT_PATH,
17+
getSocketPath,
18+
getDaemonPid,
19+
getPidFilePath,
20+
} from './utils.js';
21+
22+
export function isDaemonRunning() {
23+
return getDaemonPid();
24+
}
25+
26+
/**
27+
* Waits for a file to be created and populated.
28+
*/
29+
function waitForFile(filePath: string, timeout = 5000) {
30+
return new Promise<void>((resolve, reject) => {
31+
if (fs.existsSync(filePath) && fs.statSync(filePath).size > 0) {
32+
resolve();
33+
return;
34+
}
35+
36+
const timer = setTimeout(() => {
37+
fs.unwatchFile(filePath);
38+
reject(
39+
new Error(`Timeout: file ${filePath} not found within ${timeout}ms`),
40+
);
41+
}, timeout);
42+
43+
fs.watchFile(filePath, {interval: 500}, curr => {
44+
if (curr.size > 0) {
45+
clearTimeout(timer);
46+
fs.unwatchFile(filePath); // Always clean up your listeners!
47+
resolve();
48+
}
49+
});
50+
});
51+
}
52+
53+
export async function startDaemon(mcpArgs: string[] = []) {
54+
if (isDaemonRunning()) {
55+
console.log('Daemon is already running');
56+
return;
57+
}
58+
59+
logger('Starting daemon...');
60+
const child = spawn(process.execPath, [DAEMON_SCRIPT_PATH, ...mcpArgs], {
61+
detached: true,
62+
stdio: 'ignore',
63+
cwd: process.cwd(),
64+
});
65+
66+
await new Promise<void>((resolve, reject) => {
67+
child.on('error', err => {
68+
reject(err);
69+
});
70+
child.on('exit', code => {
71+
logger(`Child exited with code ${code}`);
72+
reject(new Error(`Daemon process exited prematurely with code ${code}`));
73+
});
74+
75+
waitForFile(getPidFilePath())
76+
.then(() => resolve())
77+
.catch(reject);
78+
});
79+
80+
child.unref();
81+
logger(`Pid file found ${getPidFilePath()}`);
82+
}
83+
84+
const SEND_COMMAND_TIMEOUT = 60_000; // ms
85+
86+
/**
87+
* `sendCommand` opens a socket connection sends a single command and disconnects.
88+
*/
89+
async function sendCommand(command: DaemonMessage) {
90+
const socketPath = getSocketPath();
91+
92+
const socket = net.createConnection({
93+
path: socketPath,
94+
});
95+
96+
return new Promise((resolve, reject) => {
97+
const timer = setTimeout(() => {
98+
socket.destroy();
99+
reject(new Error('Timeout waiting for daemon response'));
100+
}, SEND_COMMAND_TIMEOUT);
101+
102+
const transport = new PipeTransport(socket, socket);
103+
transport.onmessage = async (message: string) => {
104+
clearTimeout(timer);
105+
logger('onmessage', message);
106+
resolve(JSON.parse(message));
107+
};
108+
socket.on('error', error => {
109+
clearTimeout(timer);
110+
logger('Socket error:', error);
111+
reject(error);
112+
});
113+
logger('Sending message', command);
114+
transport.send(JSON.stringify(command));
115+
});
116+
}
117+
118+
export async function stopDaemon() {
119+
if (!isDaemonRunning()) {
120+
logger('Daemon is not running');
121+
return;
122+
}
123+
124+
await sendCommand({method: 'stop'});
125+
}

src/daemon/daemon.ts

Lines changed: 42 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@
66
* SPDX-License-Identifier: Apache-2.0
77
*/
88

9-
import fs from 'node:fs/promises';
9+
import fs from 'node:fs';
1010
import {createServer, type Server} from 'node:net';
11+
import path from 'node:path';
1112
import process from 'node:process';
1213

1314
import {Client} from '@modelcontextprotocol/sdk/client/index.js';
@@ -17,14 +18,32 @@ import {logger} from '../logger.js';
1718
import {PipeTransport} from '../third_party/index.js';
1819
import {VERSION} from '../version.js';
1920

21+
import type {DaemonMessage} from './types.js';
2022
import {
23+
getDaemonPid,
24+
getPidFilePath,
2125
getSocketPath,
22-
handlePidFile,
2326
INDEX_SCRIPT_PATH,
2427
IS_WINDOWS,
2528
} from './utils.js';
2629

27-
const pidFile = handlePidFile();
30+
const pid = getDaemonPid();
31+
if (pid) {
32+
try {
33+
process.kill(pid, 0); // Throws if process doesn't exist
34+
logger('Another daemon process is running');
35+
process.exit(1);
36+
} catch {
37+
// Process is dead, stale PID file. Proceed with startup.
38+
}
39+
}
40+
const pidFilePath = getPidFilePath();
41+
fs.mkdirSync(path.dirname(pidFilePath), {
42+
recursive: true,
43+
});
44+
fs.writeFileSync(pidFilePath, process.pid.toString());
45+
logger(`Writing ${process.pid.toString()} to ${pidFilePath}`);
46+
2847
const socketPath = getSocketPath();
2948

3049
let mcpClient: Client | null = null;
@@ -64,17 +83,6 @@ interface McpResult {
6483
content?: McpContent[] | string;
6584
text?: string;
6685
}
67-
68-
type DaemonMessage =
69-
| {
70-
method: 'stop';
71-
}
72-
| {
73-
method: 'invoke_tool';
74-
tool: string;
75-
args?: Record<string, unknown>;
76-
};
77-
7886
async function handleRequest(msg: DaemonMessage) {
7987
try {
8088
if (msg.method === 'invoke_tool') {
@@ -93,7 +101,9 @@ async function handleRequest(msg: DaemonMessage) {
93101
result: JSON.stringify(result),
94102
};
95103
} else if (msg.method === 'stop') {
96-
// Trigger cleanup asynchronously
104+
// Ensure we are not interrupting in-progress starting.
105+
await started;
106+
// Trigger cleanup asynchronously.
97107
setImmediate(() => {
98108
void cleanup();
99109
});
@@ -120,7 +130,7 @@ async function startSocketServer() {
120130
// Remove existing socket file if it exists (only on non-Windows)
121131
if (!IS_WINDOWS) {
122132
try {
123-
await fs.unlink(socketPath);
133+
fs.unlinkSync(socketPath);
124134
} catch {
125135
// ignore errors.
126136
}
@@ -179,12 +189,22 @@ async function cleanup() {
179189
} catch (error) {
180190
logger('Error closing MCP transport:', error);
181191
}
182-
server?.close(() => {
183-
if (!IS_WINDOWS) {
184-
void fs.unlink(socketPath).catch(() => undefined);
192+
if (server) {
193+
await new Promise<void>(resolve => {
194+
server!.close(() => resolve());
195+
});
196+
}
197+
if (!IS_WINDOWS) {
198+
try {
199+
fs.unlinkSync(socketPath);
200+
} catch {
201+
// ignore errors
185202
}
186-
});
187-
await fs.unlink(pidFile).catch(() => undefined);
203+
}
204+
logger(`unlinking ${pidFilePath}`);
205+
if (fs.existsSync(pidFilePath)) {
206+
fs.unlinkSync(pidFilePath);
207+
}
188208
process.exit(0);
189209
}
190210

@@ -208,7 +228,7 @@ process.on('unhandledRejection', error => {
208228
});
209229

210230
// Start the server
211-
startSocketServer().catch(error => {
231+
const started = startSocketServer().catch(error => {
212232
logger('Failed to start daemon server:', error);
213233
process.exit(1);
214234
});

src/daemon/types.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
export type DaemonMessage =
8+
| {
9+
method: 'stop';
10+
}
11+
| {
12+
method: 'invoke_tool';
13+
tool: string;
14+
args?: Record<string, unknown>;
15+
};

src/daemon/utils.ts

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import os from 'node:os';
99
import path from 'node:path';
1010
import process from 'node:process';
1111

12+
import {logger} from '../logger.js';
13+
1214
export const DAEMON_SCRIPT_PATH = path.join(import.meta.dirname, 'daemon.js');
1315
export const INDEX_SCRIPT_PATH = path.join(
1416
import.meta.dirname,
@@ -60,26 +62,32 @@ export function getRuntimeHome(): string {
6062

6163
export const IS_WINDOWS = os.platform() === 'win32';
6264

63-
export function handlePidFile() {
65+
export function getPidFilePath() {
6466
const runtimeDir = getRuntimeHome();
65-
const pidPath = path.join(runtimeDir, 'daemon.pid');
67+
return path.join(runtimeDir, 'daemon.pid');
68+
}
6669

67-
if (fs.existsSync(pidPath)) {
68-
const oldPid = parseInt(fs.readFileSync(pidPath, 'utf8'), 10);
70+
export function getDaemonPid() {
71+
try {
72+
const pidFile = getPidFilePath();
73+
logger(`Daemon pid file ${pidFile}`);
74+
if (!fs.existsSync(pidFile)) {
75+
return null;
76+
}
77+
const pidContent = fs.readFileSync(pidFile, 'utf-8');
78+
const pid = parseInt(pidContent.trim(), 10);
79+
logger(`Daemon pid: ${pid}`);
80+
if (isNaN(pid)) {
81+
return null;
82+
}
83+
// Check if process is still running (signal 0 doesn't kill, just checks)
6984
try {
70-
// Sending signal 0 checks if the process is still alive without killing it
71-
process.kill(oldPid, 0);
72-
console.error('Daemon is already running!');
73-
process.exit(1);
85+
process.kill(pid, 0);
86+
return pid;
7487
} catch {
75-
// Process is dead, we can safely overwrite the PID file
76-
fs.unlinkSync(pidPath);
88+
return null;
7789
}
90+
} catch {
91+
return null;
7892
}
79-
80-
fs.mkdirSync(path.dirname(pidPath), {
81-
recursive: true,
82-
});
83-
fs.writeFileSync(pidPath, process.pid.toString());
84-
return pidPath;
8593
}

tests/daemon/client.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import assert from 'node:assert';
8+
import {describe, it, afterEach} from 'node:test';
9+
10+
import {
11+
startDaemon,
12+
stopDaemon,
13+
isDaemonRunning,
14+
} from '../../src/daemon/client.js';
15+
16+
describe('daemon client', () => {
17+
afterEach(async () => {
18+
if (isDaemonRunning()) {
19+
await stopDaemon();
20+
// Wait a bit for the daemon to fully terminate and clean up its files.
21+
await new Promise(resolve => setTimeout(resolve, 1000));
22+
}
23+
});
24+
25+
it('should start and stop daemon', async () => {
26+
assert.ok(!isDaemonRunning(), 'Daemon should not be running initially');
27+
28+
await startDaemon();
29+
assert.ok(isDaemonRunning(), 'Daemon should be running after start');
30+
31+
await stopDaemon();
32+
await new Promise(resolve => setTimeout(resolve, 1000));
33+
assert.ok(!isDaemonRunning(), 'Daemon should not be running after stop');
34+
});
35+
36+
it('should handle starting daemon when already running', async () => {
37+
await startDaemon();
38+
assert.ok(isDaemonRunning(), 'Daemon should be running');
39+
40+
// Starting again should be a no-op
41+
await startDaemon();
42+
assert.ok(isDaemonRunning(), 'Daemon should still be running');
43+
});
44+
45+
it('should handle stopping daemon when not running', async () => {
46+
assert.ok(!isDaemonRunning(), 'Daemon should not be running initially');
47+
48+
// Stopping when not running should be a no-op
49+
await stopDaemon();
50+
assert.ok(!isDaemonRunning(), 'Daemon should still not be running');
51+
});
52+
});

0 commit comments

Comments
 (0)