@@ -9,8 +9,10 @@ import {
99 rename ,
1010 rm ,
1111 truncate ,
12+ utimes ,
1213 writeFile ,
1314} from "node:fs/promises" ;
15+ import { hostname } from "node:os" ;
1416import { dirname , isAbsolute , join , relative , resolve , sep } from "node:path" ;
1517import { promisify } from "node:util" ;
1618import Papa from "papaparse" ;
@@ -28,6 +30,8 @@ const REQUIRED_ARTIFACTS = [
2830 "coverage.json" ,
2931 "report.md" ,
3032] ;
33+ const LOCK_LEASE_MS = 30_000 ;
34+ const LOCK_HEARTBEAT_MS = 5_000 ;
3135
3236interface MultiscanTask {
3337 id : string ;
@@ -276,25 +280,153 @@ async function acquireLock(output: string): Promise<() => Promise<void>> {
276280 await mkdir ( path , { mode : 0o700 } ) ;
277281 } catch ( error ) {
278282 if ( ( error as NodeJS . ErrnoException ) . code !== "EEXIST" ) throw error ;
279- const { pid } = JSON . parse ( await readFile ( ownerPath , "utf8" ) ) as {
280- pid : number ;
283+ const existing = await inspectLock ( path ) ;
284+ if ( ! existing . stale ) {
285+ throw new Error ( "A multiscan supervisor is already running." ) ;
286+ }
287+ await recoverLock ( output , path , existing . owner ) ;
288+ return await acquireLock ( output ) ;
289+ }
290+ const owner = `${ JSON . stringify ( {
291+ pid : process . pid ,
292+ ownerId : randomUUID ( ) ,
293+ hostname : hostname ( ) ,
294+ processStartedAt : performance . timeOrigin ,
295+ } ) } \n`;
296+ await writeFile ( ownerPath , owner , { flag : "wx" , mode : 0o600 } ) ;
297+
298+ let heartbeat = Promise . resolve ( ) ;
299+ const timer = setInterval ( ( ) => {
300+ heartbeat = heartbeat
301+ . then ( async ( ) => {
302+ if ( ( await readFile ( ownerPath , "utf8" ) ) !== owner ) return ;
303+ const now = new Date ( ) ;
304+ await utimes ( ownerPath , now , now ) ;
305+ } )
306+ . catch ( ( ) => { } ) ;
307+ } , LOCK_HEARTBEAT_MS ) ;
308+ timer . unref ( ) ;
309+
310+ return async ( ) => {
311+ clearInterval ( timer ) ;
312+ await heartbeat ;
313+ const current = await readFile ( ownerPath , "utf8" ) . catch (
314+ ( error : NodeJS . ErrnoException ) => {
315+ if ( error . code !== "ENOENT" ) throw error ;
316+ return undefined ;
317+ } ,
318+ ) ;
319+ if ( current === owner ) await rm ( path , { recursive : true } ) ;
320+ } ;
321+ }
322+
323+ async function inspectLock (
324+ path : string ,
325+ ) : Promise < { owner : string | undefined ; stale : boolean } > {
326+ const ownerPath = join ( path , "owner.json" ) ;
327+ let owner : string ;
328+ let modifiedAt : number ;
329+ try {
330+ owner = await readFile ( ownerPath , "utf8" ) ;
331+ modifiedAt = ( await lstat ( ownerPath ) ) . mtimeMs ;
332+ } catch ( error ) {
333+ if ( ( error as NodeJS . ErrnoException ) . code !== "ENOENT" ) throw error ;
334+ return {
335+ owner : undefined ,
336+ stale : Date . now ( ) - ( await lstat ( path ) ) . mtimeMs > LOCK_LEASE_MS ,
281337 } ;
282- try {
283- process . kill ( pid , 0 ) ;
338+ }
339+
340+ let identity : {
341+ pid ?: number ;
342+ ownerId ?: string ;
343+ hostname ?: string ;
344+ processStartedAt ?: number ;
345+ } ;
346+ try {
347+ identity = JSON . parse ( owner ) as typeof identity ;
348+ } catch {
349+ return { owner, stale : Date . now ( ) - modifiedAt > LOCK_LEASE_MS } ;
350+ }
351+
352+ if (
353+ typeof identity . ownerId === "string" &&
354+ typeof identity . hostname === "string" &&
355+ typeof identity . processStartedAt === "number"
356+ ) {
357+ const sameProcess =
358+ identity . pid === process . pid &&
359+ identity . hostname === hostname ( ) &&
360+ identity . processStartedAt === performance . timeOrigin ;
361+ return {
362+ owner,
363+ stale : ! sameProcess && Date . now ( ) - modifiedAt > LOCK_LEASE_MS ,
364+ } ;
365+ }
366+
367+ if (
368+ identity . pid === undefined ||
369+ ! Number . isSafeInteger ( identity . pid ) ||
370+ identity . pid < 1
371+ ) {
372+ return { owner, stale : Date . now ( ) - modifiedAt > LOCK_LEASE_MS } ;
373+ }
374+ try {
375+ process . kill ( identity . pid , 0 ) ;
376+ } catch ( error ) {
377+ if ( ( error as NodeJS . ErrnoException ) . code === "ESRCH" ) {
378+ return { owner, stale : true } ;
379+ }
380+ if ( ( error as NodeJS . ErrnoException ) . code === "EPERM" ) {
381+ return { owner, stale : false } ;
382+ }
383+ throw error ;
384+ }
385+ return {
386+ owner,
387+ stale :
388+ identity . pid === process . pid &&
389+ modifiedAt + 1_000 < performance . timeOrigin ,
390+ } ;
391+ }
392+
393+ async function recoverLock (
394+ output : string ,
395+ path : string ,
396+ expectedOwner : string | undefined ,
397+ ) : Promise < void > {
398+ const recoveryPath = join ( path , ".recovering" ) ;
399+ let claim ;
400+ try {
401+ claim = await open ( recoveryPath , "wx" , 0o600 ) ;
402+ } catch ( error ) {
403+ if ( ( error as NodeJS . ErrnoException ) . code === "EEXIST" ) {
404+ if ( Date . now ( ) - ( await lstat ( recoveryPath ) ) . mtimeMs > LOCK_LEASE_MS ) {
405+ await rm ( recoveryPath , { force : true } ) ;
406+ return await recoverLock ( output , path , expectedOwner ) ;
407+ }
408+ throw new Error ( "A multiscan supervisor is already running." ) ;
409+ }
410+ throw error ;
411+ }
412+ await claim . close ( ) ;
413+
414+ let moved = false ;
415+ try {
416+ const current = await inspectLock ( path ) ;
417+ if (
418+ current . owner !== expectedOwner ||
419+ ( expectedOwner !== undefined && ! current . stale )
420+ ) {
284421 throw new Error ( "A multiscan supervisor is already running." ) ;
285- } catch ( failure ) {
286- if ( ( failure as NodeJS . ErrnoException ) . code !== "ESRCH" ) throw failure ;
287422 }
288423 const stale = join ( output , `.lock.stale-${ randomUUID ( ) } ` ) ;
289424 await rename ( path , stale ) ;
425+ moved = true ;
290426 await rm ( stale , { recursive : true } ) ;
291- return await acquireLock ( output ) ;
427+ } finally {
428+ if ( ! moved ) await rm ( recoveryPath , { force : true } ) ;
292429 }
293- await writeFile ( ownerPath , `${ JSON . stringify ( { pid : process . pid } ) } \n` , {
294- flag : "wx" ,
295- mode : 0o600 ,
296- } ) ;
297- return async ( ) => rm ( path , { recursive : true } ) ;
298430}
299431
300432async function ensureManifest (
0 commit comments