@@ -3,6 +3,7 @@ import fsSync from "node:fs";
33import fs from "node:fs/promises" ;
44import os from "node:os" ;
55import path from "node:path" ;
6+ import { fileURLToPath } from "node:url" ;
67import { MAX_TIMER_TIMEOUT_MS } from "@openclaw/normalization-core/number-coercion" ;
78import { afterEach , beforeAll , describe , expect , it , vi } from "vitest" ;
89import { SessionWriteLockStaleError } from "./session-write-lock-error.js" ;
@@ -87,6 +88,19 @@ async function writeCurrentProcessLock(lockPath: string, extra?: Record<string,
8788 ) ;
8889}
8990
91+ function readFilePathToString ( filePath : Parameters < typeof fs . readFile > [ 0 ] ) : string | undefined {
92+ if ( typeof filePath === "string" ) {
93+ return filePath ;
94+ }
95+ if ( Buffer . isBuffer ( filePath ) ) {
96+ return filePath . toString ( "utf8" ) ;
97+ }
98+ if ( filePath instanceof URL ) {
99+ return fileURLToPath ( filePath ) ;
100+ }
101+ return undefined ;
102+ }
103+
90104async function withSymlinkedSessionPaths (
91105 run : ( params : {
92106 sessionReal : string ;
@@ -453,6 +467,157 @@ describe("acquireSessionWriteLock", () => {
453467 } ) ;
454468 } ) ;
455469
470+ it ( "retries when a stale lock report disappears before diagnostics" , async ( ) => {
471+ await withTempSessionLockFile ( async ( { sessionFile, lockPath } ) => {
472+ const owner = spawn ( process . execPath , [ "-e" , "setInterval(() => {}, 1000)" , "openclaw" ] , {
473+ stdio : "ignore" ,
474+ } ) ;
475+ if ( ! owner . pid ) {
476+ throw new Error ( "missing lock owner pid" ) ;
477+ }
478+ await fs . writeFile (
479+ lockPath ,
480+ JSON . stringify ( {
481+ pid : owner . pid ,
482+ createdAt : new Date ( Date . now ( ) - 120_000 ) . toISOString ( ) ,
483+ } ) ,
484+ "utf8" ,
485+ ) ;
486+
487+ const originalReadFile = fs . readFile . bind ( fs ) ;
488+ let lockReads = 0 ;
489+ const readFileSpy = vi . spyOn ( fs , "readFile" ) . mockImplementation ( ( async (
490+ filePath ,
491+ options ,
492+ ) => {
493+ const lockFilePath = readFilePathToString ( filePath ) ;
494+ if ( lockFilePath && path . basename ( lockFilePath ) === path . basename ( lockPath ) ) {
495+ lockReads += 1 ;
496+ if ( lockReads === 3 ) {
497+ await fs . rm ( lockFilePath , { force : true } ) ;
498+ await fs . rm ( lockPath , { force : true } ) ;
499+ throw Object . assign ( new Error ( "lock disappeared" ) , { code : "ENOENT" } ) ;
500+ }
501+ }
502+ return await originalReadFile ( filePath , options as never ) ;
503+ } ) as typeof fs . readFile ) ;
504+
505+ try {
506+ const lock = await acquireSessionWriteLock ( { sessionFile, timeoutMs : 500 , staleMs : 10 } ) ;
507+ await lock . release ( ) ;
508+ expect ( lockReads ) . toBeGreaterThanOrEqual ( 3 ) ;
509+ await expectPathMissing ( lockPath ) ;
510+ } finally {
511+ readFileSpy . mockRestore ( ) ;
512+ owner . kill ( "SIGTERM" ) ;
513+ }
514+ } ) ;
515+ } ) ;
516+
517+ it ( "retries when a stale lock report is replaced before diagnostics" , async ( ) => {
518+ await withTempSessionLockFile ( async ( { sessionFile, lockPath } ) => {
519+ const owner = spawn ( process . execPath , [ "-e" , "setInterval(() => {}, 1000)" , "openclaw" ] , {
520+ stdio : "ignore" ,
521+ } ) ;
522+ if ( ! owner . pid ) {
523+ throw new Error ( "missing lock owner pid" ) ;
524+ }
525+ await fs . writeFile (
526+ lockPath ,
527+ JSON . stringify ( {
528+ pid : owner . pid ,
529+ createdAt : new Date ( Date . now ( ) - 120_000 ) . toISOString ( ) ,
530+ } ) ,
531+ "utf8" ,
532+ ) ;
533+
534+ const originalReadFile = fs . readFile . bind ( fs ) ;
535+ let lockReads = 0 ;
536+ const readFileSpy = vi . spyOn ( fs , "readFile" ) . mockImplementation ( ( async (
537+ filePath ,
538+ options ,
539+ ) => {
540+ const lockFilePath = readFilePathToString ( filePath ) ;
541+ if ( lockFilePath && path . basename ( lockFilePath ) === path . basename ( lockPath ) ) {
542+ lockReads += 1 ;
543+ if ( lockReads === 3 ) {
544+ await fs . rm ( lockFilePath , { force : true } ) ;
545+ await fs . rm ( lockPath , { force : true } ) ;
546+ await fs . writeFile (
547+ lockFilePath ,
548+ JSON . stringify ( { pid : owner . pid , createdAt : new Date ( ) . toISOString ( ) } ) ,
549+ "utf8" ,
550+ ) ;
551+ setTimeout ( ( ) => {
552+ void fs . rm ( lockFilePath , { force : true } ) ;
553+ } , 10 ) ;
554+ throw Object . assign ( new Error ( "lock disappeared" ) , { code : "ENOENT" } ) ;
555+ }
556+ }
557+ return await originalReadFile ( filePath , options as never ) ;
558+ } ) as typeof fs . readFile ) ;
559+
560+ try {
561+ const lock = await acquireSessionWriteLock ( { sessionFile, timeoutMs : 500 , staleMs : 10 } ) ;
562+ await lock . release ( ) ;
563+ expect ( lockReads ) . toBeGreaterThanOrEqual ( 3 ) ;
564+ await expectPathMissing ( lockPath ) ;
565+ } finally {
566+ readFileSpy . mockRestore ( ) ;
567+ owner . kill ( "SIGTERM" ) ;
568+ }
569+ } ) ;
570+ } ) ;
571+
572+ it ( "retries when a stale lock report is replaced by a fresh payload-less lock" , async ( ) => {
573+ await withTempSessionLockFile ( async ( { sessionFile, lockPath } ) => {
574+ const owner = spawn ( process . execPath , [ "-e" , "setInterval(() => {}, 1000)" , "openclaw" ] , {
575+ stdio : "ignore" ,
576+ } ) ;
577+ if ( ! owner . pid ) {
578+ throw new Error ( "missing lock owner pid" ) ;
579+ }
580+ await fs . writeFile (
581+ lockPath ,
582+ JSON . stringify ( {
583+ pid : owner . pid ,
584+ createdAt : new Date ( Date . now ( ) - 120_000 ) . toISOString ( ) ,
585+ } ) ,
586+ "utf8" ,
587+ ) ;
588+
589+ const originalReadFile = fs . readFile . bind ( fs ) ;
590+ let lockReads = 0 ;
591+ const readFileSpy = vi . spyOn ( fs , "readFile" ) . mockImplementation ( ( async (
592+ filePath ,
593+ options ,
594+ ) => {
595+ const lockFilePath = readFilePathToString ( filePath ) ;
596+ if ( lockFilePath && path . basename ( lockFilePath ) === path . basename ( lockPath ) ) {
597+ lockReads += 1 ;
598+ if ( lockReads === 3 ) {
599+ await fs . rm ( lockFilePath , { force : true } ) ;
600+ await fs . writeFile ( lockFilePath , "" , "utf8" ) ;
601+ setTimeout ( ( ) => {
602+ void fs . rm ( lockFilePath , { force : true } ) ;
603+ } , 10 ) ;
604+ }
605+ }
606+ return await originalReadFile ( filePath , options as never ) ;
607+ } ) as typeof fs . readFile ) ;
608+
609+ try {
610+ const lock = await acquireSessionWriteLock ( { sessionFile, timeoutMs : 800 , staleMs : 10 } ) ;
611+ await lock . release ( ) ;
612+ expect ( lockReads ) . toBeGreaterThanOrEqual ( 3 ) ;
613+ await expectPathMissing ( lockPath ) ;
614+ } finally {
615+ readFileSpy . mockRestore ( ) ;
616+ owner . kill ( "SIGTERM" ) ;
617+ }
618+ } ) ;
619+ } ) ;
620+
456621 it ( "watchdog releases stale in-process locks" , async ( ) => {
457622 const root = await fs . mkdtemp ( path . join ( os . tmpdir ( ) , "openclaw-lock-" ) ) ;
458623 const stderrSpy = vi . spyOn ( process . stderr , "write" ) . mockImplementation ( ( ) => true ) ;
@@ -600,6 +765,14 @@ describe("acquireSessionWriteLock", () => {
600765 } ) ;
601766 } ) ;
602767
768+ it ( "preserves one acquire timeout budget across retries" , ( ) => {
769+ expect ( testing . resolveRemainingAcquireTimeoutMs ( 500 , 1_000 , 1_125 ) ) . toBe ( 375 ) ;
770+ expect ( testing . resolveRemainingAcquireTimeoutMs ( 500 , 1_000 , 1_500 ) ) . toBe ( 0 ) ;
771+ expect ( testing . resolveRemainingAcquireTimeoutMs ( Number . POSITIVE_INFINITY , 1_000 , 9_000 ) ) . toBe (
772+ Number . POSITIVE_INFINITY ,
773+ ) ;
774+ } ) ;
775+
603776 it ( "uses resolved stale policy when cleaning stale lock files" , async ( ) => {
604777 const root = await fs . mkdtemp ( path . join ( os . tmpdir ( ) , "openclaw-lock-policy-" ) ) ;
605778 const sessionsDir = path . join ( root , "sessions" ) ;
0 commit comments