豆豆友情提示:这是一个非官方 GitHub 代理镜像,主要用于网络测试或访问加速。请勿在此进行登录、注册或处理任何敏感信息。进行这些操作请务必访问官方网站 github.com。 Raw 内容也通过此代理提供。
Skip to content

Commit 13a18e2

Browse files
authored
chore: implement daemon client (#1037)
The client is meant to start the daemon if it is not running or connect to the existing one. Long-running client connections are not needed yet. Removed previous direct daemon test as we better test it via the client, the intended way to manage the daemon.
1 parent 5f694c6 commit 13a18e2

File tree

6 files changed

+256
-100
lines changed

6 files changed

+256
-100
lines changed

src/daemon/client.ts

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

src/daemon/daemon.ts

Lines changed: 38 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,28 @@ 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,
28+
isDaemonRunning,
2529
} from './utils.js';
2630

27-
const pidFile = handlePidFile();
31+
const pid = getDaemonPid();
32+
if (isDaemonRunning(pid)) {
33+
logger('Another daemon process is running.');
34+
process.exit(1);
35+
}
36+
const pidFilePath = getPidFilePath();
37+
fs.mkdirSync(path.dirname(pidFilePath), {
38+
recursive: true,
39+
});
40+
fs.writeFileSync(pidFilePath, process.pid.toString());
41+
logger(`Writing ${process.pid.toString()} to ${pidFilePath}`);
42+
2843
const socketPath = getSocketPath();
2944

3045
let mcpClient: Client | null = null;
@@ -64,17 +79,6 @@ interface McpResult {
6479
content?: McpContent[] | string;
6580
text?: string;
6681
}
67-
68-
type DaemonMessage =
69-
| {
70-
method: 'stop';
71-
}
72-
| {
73-
method: 'invoke_tool';
74-
tool: string;
75-
args?: Record<string, unknown>;
76-
};
77-
7882
async function handleRequest(msg: DaemonMessage) {
7983
try {
8084
if (msg.method === 'invoke_tool') {
@@ -93,7 +97,9 @@ async function handleRequest(msg: DaemonMessage) {
9397
result: JSON.stringify(result),
9498
};
9599
} else if (msg.method === 'stop') {
96-
// Trigger cleanup asynchronously
100+
// Ensure we are not interrupting in-progress starting.
101+
await started;
102+
// Trigger cleanup asynchronously.
97103
setImmediate(() => {
98104
void cleanup();
99105
});
@@ -120,7 +126,7 @@ async function startSocketServer() {
120126
// Remove existing socket file if it exists (only on non-Windows)
121127
if (!IS_WINDOWS) {
122128
try {
123-
await fs.unlink(socketPath);
129+
fs.unlinkSync(socketPath);
124130
} catch {
125131
// ignore errors.
126132
}
@@ -179,12 +185,22 @@ async function cleanup() {
179185
} catch (error) {
180186
logger('Error closing MCP transport:', error);
181187
}
182-
server?.close(() => {
183-
if (!IS_WINDOWS) {
184-
void fs.unlink(socketPath).catch(() => undefined);
188+
if (server) {
189+
await new Promise<void>(resolve => {
190+
server!.close(() => resolve());
191+
});
192+
}
193+
if (!IS_WINDOWS) {
194+
try {
195+
fs.unlinkSync(socketPath);
196+
} catch {
197+
// ignore errors
185198
}
186-
});
187-
await fs.unlink(pidFile).catch(() => undefined);
199+
}
200+
logger(`unlinking ${pidFilePath}`);
201+
if (fs.existsSync(pidFilePath)) {
202+
fs.unlinkSync(pidFilePath);
203+
}
188204
process.exit(0);
189205
}
190206

@@ -208,7 +224,7 @@ process.on('unhandledRejection', error => {
208224
});
209225

210226
// Start the server
211-
startSocketServer().catch(error => {
227+
const started = startSocketServer().catch(error => {
212228
logger('Failed to start daemon server:', error);
213229
process.exit(1);
214230
});

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: 30 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,38 @@ 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+
}
69+
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+
return pid;
84+
} catch {
85+
return null;
86+
}
87+
}
6688

67-
if (fs.existsSync(pidPath)) {
68-
const oldPid = parseInt(fs.readFileSync(pidPath, 'utf8'), 10);
89+
export function isDaemonRunning(pid = getDaemonPid()): pid is number {
90+
if (pid) {
6991
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);
92+
process.kill(pid, 0); // Throws if process doesn't exist
93+
return true;
7494
} catch {
75-
// Process is dead, we can safely overwrite the PID file
76-
fs.unlinkSync(pidPath);
95+
// Process is dead, stale PID file. Proceed with startup.
7796
}
7897
}
79-
80-
fs.mkdirSync(path.dirname(pidPath), {
81-
recursive: true,
82-
});
83-
fs.writeFileSync(pidPath, process.pid.toString());
84-
return pidPath;
98+
return false;
8599
}

tests/daemon/client.test.ts

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

0 commit comments

Comments
 (0)