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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions src/daemon/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import {spawn} from 'node:child_process';
import fs from 'node:fs';
import net from 'node:net';

import {logger} from '../logger.js';
import {PipeTransport} from '../third_party/index.js';

import type {DaemonMessage} from './types.js';
import {
DAEMON_SCRIPT_PATH,
getSocketPath,
getPidFilePath,
isDaemonRunning,
} from './utils.js';

/**
* Waits for a file to be created and populated.
*/
function waitForFile(filePath: string, timeout = 5000) {
return new Promise<void>((resolve, reject) => {
if (fs.existsSync(filePath) && fs.statSync(filePath).size > 0) {
resolve();
return;
}

const timer = setTimeout(() => {
fs.unwatchFile(filePath);
reject(
new Error(`Timeout: file ${filePath} not found within ${timeout}ms`),
);
}, timeout);

fs.watchFile(filePath, {interval: 500}, curr => {
Comment thread
OrKoN marked this conversation as resolved.
if (curr.size > 0) {
clearTimeout(timer);
fs.unwatchFile(filePath); // Always clean up your listeners!
resolve();
}
});
});
}

export async function startDaemon(mcpArgs: string[] = []) {
if (isDaemonRunning()) {
logger('Daemon is already running');
return;
}

logger('Starting daemon...');
const child = spawn(process.execPath, [DAEMON_SCRIPT_PATH, ...mcpArgs], {
Comment thread
OrKoN marked this conversation as resolved.
detached: true,
stdio: 'ignore',
cwd: process.cwd(),
});

await new Promise<void>((resolve, reject) => {
child.on('error', err => {
reject(err);
});
child.on('exit', code => {
logger(`Child exited with code ${code}`);
reject(new Error(`Daemon process exited prematurely with code ${code}`));
});

waitForFile(getPidFilePath()).then(resolve).catch(reject);
});

child.unref();
logger(`Pid file found ${getPidFilePath()}`);
}

const SEND_COMMAND_TIMEOUT = 60_000; // ms

/**
* `sendCommand` opens a socket connection sends a single command and disconnects.
*/
async function sendCommand(command: DaemonMessage) {
const socketPath = getSocketPath();

const socket = net.createConnection({
path: socketPath,
});

return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
socket.destroy();
reject(new Error('Timeout waiting for daemon response'));
}, SEND_COMMAND_TIMEOUT);

const transport = new PipeTransport(socket, socket);
transport.onmessage = async (message: string) => {
clearTimeout(timer);
logger('onmessage', message);
resolve(JSON.parse(message));
};
socket.on('error', error => {
Comment thread
OrKoN marked this conversation as resolved.
clearTimeout(timer);
logger('Socket error:', error);
reject(error);
});
socket.on('close', () => {
clearTimeout(timer);
logger('Socket closed:');
reject(new Error('Socket closed'));
});
logger('Sending message', command);
transport.send(JSON.stringify(command));
});
}

export async function stopDaemon() {
if (!isDaemonRunning()) {
logger('Daemon is not running');
return;
}

await sendCommand({method: 'stop'});
}
60 changes: 38 additions & 22 deletions src/daemon/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@
* SPDX-License-Identifier: Apache-2.0
*/

import fs from 'node:fs/promises';
import fs from 'node:fs';
import {createServer, type Server} from 'node:net';
import path from 'node:path';
import process from 'node:process';

import {Client} from '@modelcontextprotocol/sdk/client/index.js';
Expand All @@ -17,14 +18,28 @@ import {logger} from '../logger.js';
import {PipeTransport} from '../third_party/index.js';
import {VERSION} from '../version.js';

import type {DaemonMessage} from './types.js';
import {
getDaemonPid,
getPidFilePath,
getSocketPath,
handlePidFile,
INDEX_SCRIPT_PATH,
IS_WINDOWS,
isDaemonRunning,
} from './utils.js';

const pidFile = handlePidFile();
const pid = getDaemonPid();
if (isDaemonRunning(pid)) {
logger('Another daemon process is running.');
process.exit(1);
}
const pidFilePath = getPidFilePath();
fs.mkdirSync(path.dirname(pidFilePath), {
recursive: true,
});
fs.writeFileSync(pidFilePath, process.pid.toString());
logger(`Writing ${process.pid.toString()} to ${pidFilePath}`);

const socketPath = getSocketPath();

let mcpClient: Client | null = null;
Expand Down Expand Up @@ -64,17 +79,6 @@ interface McpResult {
content?: McpContent[] | string;
text?: string;
}

type DaemonMessage =
| {
method: 'stop';
}
| {
method: 'invoke_tool';
tool: string;
args?: Record<string, unknown>;
};

async function handleRequest(msg: DaemonMessage) {
try {
if (msg.method === 'invoke_tool') {
Expand All @@ -93,7 +97,9 @@ async function handleRequest(msg: DaemonMessage) {
result: JSON.stringify(result),
};
} else if (msg.method === 'stop') {
// Trigger cleanup asynchronously
// Ensure we are not interrupting in-progress starting.
await started;
// Trigger cleanup asynchronously.
setImmediate(() => {
void cleanup();
});
Expand All @@ -120,7 +126,7 @@ async function startSocketServer() {
// Remove existing socket file if it exists (only on non-Windows)
if (!IS_WINDOWS) {
try {
await fs.unlink(socketPath);
fs.unlinkSync(socketPath);
} catch {
// ignore errors.
}
Expand Down Expand Up @@ -179,12 +185,22 @@ async function cleanup() {
} catch (error) {
logger('Error closing MCP transport:', error);
}
server?.close(() => {
if (!IS_WINDOWS) {
void fs.unlink(socketPath).catch(() => undefined);
if (server) {
await new Promise<void>(resolve => {
server!.close(() => resolve());
});
}
if (!IS_WINDOWS) {
try {
fs.unlinkSync(socketPath);
} catch {
// ignore errors
}
});
await fs.unlink(pidFile).catch(() => undefined);
}
logger(`unlinking ${pidFilePath}`);
if (fs.existsSync(pidFilePath)) {
fs.unlinkSync(pidFilePath);
}
process.exit(0);
}

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

// Start the server
startSocketServer().catch(error => {
const started = startSocketServer().catch(error => {
logger('Failed to start daemon server:', error);
process.exit(1);
});
15 changes: 15 additions & 0 deletions src/daemon/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

export type DaemonMessage =
| {
method: 'stop';
}
| {
method: 'invoke_tool';
tool: string;
args?: Record<string, unknown>;
};
46 changes: 30 additions & 16 deletions src/daemon/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import os from 'node:os';
import path from 'node:path';
import process from 'node:process';

import {logger} from '../logger.js';

export const DAEMON_SCRIPT_PATH = path.join(import.meta.dirname, 'daemon.js');
export const INDEX_SCRIPT_PATH = path.join(
import.meta.dirname,
Expand Down Expand Up @@ -60,26 +62,38 @@ export function getRuntimeHome(): string {

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

export function handlePidFile() {
export function getPidFilePath() {
const runtimeDir = getRuntimeHome();
const pidPath = path.join(runtimeDir, 'daemon.pid');
return path.join(runtimeDir, 'daemon.pid');
}

export function getDaemonPid() {
try {
const pidFile = getPidFilePath();
logger(`Daemon pid file ${pidFile}`);
if (!fs.existsSync(pidFile)) {
return null;
}
const pidContent = fs.readFileSync(pidFile, 'utf-8');
const pid = parseInt(pidContent.trim(), 10);
logger(`Daemon pid: ${pid}`);
if (isNaN(pid)) {
return null;
}
return pid;
} catch {
return null;
}
}

if (fs.existsSync(pidPath)) {
const oldPid = parseInt(fs.readFileSync(pidPath, 'utf8'), 10);
export function isDaemonRunning(pid = getDaemonPid()): pid is number {
if (pid) {
try {
// Sending signal 0 checks if the process is still alive without killing it
process.kill(oldPid, 0);
console.error('Daemon is already running!');
process.exit(1);
process.kill(pid, 0); // Throws if process doesn't exist
return true;
} catch {
// Process is dead, we can safely overwrite the PID file
fs.unlinkSync(pidPath);
// Process is dead, stale PID file. Proceed with startup.
}
}

fs.mkdirSync(path.dirname(pidPath), {
recursive: true,
});
fs.writeFileSync(pidPath, process.pid.toString());
return pidPath;
return false;
}
49 changes: 49 additions & 0 deletions tests/daemon/client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import assert from 'node:assert';
import {describe, it, afterEach} from 'node:test';

import {startDaemon, stopDaemon} from '../../src/daemon/client.js';
import {isDaemonRunning} from '../../src/daemon/utils.js';

describe('daemon client', () => {
afterEach(async () => {
if (isDaemonRunning()) {
await stopDaemon();
// Wait a bit for the daemon to fully terminate and clean up its files.
await new Promise(resolve => setTimeout(resolve, 1000));
}
});

it('should start and stop daemon', async () => {
assert.ok(!isDaemonRunning(), 'Daemon should not be running initially');

await startDaemon();
assert.ok(isDaemonRunning(), 'Daemon should be running after start');

await stopDaemon();
await new Promise(resolve => setTimeout(resolve, 1000));
assert.ok(!isDaemonRunning(), 'Daemon should not be running after stop');
});

it('should handle starting daemon when already running', async () => {
await startDaemon();
assert.ok(isDaemonRunning(), 'Daemon should be running');

// Starting again should be a no-op
await startDaemon();
assert.ok(isDaemonRunning(), 'Daemon should still be running');
});

it('should handle stopping daemon when not running', async () => {
assert.ok(!isDaemonRunning(), 'Daemon should not be running initially');

// Stopping when not running should be a no-op
await stopDaemon();
assert.ok(!isDaemonRunning(), 'Daemon should still not be running');
});
});
Loading
Loading