@@ -4,7 +4,8 @@ import { homedir, tmpdir } from 'node:os';
44import { join } from 'node:path' ;
55import { pathExists , atomicWriteFile , ensureDir , readJsonIfExists } from './fsUtil.js' ;
66import { 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' ;
89import { log } from './log.js' ;
910
1011const execFileAsync = promisify ( execFile ) ;
@@ -18,9 +19,14 @@ export interface PreflightInput {
1819export 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+
2430export 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
4753async 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
282312async function recoverAbandonedRuns ( storageRoot : string ) : Promise < void > {
0 commit comments