UNPKG

clangd-query

Version:

Fast C++ code intelligence CLI tool for humans and AI agents. Provides semantic search, source code reading and usage lookups.

638 lines 25.5 kB
#!/usr/bin/env node /** * clangd-daemon - Background server for clangd-query * * This daemon maintains a persistent clangd instance to provide fast code intelligence * queries without repeated indexing overhead. Features: * - Single daemon per project root via lock files * - Unix domain socket communication with JSON-RPC 2.0 protocol * - Automatic idle timeout after 30 minutes of inactivity * - Graceful shutdown with proper resource cleanup * - Concurrent client handling * * The daemon is automatically started by clangd-query when needed and runs in the * background until explicitly stopped or idle timeout expires. */ import * as net from "node:net"; import * as fs from "node:fs"; import * as path from "node:path"; import { ClangdClient } from "./clangd-client.js"; import { generateSocketPath, generateLockFilePath, getLogFilePath, readLockFile, writeLockFile, cleanupStaleLockFile, calculateBuildTimestamp, } from "./socket-utils.js"; import * as commands from "./commands/index.js"; import { FileWatcher } from "./file-watcher.js"; var LogLevel; (function (LogLevel) { LogLevel[LogLevel["ERROR"] = 0] = "ERROR"; LogLevel[LogLevel["INFO"] = 1] = "INFO"; LogLevel[LogLevel["DEBUG"] = 2] = "DEBUG"; })(LogLevel || (LogLevel = {})); /** * Logger implementation that captures logs to a buffer during request processing. * This allows the daemon to capture logs from ClangdClient and include them in responses. */ class RequestLogger { buffer; constructor(buffer) { this.buffer = buffer; } error(message, ...args) { this.captureLog(LogLevel.ERROR, message, args); } info(message, ...args) { this.captureLog(LogLevel.INFO, message, args); } debug(message, ...args) { this.captureLog(LogLevel.DEBUG, message, args); } captureLog(level, message, args) { const levelName = LogLevel[level]; let logMessage = `[${levelName}] ${message}`; // Add arguments if any if (args.length > 0) { // Format each argument nicely for (const arg of args) { if (typeof arg === 'string') { // Try to parse as JSON for better formatting try { const parsed = JSON.parse(arg); logMessage += "\n" + JSON.stringify(parsed, null, 2); } catch { // Not JSON, just append as-is logMessage += "\n" + arg; } } else { logMessage += "\n" + JSON.stringify(arg, null, 2); } } } // Always capture to buffer this.buffer.push(logMessage); } } /** * Logger implementation for the daemon's internal operations. * Writes to the daemon's log file and maintains recent logs in memory. */ class DaemonLogger { logFilePath; level; logStream; recentLogs = []; maxRecentLogs = 1000; constructor(logFilePath, level) { this.logFilePath = logFilePath; this.level = level; // Create log directory if needed const logDir = path.dirname(this.logFilePath); fs.mkdirSync(logDir, { recursive: true }); // Create log stream with append mode this.logStream = fs.createWriteStream(this.logFilePath, { flags: "a", encoding: "utf8", }); } /** * Get filtered logs based on log level * @param requestedLevel The minimum log level to include ("error", "info", or "debug") * @param lines Maximum number of lines to return */ getFilteredLogs(requestedLevel, lines) { // Filter logs based on requested level let filteredLogs = this.recentLogs; if (requestedLevel !== "debug") { const minLevel = requestedLevel === "error" ? LogLevel.ERROR : LogLevel.INFO; filteredLogs = this.recentLogs.filter(entry => entry.level <= minLevel); } // Get the last N lines from filtered logs const startIndex = Math.max(0, filteredLogs.length - lines); const selectedLogs = filteredLogs.slice(startIndex); // Format logs as strings for output const formattedLogs = selectedLogs.map(entry => `[${entry.timestamp}] [${LogLevel[entry.level]}] ${entry.message}`); return { logs: formattedLogs, totalCount: filteredLogs.length }; } /** * Close the log stream */ close() { this.logStream.end(); } error(message, ...args) { this.log(LogLevel.ERROR, message, ...args); } info(message, ...args) { this.log(LogLevel.INFO, message, ...args); } debug(message, ...args) { this.log(LogLevel.DEBUG, message, ...args); } log(level, message, ...args) { const now = new Date(); const timestamp = now.toTimeString().split(' ')[0]; // HH:MM:SS format const levelName = LogLevel[level]; // Format the full message let fullMessage = message; if (args.length > 0) { const formattedArgs = args.map(arg => typeof arg === 'object' ? JSON.stringify(arg) : String(arg)).join(' '); fullMessage += ' ' + formattedArgs; } // Add to recent logs this.addToRecentLogs({ level: level, timestamp: timestamp, message: fullMessage }); // Only write to file if the message level is at or below the configured level if (level <= this.level) { // Write to log file with full timestamp const fullTimestamp = now.toISOString(); let fileLogMessage = `[${fullTimestamp}] [${levelName}] ${message}`; if (args.length > 0) { const formattedArgs = args.map(arg => typeof arg === 'object' ? JSON.stringify(arg) : String(arg)).join(' '); fileLogMessage += ' ' + formattedArgs; } this.logStream.write(fileLogMessage + "\n"); } } addToRecentLogs(entry) { this.recentLogs.push(entry); // Maintain circular buffer size if (this.recentLogs.length > this.maxRecentLogs) { this.recentLogs.shift(); } } } class ClangdDaemon { projectRoot; socketPath; lockFilePath; logFilePath; server = null; clangdClient = null; startTime = Date.now(); requestCount = 0; lastRequestTime = Date.now(); idleTimer = null; idleTimeoutMs; connections = new Set(); logLevel; requestLogBuffer = null; // Buffer for request-specific logs fileWatcher = null; logger; // Daemon-wide logger instance (initialized in constructor) constructor(projectRoot) { this.projectRoot = path.resolve(projectRoot); this.socketPath = generateSocketPath(this.projectRoot); this.lockFilePath = generateLockFilePath(this.projectRoot); this.logFilePath = getLogFilePath(this.projectRoot); // Get idle timeout from environment or use default (30 minutes) const timeoutSeconds = parseInt(process.env.CLANGD_DAEMON_TIMEOUT || "1800", 10); this.idleTimeoutMs = timeoutSeconds * 1000; // Set default log level to INFO // This captures important events without too much noise this.logLevel = LogLevel.INFO; // Create the daemon-wide logger (non-null from this point on) this.logger = new DaemonLogger(this.logFilePath, this.logLevel); } initializeLogging() { this.logger.info(`Daemon starting for project: ${this.projectRoot}`); this.logger.info(`PID: ${process.pid}`); this.logger.info(`Socket path: ${this.socketPath}`); this.logger.info(`Idle timeout: ${this.idleTimeoutMs / 1000} seconds`); } resetIdleTimer() { if (this.idleTimer) { clearTimeout(this.idleTimer); } this.idleTimer = setTimeout(() => { this.logger.info("Idle timeout reached, shutting down"); this.shutdown().catch((error) => { this.logger.error("Error during idle shutdown", error); process.exit(1); }); }, this.idleTimeoutMs); } async checkExistingDaemon() { // Clean up stale lock files first const wasStale = cleanupStaleLockFile(this.lockFilePath); if (wasStale) { this.logger.info("Cleaned up stale lock file"); } // Check if lock file still exists const lockData = readLockFile(this.lockFilePath); if (lockData) { this.logger.error(`Another daemon is already running (PID: ${lockData.pid})`); return true; } return false; } createLockFile() { const lockData = { pid: process.pid, socketPath: this.socketPath, startTime: this.startTime, projectRoot: this.projectRoot, buildTimestamp: calculateBuildTimestamp(import.meta.url), }; writeLockFile(this.lockFilePath, lockData); this.logger.info("Lock file created"); } removeLockFile() { try { fs.unlinkSync(this.lockFilePath); this.logger.info("Lock file removed"); } catch (error) { this.logger.error("Failed to remove lock file", error); } } removeSocketFile() { try { if (fs.existsSync(this.socketPath)) { fs.unlinkSync(this.socketPath); this.logger.info("Socket file removed"); } } catch (error) { this.logger.error("Failed to remove socket file", error); } } async initializeClangd() { this.logger.info("Initializing clangd"); this.clangdClient = new ClangdClient(this.projectRoot, { clangdPath: process.env.CLANGD_PATH, logger: this.logger, }); await this.clangdClient.start(); this.logger.info("Clangd initialized successfully"); } /** * Handle file change events from the file watcher */ async handleFileChanges(changes) { if (!this.clangdClient) { this.logger.error("Cannot handle file changes: clangd client not initialized"); return; } try { // Convert our FileEvent type to LSP FileEvent type const lspFileEvents = changes.map(change => ({ uri: change.uri, type: change.type, })); await this.clangdClient.sendFileChangeNotification(lspFileEvents); this.logger.info(`Notified clangd about ${changes.length} file changes`); this.logger.debug("File change details:", lspFileEvents); // Check if compile_commands.json changed const hasCompileCommandsChanged = changes.some(change => change.uri.endsWith("/compile_commands.json")); if (hasCompileCommandsChanged) { this.logger.info("compile_commands.json changed - clangd should reindex the project"); // clangd should handle this automatically when notified } } catch (error) { this.logger.error("Failed to notify clangd about file changes", error); } } /** * Initialize the file watcher */ async initializeFileWatcher() { this.logger.info("Initializing file watcher"); this.fileWatcher = new FileWatcher({ projectRoot: this.projectRoot, onFileChanges: (changes) => this.handleFileChanges(changes), logger: { error: (msg, ...args) => this.logger.error(msg, ...args), info: (msg, ...args) => this.logger.info(msg, ...args), debug: (msg, ...args) => this.logger.debug(msg, ...args), }, debounceMs: 500, }); await this.fileWatcher.start(); this.logger.info("File watcher initialized successfully"); } async handleRequest(request) { this.requestCount++; this.lastRequestTime = Date.now(); this.resetIdleTimer(); // Initialize request log buffer to capture all logs during this request this.requestLogBuffer = []; const requestLogger = new RequestLogger(this.requestLogBuffer); const response = { jsonrpc: "2.0", id: request.id, }; try { switch (request.method) { case "searchSymbols": { const { query, limit } = request.params || {}; if (!query) { throw new Error("Missing required parameter: query"); } const result = await commands.searchSymbolsAsText(this.clangdClient, query, limit, requestLogger); response.result = { text: result }; break; } case "viewSourceCode": { const { query } = request.params || {}; if (!query) { throw new Error("Missing required parameter: query"); } const result = await commands.viewSourceCodeAsText(this.clangdClient, query, requestLogger); response.result = { text: result }; break; } case "findReferences": { const { location } = request.params || {}; if (!location) { throw new Error("Missing required parameter: location"); } const result = await commands.findReferencesAsText(this.clangdClient, location, requestLogger); response.result = { text: result }; break; } case "findReferencesToSymbol": { const { symbolName } = request.params || {}; if (!symbolName) { throw new Error("Missing required parameter: symbolName"); } const result = await commands.findReferencesToSymbolAsText(this.clangdClient, symbolName, requestLogger); response.result = { text: result }; break; } case "getTypeHierarchy": { const { className } = request.params || {}; if (!className) { throw new Error("Missing required parameter: className"); } const result = await commands.getTypeHierarchyAsText(this.clangdClient, className, requestLogger); response.result = { text: result }; break; } case "getSignature": { const { functionName } = request.params || {}; if (!functionName) { throw new Error("Missing required parameter: functionName"); } const result = await commands.getSignatureAsText(this.clangdClient, functionName, requestLogger); response.result = { text: result }; break; } case "getInterface": { const { className } = request.params || {}; if (!className) { throw new Error("Missing required parameter: className"); } const result = await commands.getInterfaceAsText(this.clangdClient, className, requestLogger); response.result = { text: result }; break; } case "getShow": { const { symbol } = request.params || {}; if (!symbol) { throw new Error("Missing required parameter: symbol"); } const result = await commands.getShowAsText(this.clangdClient, symbol, requestLogger); response.result = { text: result }; break; } case "getContext": { // Keep for backward compatibility but redirect to show const { symbol } = request.params || {}; if (!symbol) { throw new Error("Missing required parameter: symbol"); } const result = await commands.getShowAsText(this.clangdClient, symbol, requestLogger); response.result = { text: result }; break; } case "ping": { response.result = { status: "ok", timestamp: Date.now() }; break; } case "getStatus": { const memUsage = process.memoryUsage(); const status = { uptime: Date.now() - this.startTime, projectRoot: this.projectRoot, memory: { heapUsed: `${Math.round(memUsage.heapUsed / 1024 / 1024)}MB`, heapTotal: `${Math.round(memUsage.heapTotal / 1024 / 1024)}MB`, rss: `${Math.round(memUsage.rss / 1024 / 1024)}MB`, }, requestCount: this.requestCount, lastRequestTime: this.lastRequestTime, indexingComplete: true, // We wait for indexing during startup }; response.result = status; break; } case "getRecentLogs": { const lines = request.params?.lines || 100; const requestedLevel = request.params?.logLevel || "debug"; // Get filtered logs from logger const result = this.logger.getFilteredLogs(requestedLevel, lines); response.result = { logs: result.logs, totalCount: result.totalCount, returnedCount: result.logs.length }; break; } case "shutdown": { response.result = { status: "shutting down" }; // Schedule shutdown after sending response setTimeout(() => { this.shutdown().catch((error) => { this.logger.error("Error during shutdown", error); process.exit(1); }); }, 100); break; } default: response.error = { code: -32601, message: `Method not found: ${request.method}`, }; } } catch (error) { this.logger.error(`Error handling request ${request.method}`, error); response.error = { code: -32000, message: error instanceof Error ? error.message : "Unknown error", data: error instanceof Error ? { stack: error.stack } : undefined, }; } // Include captured logs in the response if (this.requestLogBuffer !== null && this.requestLogBuffer.length > 0) { response.logs = this.requestLogBuffer; } // Clear request log buffer this.requestLogBuffer = null; return response; } handleConnection(socket) { this.logger.debug("New client connected"); this.connections.add(socket); let buffer = ""; socket.on("data", async (data) => { buffer += data.toString(); // Process complete JSON messages (newline-delimited) let lines = buffer.split("\n"); buffer = lines.pop() || ""; // Keep incomplete line in buffer for (const line of lines) { if (!line.trim()) continue; try { const request = JSON.parse(line); this.logger.debug(`Received request: ${request.method}`, request.params); const response = await this.handleRequest(request); socket.write(JSON.stringify(response) + "\n"); this.logger.debug(`Sent response for request ${request.id}`); } catch (error) { this.logger.error("Failed to parse request", error); const errorResponse = { jsonrpc: "2.0", id: 0, error: { code: -32700, message: "Parse error", }, }; socket.write(JSON.stringify(errorResponse) + "\n"); } } }); socket.on("error", (error) => { this.logger.error("Socket error", error); }); socket.on("close", () => { this.logger.debug("Client disconnected"); this.connections.delete(socket); }); } async start() { // Initialize logging first this.initializeLogging(); // Check for existing daemon if (await this.checkExistingDaemon()) { throw new Error("Another daemon is already running"); } // Create lock file this.createLockFile(); // Clean up socket file if it exists this.removeSocketFile(); // Initialize clangd await this.initializeClangd(); // Initialize file watcher after clangd is ready await this.initializeFileWatcher(); // Create socket server this.server = net.createServer((socket) => this.handleConnection(socket)); // Set socket permissions (owner read/write only) this.server.listen(this.socketPath, () => { fs.chmodSync(this.socketPath, 0o600); this.logger.info(`Daemon listening on ${this.socketPath}`); }); // Start idle timer this.resetIdleTimer(); // Handle graceful shutdown process.on("SIGTERM", () => { this.logger.info("Received SIGTERM, shutting down"); this.shutdown().catch((error) => { this.logger.error("Error during shutdown", error); process.exit(1); }); }); process.on("SIGINT", () => { this.logger.info("Received SIGINT, shutting down"); this.shutdown().catch((error) => { this.logger.error("Error during shutdown", error); process.exit(1); }); }); // Handle uncaught errors process.on("uncaughtException", (error) => { this.logger.error("Uncaught exception", error); this.shutdown().then(() => { process.exit(1); }); }); process.on("unhandledRejection", (reason, promise) => { this.logger.error("Unhandled rejection", reason); this.shutdown().then(() => { process.exit(1); }); }); } async shutdown() { this.logger.info("Starting shutdown sequence"); // Clear idle timer if (this.idleTimer) { clearTimeout(this.idleTimer); this.idleTimer = null; } // Close all connections for (const socket of this.connections) { socket.destroy(); } this.connections.clear(); // Close server if (this.server) { await new Promise((resolve) => { this.server.close(() => { this.logger.info("Server closed"); resolve(); }); }); } // Stop file watcher if (this.fileWatcher) { await this.fileWatcher.stop(); this.logger.info("File watcher stopped"); } // Stop clangd if (this.clangdClient) { await this.clangdClient.stop(); this.logger.info("Clangd stopped"); } // Remove lock file and socket this.removeLockFile(); this.removeSocketFile(); // Log final message before closing logger this.logger.info("Shutdown complete"); // Close logger this.logger.close(); process.exit(0); } } // Main entry point async function main() { const projectRoot = process.argv[2]; if (!projectRoot) { console.error("Usage: clangd-daemon <project-root>"); process.exit(1); } const daemon = new ClangdDaemon(projectRoot); try { await daemon.start(); } catch (error) { console.error("Failed to start daemon:", error); process.exit(1); } } // Run if executed directly if (import.meta.url === `file://${process.argv[1]}`) { main().catch((error) => { console.error("Fatal error:", error); process.exit(1); }); } //# sourceMappingURL=daemon.js.map