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
JavaScript
/**
* 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