UNPKG

socraticode

Version:

SocratiCode — MCP server for local codebase indexing, semantic search, and code dependency graphs. All private, all local via Docker.

307 lines • 12.7 kB
// SPDX-License-Identifier: AGPL-3.0-only // Copyright (C) 2026 Giancarlo Erra - Altaire Limited import { execSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import lockfile from "proper-lockfile"; import { projectIdFromPath } from "../config.js"; import { logger } from "./logger.js"; /** * Cross-process lock for SocratiCode operations. * * Uses proper-lockfile for atomic file-based locking that works on * macOS, Windows, and Linux. Prevents multiple MCP instances from * simultaneously indexing or watching the same project. * * Lock files are stored in the OS temp directory under a socraticode subfolder. * Each lock file is named by project ID and operation type, e.g.: * /tmp/socraticode-locks/cfc52e6ea434-index.lock * * Staleness: locks are considered stale after 2 minutes (if the holding process * crashes without releasing). proper-lockfile handles this automatically. */ const LOCK_DIR = path.join(os.tmpdir(), "socraticode-locks"); /** Staleness threshold in ms — if a lock hasn't been refreshed in this time, reclaim it */ const STALE_MS = 120_000; // 2 minutes /** How often proper-lockfile refreshes the lock (must be < stale/2) */ const UPDATE_MS = 30_000; // 30 seconds /** Track release functions for locks we hold */ const heldLocks = new Map(); function ensureLockDir() { if (!fs.existsSync(LOCK_DIR)) { fs.mkdirSync(LOCK_DIR, { recursive: true }); } } function lockKey(projectPath, operation) { return identityLockKey(projectIdFromPath(path.resolve(projectPath)), operation); } function identityLockKey(projectId, operation) { return `${projectId}-${operation}`; } function lockFilePath(key) { return path.join(LOCK_DIR, key); } /** * Try to acquire a cross-process lock for a project operation. * * @param projectPath - Absolute path to the project directory * @param operation - Operation type: "index" or "watch" * @param onCompromised - Called if the lock is lost while still held. The lock * guards a shared collection, so losing it means another process may now be * writing to what this one is still writing to; the caller is the only thing * that knows how to stand down. It runs from proper-lockfile's timer, where * nothing is waiting on it, so both a synchronous throw and a rejected * promise are logged and swallowed rather than becoming an unhandled * rejection. * @returns true if the lock was acquired, false if another process holds it */ export async function acquireProjectLock(projectPath, operation, onCompromised, options = {}) { return acquireIdentityLock(options.projectId ?? projectIdFromPath(path.resolve(projectPath)), operation, onCompromised, projectPath, options.reentrant ?? true); } /** Reclamation addresses an identity whose directory may be gone, so it locks by identity, not path. */ export async function acquireIdentityLock(projectId, operation, onCompromised, projectPath = projectId, reentrant = true) { ensureLockDir(); const key = identityLockKey(projectId, operation); const filePath = lockFilePath(key); // Ensure the file to lock exists (proper-lockfile requires it) if (!fs.existsSync(filePath)) { fs.writeFileSync(filePath, `${process.pid}\n`, "utf-8"); } // A lock this process holds belongs to whoever took it; a non-reentrant caller must not adopt it. if (heldLocks.has(key)) { return reentrant; } try { const release = await lockfile.lock(filePath, { stale: STALE_MS, update: UPDATE_MS, retries: 0, // Don't retry — fail immediately if already locked realpath: false, // Don't resolve symlinks — lock the exact path onCompromised: (err) => { // Lock was compromised (e.g., stale lock reclaimed by another process) logger.warn("Lock compromised, another process may have reclaimed it", { projectPath, operation, error: err.message, }); heldLocks.delete(key); const logHandlerFailure = (handlerErr) => { logger.warn("Lock compromise handler failed", { projectPath, operation, error: handlerErr instanceof Error ? handlerErr.message : String(handlerErr), }); }; try { // The handler is invoked synchronously, so a caller standing down // takes effect immediately; only its rejection is deferred. A // synchronous throw still reaches the catch below. void Promise.resolve(onCompromised?.(err)).catch(logHandlerFailure); } catch (handlerErr) { logHandlerFailure(handlerErr); } }, }); // Always stamp our PID into the lock file after acquiring it — even if the file // already existed with a stale PID from a prior run. getLockHolderPid reads this // to identify which process currently owns the lock across process boundaries. fs.writeFileSync(filePath, `${process.pid}\n`, "utf-8"); heldLocks.set(key, release); logger.debug("Acquired project lock", { projectPath, operation, key }); return true; } catch (err) { // ELOCKED = another process holds the lock if (err instanceof Error && err.code === "ELOCKED") { logger.debug("Lock already held by another process", { projectPath, operation, key }); return false; } // Unexpected error — log and treat as unable to acquire logger.warn("Failed to acquire project lock", { projectPath, operation, error: err instanceof Error ? err.message : String(err), }); return false; } } /** * Whether this process still holds the lock for a project operation. * * `onCompromised` removes the entry when proper-lockfile reports the lock lost, * so this answers false from the moment ownership is known to be gone. Callers * use it to decide whether a write they were about to make is still theirs to * make — once another process may hold the lock, the safe move is to stop * rather than to correct, since a correction would overwrite the new holder. */ export function holdsProjectLock(projectPath, operation, projectId) { return heldLocks.has(projectId === undefined ? lockKey(projectPath, operation) : identityLockKey(projectId, operation)); } /** Whether this process holds the lock for a recorded project identity. */ export function holdsIdentityLock(projectId, operation) { return heldLocks.has(identityLockKey(projectId, operation)); } /** * Release a previously acquired lock. * * @param projectPath - Absolute path to the project directory * @param operation - Operation type: "index" or "watch" */ export async function releaseProjectLock(projectPath, operation, projectId) { return releaseIdentityLock(projectId ?? projectIdFromPath(path.resolve(projectPath)), operation, projectPath); } /** Release a lock acquired through {@link acquireIdentityLock}. */ export async function releaseIdentityLock(projectId, operation, projectPath = projectId) { const key = identityLockKey(projectId, operation); const release = heldLocks.get(key); if (release) { try { await release(); logger.debug("Released project lock", { projectPath, operation, key }); } catch (err) { // Lock may already be released (e.g., compromised) — not fatal logger.debug("Lock release failed (may already be released)", { projectPath, operation, error: err instanceof Error ? err.message : String(err), }); } finally { heldLocks.delete(key); } } } /** * Check if a lock is currently held (by any process). */ export async function isProjectLocked(projectPath, operation) { try { return await isProjectIdentityLocked(projectIdFromPath(path.resolve(projectPath)), operation); } catch { return false; } } /** A lock whose state could not be read; a caller about to delete must treat it as held. */ export class LockInspectionError extends Error { projectId; operation; constructor(projectId, operation, cause) { super(`Could not inspect the ${operation} lock for ${projectId}: ${cause instanceof Error ? cause.message : String(cause)}`); this.projectId = projectId; this.operation = operation; this.name = "LockInspectionError"; } } /** Check a lock by identity; throws LockInspectionError when the answer is unknown rather than answering false. */ export async function isProjectIdentityLocked(projectId, operation) { try { ensureLockDir(); const filePath = lockFilePath(identityLockKey(projectId, operation)); try { fs.statSync(filePath); } catch (err) { // Only a missing file means nobody holds the lock; an unreadable one says nothing. if (err.code === "ENOENT") return false; throw err; } return await lockfile.check(filePath, { stale: STALE_MS, realpath: false, }); } catch (err) { throw new LockInspectionError(projectId, operation, err); } } /** * Read the PID stored in the lock file and verify the process is still alive. * Returns the PID if the lock is held by a live process, or null otherwise. */ export async function getLockHolderPid(projectPath, operation) { ensureLockDir(); const key = lockKey(projectPath, operation); const filePath = lockFilePath(key); if (!fs.existsSync(filePath)) return null; // Only check if the lock is actually held try { const locked = await lockfile.check(filePath, { stale: STALE_MS, realpath: false }); if (!locked) return null; } catch { return null; } // Read the PID from the lock file content try { const content = fs.readFileSync(filePath, "utf-8").trim(); const pid = parseInt(content, 10); if (Number.isNaN(pid) || pid <= 0) return null; // Don't report our own process as an orphan if (pid === process.pid) return null; // Check if the process is alive (signal 0 = existence check) process.kill(pid, 0); return pid; } catch { // process.kill(pid, 0) throws if process doesn't exist return null; } } /** * Attempt to terminate an orphan process holding a project lock. * On POSIX (macOS/Linux): sends SIGTERM for graceful shutdown. * On Windows: uses `taskkill /F /PID` since cross-process SIGTERM is not supported. * Returns true if the termination signal was sent successfully, false otherwise. */ export async function terminateLockHolder(projectPath, operation) { const pid = await getLockHolderPid(projectPath, operation); if (pid === null) { return { terminated: false, pid: null }; } try { if (process.platform === "win32") { // process.kill() with signals other than 0 is not supported cross-process on Windows. // taskkill /F is a force-kill (no graceful shutdown), but it's the only reliable option. execSync(`taskkill /F /PID ${pid}`, { stdio: "ignore" }); logger.info("Sent taskkill to orphan lock-holder process (Windows)", { pid, projectPath, operation }); } else { process.kill(pid, "SIGTERM"); logger.info("Sent SIGTERM to orphan lock-holder process", { pid, projectPath, operation }); } return { terminated: true, pid }; } catch (err) { logger.warn("Failed to terminate lock-holder process", { pid, projectPath, operation, error: err instanceof Error ? err.message : String(err), }); return { terminated: false, pid }; } } /** * Release all locks held by this process. * Called during graceful shutdown. */ export async function releaseAllLocks() { for (const [key, release] of heldLocks) { try { await release(); logger.debug("Released lock during shutdown", { key }); } catch { // Best effort during shutdown } } heldLocks.clear(); } //# sourceMappingURL=lock.js.map