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

Commit 8878ce4

Browse files
committed
chore: implement daemon client
1 parent e4db250 commit 8878ce4

File tree

6 files changed

+261
-100
lines changed

6 files changed

+261
-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: 43 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,33 @@ 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+
try {
34+
process.kill(pid, 0); // Throws if process doesn't exist
35+
logger('Another daemon process is running');
36+
process.exit(1);
37+
} catch {
38+
// Process is dead, stale PID file. Proceed with startup.
39+
}
40+
}
41+
const pidFilePath = getPidFilePath();
42+
fs.mkdirSync(path.dirname(pidFilePath), {
43+
recursive: true,
44+
});
45+
fs.writeFileSync(pidFilePath, process.pid.toString());
46+
logger(`Writing ${process.pid.toString()} to ${pidFilePath}`);
47+
2848
const socketPath = getSocketPath();
2949

3050
let mcpClient: Client | null = null;
@@ -64,17 +84,6 @@ interface McpResult {
6484
content?: McpContent[] | string;
6585
text?: string;
6686
}
67-
68-
type DaemonMessage =
69-
| {
70-
method: 'stop';
71-
}
72-
| {
73-
method: 'invoke_tool';
74-
tool: string;
75-
args?: Record<string, unknown>;
76-
};
77-
7887
async function handleRequest(msg: DaemonMessage) {
7988
try {
8089
if (msg.method === 'invoke_tool') {
@@ -93,7 +102,9 @@ async function handleRequest(msg: DaemonMessage) {
93102
result: JSON.stringify(result),
94103
};
95104
} else if (msg.method === 'stop') {
96-
// Trigger cleanup asynchronously
105+
// Ensure we are not interrupting in-progress starting.
106+
await started;
107+
// Trigger cleanup asynchronously.
97108
setImmediate(() => {
98109
void cleanup();
99110
});
@@ -120,7 +131,7 @@ async function startSocketServer() {
120131
// Remove existing socket file if it exists (only on non-Windows)
121132
if (!IS_WINDOWS) {
122133
try {
123-
await fs.unlink(socketPath);
134+
fs.unlinkSync(socketPath);
124135
} catch {
125136
// ignore errors.
126137
}
@@ -179,12 +190,22 @@ async function cleanup() {
179190
} catch (error) {
180191
logger('Error closing MCP transport:', error);
181192
}
182-
server?.close(() => {
183-
if (!IS_WINDOWS) {
184-
void fs.unlink(socketPath).catch(() => undefined);
193+
if (server) {
194+
await new Promise<void>(resolve => {
195+
server!.close(() => resolve());
196+
});
197+
}
198+
if (!IS_WINDOWS) {
199+
try {
200+
fs.unlinkSync(socketPath);
201+
} catch {
202+
// ignore errors
185203
}
186-
});
187-
await fs.unlink(pidFile).catch(() => undefined);
204+
}
205+
logger(`unlinking ${pidFilePath}`);
206+
if (fs.existsSync(pidFilePath)) {
207+
fs.unlinkSync(pidFilePath);
208+
}
188209
process.exit(0);
189210
}
190211

@@ -208,7 +229,7 @@ process.on('unhandledRejection', error => {
208229
});
209230

210231
// Start the server
211-
startSocketServer().catch(error => {
232+
const started = startSocketServer().catch(error => {
212233
logger('Failed to start daemon server:', error);
213234
process.exit(1);
214235
});

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)