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