omnifocus-mcp
Version:
Model Context Protocol (MCP) server that integrates with OmniFocus for AI assistant interaction
116 lines (115 loc) • 4.28 kB
JavaScript
import { mkdirSync, writeFileSync, readFileSync, rmSync, statSync } from 'fs';
import { join } from 'path';
/**
* A cross-process advisory lock built on atomic `mkdir`.
*
* Ten agents starting at once is the normal case for this project (issue #80),
* so "who creates the daemon" has to be settled without a coordinator. `mkdir`
* is atomic on every POSIX filesystem: exactly one caller creates the directory,
* everyone else gets EEXIST. That is the whole mechanism.
*
* Node has no `flock` in core, and a lock that only exists in memory would be
* useless here — the contenders are separate processes. The cost of the mkdir
* approach is that a process killed while holding the lock leaves the directory
* behind, so acquisition also has to recognise and break a dead holder's lock.
*/
/** How long before an unattended lock is presumed dead. */
export const DEFAULT_STALE_MS = 30_000;
function defaultIsAlive(pid) {
try {
// Signal 0 performs the permission/existence check without delivering a
// signal — the standard liveness probe.
process.kill(pid, 0);
return true;
}
catch (err) {
// EPERM means the process exists but belongs to someone else. Since the
// socket dir is 0700 that shouldn't happen, but "exists" is the safe read.
return err.code === 'EPERM';
}
}
/**
* Is this lock directory abandoned?
*
* Either signal is enough to break it: a dead holder, or an old lock. That is
* deliberately eager, and the asymmetry of the two failure modes is why.
*
* Breaking a lock too readily costs almost nothing — two daemons start, one
* loses the bind race and exits. Refusing to break one costs a great deal: every
* client falls back to its own standalone server, which is precisely the process
* pileup of issue #80. So a holder that is alive but wedged past the stale window
* still loses its lock; being alive is not by itself proof of progress.
*
* The pid check is what makes recovery from a SIGKILLed daemon fast (immediate
* rather than a full stale window); the age check is the backstop for when the
* pid file is missing or we caught the holder mid-write.
*/
function isStale(lockDir, staleMs, isAlive) {
let holder;
try {
const raw = readFileSync(join(lockDir, 'pid'), 'utf-8').trim();
const parsed = Number.parseInt(raw, 10);
if (Number.isInteger(parsed) && parsed > 0)
holder = parsed;
}
catch {
/* no pid file yet, or unreadable — fall through to the age check */
}
if (holder !== undefined && !isAlive(holder))
return true;
try {
return Date.now() - statSync(lockDir).mtimeMs > staleMs;
}
catch {
// Vanished underneath us; treat as free rather than stale — the caller's
// retry will simply create it.
return true;
}
}
/**
* Try to take the lock without blocking. Returns null if someone else holds it.
*/
export function tryAcquireLock(lockDir, options = {}) {
const staleMs = options.staleMs ?? DEFAULT_STALE_MS;
const isAlive = options.isAlive ?? defaultIsAlive;
const pid = options.pid ?? process.pid;
const claim = () => {
try {
mkdirSync(lockDir, { recursive: false, mode: 0o700 });
}
catch (err) {
if (err.code === 'EEXIST')
return null;
throw err;
}
try {
writeFileSync(join(lockDir, 'pid'), String(pid), { mode: 0o600 });
}
catch {
/* the directory is the lock; the pid file is only a diagnostic aid */
}
return { release: () => releaseLock(lockDir) };
};
const lock = claim();
if (lock)
return lock;
if (!isStale(lockDir, staleMs, isAlive))
return null;
// Break the dead holder's lock, then race everyone else who noticed at the
// same moment. Exactly one of us wins the re-claim, which is the point.
try {
rmSync(lockDir, { recursive: true, force: true });
}
catch {
return null;
}
return claim();
}
export function releaseLock(lockDir) {
try {
rmSync(lockDir, { recursive: true, force: true });
}
catch {
/* already gone */
}
}