From f6f8fc1d459c752e40a37a76d096f8630b30cd23 Mon Sep 17 00:00:00 2001 From: Alex Rudenko Date: Thu, 26 Feb 2026 15:51:37 +0100 Subject: [PATCH] chore: implement daemon client --- src/daemon/client.ts | 124 ++++++++++++++++++++++++++++++++++++ src/daemon/daemon.ts | 60 ++++++++++------- src/daemon/types.ts | 15 +++++ src/daemon/utils.ts | 46 ++++++++----- tests/daemon/client.test.ts | 49 ++++++++++++++ tests/daemon/daemon.test.ts | 62 ------------------ 6 files changed, 256 insertions(+), 100 deletions(-) create mode 100644 src/daemon/client.ts create mode 100644 src/daemon/types.ts create mode 100644 tests/daemon/client.test.ts delete mode 100644 tests/daemon/daemon.test.ts diff --git a/src/daemon/client.ts b/src/daemon/client.ts new file mode 100644 index 000000000..5b046d369 --- /dev/null +++ b/src/daemon/client.ts @@ -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((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 => { + 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], { + detached: true, + stdio: 'ignore', + cwd: process.cwd(), + }); + + await new Promise((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 => { + 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'}); +} diff --git a/src/daemon/daemon.ts b/src/daemon/daemon.ts index f2ed38e17..b0cd3aea4 100644 --- a/src/daemon/daemon.ts +++ b/src/daemon/daemon.ts @@ -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'; @@ -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; @@ -64,17 +79,6 @@ interface McpResult { content?: McpContent[] | string; text?: string; } - -type DaemonMessage = - | { - method: 'stop'; - } - | { - method: 'invoke_tool'; - tool: string; - args?: Record; - }; - async function handleRequest(msg: DaemonMessage) { try { if (msg.method === 'invoke_tool') { @@ -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(); }); @@ -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. } @@ -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(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); } @@ -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); }); diff --git a/src/daemon/types.ts b/src/daemon/types.ts new file mode 100644 index 000000000..1fc3581a4 --- /dev/null +++ b/src/daemon/types.ts @@ -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; + }; diff --git a/src/daemon/utils.ts b/src/daemon/utils.ts index 3ff2472e8..47603e8d5 100644 --- a/src/daemon/utils.ts +++ b/src/daemon/utils.ts @@ -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, @@ -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; } diff --git a/tests/daemon/client.test.ts b/tests/daemon/client.test.ts new file mode 100644 index 000000000..626c89933 --- /dev/null +++ b/tests/daemon/client.test.ts @@ -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'); + }); +}); diff --git a/tests/daemon/daemon.test.ts b/tests/daemon/daemon.test.ts deleted file mode 100644 index 521562139..000000000 --- a/tests/daemon/daemon.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** - * @license - * Copyright 2026 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import assert from 'node:assert'; -import {spawn} from 'node:child_process'; -import net from 'node:net'; -import path from 'node:path'; -import {describe, it} from 'node:test'; - -import {getSocketPath} from '../../src/daemon/utils.js'; - -const DAEMON_SCRIPT = path.join( - import.meta.dirname, - '..', - '..', - 'src', - 'daemon', - 'daemon.js', -); - -describe('Daemon', () => { - it('should terminate chrome instance when transport is closed', async () => { - const daemonProcess = spawn(process.execPath, [DAEMON_SCRIPT], { - env: { - ...process.env, - }, - stdio: ['ignore', 'pipe', 'pipe'], - }); - - const socketPath = getSocketPath(); - // Wait for daemon to be ready - await new Promise((resolve, reject) => { - const onData = (data: Buffer) => { - const output = data.toString(); - // Wait for MCP client to connect - if (output.includes('MCP client connected')) { - daemonProcess.stdout.off('data', onData); - resolve(); - } - }; - daemonProcess.stdout.on('data', onData); - daemonProcess.stderr.on('data', data => { - console.log('err', data.toString('utf8')); - }); - daemonProcess.on('error', reject); - daemonProcess.on('exit', (code: number) => { - if (code !== 0 && code !== null) { - reject(new Error(`Daemon exited with code ${code}`)); - } - }); - }); - - const socket = net.createConnection(socketPath); - await new Promise(resolve => socket.on('connect', resolve)); - - daemonProcess.kill(); - assert.ok(daemonProcess.killed); - }); -});