Skip to content

Commit 5458899

Browse files
committed
Use proper-lockfile for atomic instance lock, fix #11
Replace the manual PID/alive check with proper-lockfile.lock(), which acquires the lock atomically via mkdir. The previous implementation had a TOCTOU window between isAlive() in preflight and the writeFile() in server.listen(): two concurrent invocations could both pass the check and proceed, racing on session.json and corrupting run folders. Pid/port info (used in the "instance-running" error hint) moves to a sidecar `.lock.meta.json` written after the server begins listening. A migration step removes a legacy `.lock` file from older versions so proper-lockfile (which needs to mkdir at that path) can take over.
1 parent cdc29cd commit 5458899

2 files changed

Lines changed: 65 additions & 35 deletions

File tree

src/server/index.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { fileURLToPath } from 'node:url';
33
import { dirname, resolve } from 'node:path';
44
import getPort from 'get-port';
55
import open from 'open';
6-
import { runPreflight, writeLock, releaseLock } from './preflight.js';
6+
import { runPreflight, writeLockMeta } from './preflight.js';
77
import { SessionStore } from './session.js';
88
import { RunManager } from './runManager.js';
99
import { createRouter } from './routes.js';
@@ -56,7 +56,7 @@ async function main(): Promise<void> {
5656
});
5757

5858
server.listen(port, '127.0.0.1', async () => {
59-
await writeLock(preflight.lockFilePath, process.pid, port);
59+
await writeLockMeta(preflight.lockFilePath, process.pid, port);
6060
const url = `${auth.origin}/?t=${auth.token}`;
6161
log.info('server.listening', { url, pid: process.pid });
6262
console.log(`mdredd listening at ${url}`);
@@ -93,7 +93,7 @@ async function main(): Promise<void> {
9393
if (result.timedOut) {
9494
log.warn('server.shutdown.stopAll-timeout', { stopped: result.stopped });
9595
}
96-
await releaseLock(preflight.lockFilePath);
96+
await preflight.releaseLock();
9797
} catch (err) {
9898
log.error('server.shutdown-error', { error: (err as Error).message });
9999
} finally {

src/server/preflight.ts

Lines changed: 62 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ import { homedir, tmpdir } from 'node:os';
44
import { join } from 'node:path';
55
import { pathExists, atomicWriteFile, ensureDir, readJsonIfExists } from './fsUtil.js';
66
import { PROJECT_MARKERS, STORAGE_DIR_NAME } from '@shared/constants.js';
7-
import { readdir, readFile, writeFile, unlink } from 'node:fs/promises';
7+
import { readdir, readFile, stat, unlink, writeFile } from 'node:fs/promises';
8+
import lockfile from 'proper-lockfile';
89
import { log } from './log.js';
910

1011
const execFileAsync = promisify(execFile);
@@ -18,9 +19,14 @@ export interface PreflightInput {
1819
export interface PreflightResult {
1920
storageRoot: string;
2021
lockFilePath: string;
21-
ownedLock: boolean;
22+
releaseLock: () => Promise<void>;
2223
}
2324

25+
// Threshold (ms) past which a lockfile is considered stale and may be
26+
// reclaimed. proper-lockfile refreshes the lockfile's mtime every stale/2
27+
// while the holder is alive; after a crash it is reclaimable after `stale` ms.
28+
const LOCK_STALE_MS = 30_000;
29+
2430
export class PreflightError extends Error {
2531
code: string;
2632
hint: string | undefined;
@@ -39,9 +45,9 @@ export async function runPreflight(input: PreflightInput): Promise<PreflightResu
3945
const lockFilePath = join(storageRoot, '.lock');
4046
await ensureDir(storageRoot);
4147
await ensureAutoGitignore(storageRoot);
42-
await acquireLock(lockFilePath);
48+
const releaseLock = await acquireLock(storageRoot, lockFilePath);
4349
await recoverAbandonedRuns(storageRoot);
44-
return { storageRoot, lockFilePath, ownedLock: true };
50+
return { storageRoot, lockFilePath, releaseLock };
4551
}
4652

4753
async function checkClaudeCli(bin: string): Promise<void> {
@@ -242,41 +248,65 @@ async function ensureAutoGitignore(storageRoot: string): Promise<void> {
242248
await atomicWriteFile(gitignorePath, '*\n!.gitignore\n');
243249
}
244250

245-
async function acquireLock(lockFilePath: string): Promise<void> {
246-
const existing = await readJsonIfExists<{ pid: number; port: number; startedAt: string }>(
247-
lockFilePath,
248-
);
249-
if (existing && isAlive(existing.pid)) {
250-
throw new PreflightError(
251-
'instance-running',
252-
`Another mdredd instance appears to be running (pid ${existing.pid}, port ${existing.port}).`,
253-
`Close it first, or remove ${lockFilePath} if you are sure it is stale.`,
254-
);
255-
}
256-
if (existing) {
257-
log.info('preflight.stale-lock-recovered', { pid: existing.pid });
258-
await unlink(lockFilePath).catch(() => undefined);
259-
}
251+
function lockMetaPath(lockFilePath: string): string {
252+
return `${lockFilePath}.meta.json`;
260253
}
261254

262-
export async function writeLock(lockFilePath: string, pid: number, port: number): Promise<void> {
263-
await writeFile(
264-
lockFilePath,
265-
JSON.stringify({ pid, port, startedAt: new Date().toISOString() }, null, 2),
266-
);
267-
}
268-
269-
export async function releaseLock(lockFilePath: string): Promise<void> {
270-
await unlink(lockFilePath).catch(() => undefined);
255+
// Older versions wrote a JSON file at `.lock`. proper-lockfile uses that path
256+
// as a directory (mkdir-based), so a stale legacy file would block startup
257+
// forever. Remove it before attempting to acquire.
258+
async function migrateLegacyLock(lockFilePath: string): Promise<void> {
259+
try {
260+
const st = await stat(lockFilePath);
261+
if (st.isFile()) {
262+
await unlink(lockFilePath).catch(() => undefined);
263+
log.info('preflight.legacy-lock-removed', { path: lockFilePath });
264+
}
265+
} catch {
266+
// not present — nothing to migrate
267+
}
271268
}
272269

273-
function isAlive(pid: number): boolean {
270+
async function acquireLock(
271+
storageRoot: string,
272+
lockFilePath: string,
273+
): Promise<() => Promise<void>> {
274+
await migrateLegacyLock(lockFilePath);
275+
const metaPath = lockMetaPath(lockFilePath);
276+
let release: () => Promise<void>;
274277
try {
275-
process.kill(pid, 0);
276-
return true;
278+
release = await lockfile.lock(storageRoot, {
279+
lockfilePath: lockFilePath,
280+
stale: LOCK_STALE_MS,
281+
realpath: false,
282+
});
277283
} catch (err) {
278-
return (err as NodeJS.ErrnoException).code === 'EPERM';
284+
if ((err as { code?: string }).code === 'ELOCKED') {
285+
const meta = await readJsonIfExists<{ pid: number; port: number }>(metaPath);
286+
const info = meta ? ` (pid ${meta.pid}, port ${meta.port})` : '';
287+
throw new PreflightError(
288+
'instance-running',
289+
`Another mdredd instance appears to be running${info}.`,
290+
`Close it first, or remove ${lockFilePath} if you are sure it is stale.`,
291+
);
292+
}
293+
throw err;
279294
}
295+
return async () => {
296+
await release().catch(() => undefined);
297+
await unlink(metaPath).catch(() => undefined);
298+
};
299+
}
300+
301+
export async function writeLockMeta(
302+
lockFilePath: string,
303+
pid: number,
304+
port: number,
305+
): Promise<void> {
306+
await atomicWriteFile(
307+
lockMetaPath(lockFilePath),
308+
JSON.stringify({ pid, port, startedAt: new Date().toISOString() }, null, 2),
309+
);
280310
}
281311

282312
async function recoverAbandonedRuns(storageRoot: string): Promise<void> {

0 commit comments

Comments
 (0)