@@ -2,8 +2,11 @@ package lockutil
22
33import (
44 "errors"
5+ "fmt"
56 "os"
67 "path/filepath"
8+ "sync"
9+ "sync/atomic"
710 "testing"
811)
912
@@ -41,13 +44,12 @@ func TestReclaimStaleLock(t *testing.T) {
4144 }
4245}
4346
44- // restoreLiveLock's fast path is a single replacing rename, chosen to
45- // minimize (not eliminate) the window during which lockPath is absent and
46- // open to an unrelated O_EXCL create; see ReclaimStaleLock's doc comment for
47- // why that residual race exists. This test pins the resulting behavior: the
48- // fast path overwrites a competing file at path rather than detecting it,
49- // unlike RestoreLockFile's own no-replace contract.
50- func TestRestoreLiveLockFastPathOverwritesCompetingFile (t * testing.T ) {
47+ // Restoring a live lock uses no-replace semantics (RestoreLockFile).
48+ // If a new claimant created lockPath in the gap between rename-aside and restore,
49+ // the restore must NOT overwrite the new claimant's lock file; instead the restore
50+ // fails with os.ErrExist, the new claimant's lock is preserved intact, and ReclaimStaleLock
51+ // cleans up the sidelined file and reports a lost race (ok=false, err=nil).
52+ func TestRestoreLiveLockPreservesCompetingHolder (t * testing.T ) {
5153 dir := t .TempDir ()
5254 reclaimed := filepath .Join (dir , "lock.stale.tok" )
5355 path := filepath .Join (dir , "lock" )
@@ -58,15 +60,16 @@ func TestRestoreLiveLockFastPathOverwritesCompetingFile(t *testing.T) {
5860 if err := os .WriteFile (path , []byte ("new-claimant" ), 0o600 ); err != nil {
5961 t .Fatal (err )
6062 }
61- if err := restoreLiveLock (reclaimed , path ); err != nil {
62- t .Fatalf ("restoreLiveLock failed: %v" , err )
63+ err := RestoreLockFile (reclaimed , path )
64+ if ! errors .Is (err , os .ErrExist ) {
65+ t .Fatalf ("RestoreLockFile on existing destination = %v, want os.ErrExist" , err )
6366 }
6467 data , err := os .ReadFile (path )
65- if err != nil || string (data ) != "original-holder " {
66- t .Fatalf ("path = %q, err %v; want the original holder 's content restored " , data , err )
68+ if err != nil || string (data ) != "new-claimant " {
69+ t .Fatalf ("path = %q, err %v; want the new claimant 's content preserved intact " , data , err )
6770 }
68- if _ , err := os .Stat (reclaimed ); ! errors . Is ( err , os . ErrNotExist ) {
69- t .Fatalf ("expected reclaimed to be consumed by the rename : %v" , err )
71+ if _ , err := os .Stat (reclaimed ); err != nil {
72+ t .Fatalf ("expected reclaimed file to still exist after failed restore : %v" , err )
7073 }
7174}
7275
@@ -76,7 +79,7 @@ func TestReclaimStaleLockFailsClosedOnRestoreError(t *testing.T) {
7679 // caller must receive an error so it fails closed instead of re-acquiring a
7780 // missing lock path, and the sidelined file must not leak.
7881 restoreLockFile = func (reclaimed , path string ) error { return errors .New ("restore failed" ) }
79- defer func () { restoreLockFile = restoreLiveLock }()
82+ defer func () { restoreLockFile = RestoreLockFile }()
8083
8184 lockPath := filepath .Join (t .TempDir (), "lock" )
8285 if err := os .WriteFile (lockPath , []byte ("live-holder" ), 0o600 ); err != nil {
@@ -96,7 +99,7 @@ func TestReclaimStaleLockDropsSidelinedWhenNewHolderWins(t *testing.T) {
9699 // path; that is not an error for the caller, and the sidelined file is
97100 // dropped rather than leaked.
98101 restoreLockFile = func (reclaimed , path string ) error { return os .ErrExist }
99- defer func () { restoreLockFile = restoreLiveLock }()
102+ defer func () { restoreLockFile = RestoreLockFile }()
100103
101104 lockPath := filepath .Join (t .TempDir (), "lock" )
102105 if err := os .WriteFile (lockPath , []byte ("live-holder" ), 0o600 ); err != nil {
@@ -110,3 +113,148 @@ func TestReclaimStaleLockDropsSidelinedWhenNewHolderWins(t *testing.T) {
110113 t .Fatalf ("the sidelined file must be dropped when a new holder wins: %v" , matches )
111114 }
112115}
116+
117+ func TestReclaimStaleLockConcurrentDeadReclaim (t * testing.T ) {
118+ const goroutines = 24
119+ dir := t .TempDir ()
120+ lockPath := filepath .Join (dir , "lock" )
121+
122+ if err := os .WriteFile (lockPath , []byte ("crashed-process" ), 0o600 ); err != nil {
123+ t .Fatal (err )
124+ }
125+
126+ var wg sync.WaitGroup
127+ var reclaimWins atomic.Int64
128+ var errCount atomic.Int64
129+
130+ start := make (chan struct {})
131+ for i := 0 ; i < goroutines ; i ++ {
132+ wg .Add (1 )
133+ go func (id int ) {
134+ defer wg .Done ()
135+ <- start
136+ ok , err := ReclaimStaleLock (lockPath , fmt .Sprintf ("tok-%d" , id ), func (string ) bool {
137+ return false // dead
138+ })
139+ if err != nil {
140+ errCount .Add (1 )
141+ return
142+ }
143+ if ok {
144+ reclaimWins .Add (1 )
145+ }
146+ }(i )
147+ }
148+
149+ close (start )
150+ wg .Wait ()
151+
152+ if errs := errCount .Load (); errs != 0 {
153+ t .Fatalf ("unexpected errors during concurrent dead reclaim: %d" , errs )
154+ }
155+ if wins := reclaimWins .Load (); wins != 1 {
156+ t .Fatalf ("expected exactly 1 winner for dead lock reclaim, got %d" , wins )
157+ }
158+ if _ , err := os .Stat (lockPath ); ! errors .Is (err , os .ErrNotExist ) {
159+ t .Fatalf ("expected lock file to be removed, stat = %v" , err )
160+ }
161+ if matches , _ := filepath .Glob (lockPath + ".stale.*" ); len (matches ) != 0 {
162+ t .Fatalf ("leaked sidelined files: %v" , matches )
163+ }
164+ }
165+
166+ func TestReclaimStaleLockConcurrentLiveRestoration (t * testing.T ) {
167+ const goroutines = 24
168+ dir := t .TempDir ()
169+ lockPath := filepath .Join (dir , "lock" )
170+
171+ if err := os .WriteFile (lockPath , []byte ("live-holder-token" ), 0o600 ); err != nil {
172+ t .Fatal (err )
173+ }
174+
175+ var wg sync.WaitGroup
176+ var reclaimWins atomic.Int64
177+ var errCount atomic.Int64
178+
179+ start := make (chan struct {})
180+ for i := 0 ; i < goroutines ; i ++ {
181+ wg .Add (1 )
182+ go func (id int ) {
183+ defer wg .Done ()
184+ <- start
185+ ok , err := ReclaimStaleLock (lockPath , fmt .Sprintf ("tok-%d" , id ), func (string ) bool {
186+ return true // live
187+ })
188+ if err != nil {
189+ errCount .Add (1 )
190+ return
191+ }
192+ if ok {
193+ reclaimWins .Add (1 )
194+ }
195+ }(i )
196+ }
197+
198+ close (start )
199+ wg .Wait ()
200+
201+ if errs := errCount .Load (); errs != 0 {
202+ t .Fatalf ("unexpected errors during concurrent live restore: %d" , errs )
203+ }
204+ if wins := reclaimWins .Load (); wins != 0 {
205+ t .Fatalf ("expected 0 winners for live lock reclaim, got %d" , wins )
206+ }
207+ data , err := os .ReadFile (lockPath )
208+ if err != nil || string (data ) != "live-holder-token" {
209+ t .Fatalf ("expected live lock file intact at %q, got %q (err %v)" , lockPath , data , err )
210+ }
211+ if matches , _ := filepath .Glob (lockPath + ".stale.*" ); len (matches ) != 0 {
212+ t .Fatalf ("leaked sidelined files: %v" , matches )
213+ }
214+ }
215+
216+ func TestReclaimStaleLockRaceWithNewClaimant (t * testing.T ) {
217+ // Exercise the exact race condition:
218+ // A live lock is sidelined for inspection.
219+ // Before restore completes, an O_EXCL claimant creates lockPath.
220+ // The restore must NOT overwrite the new claimant.
221+ dir := t .TempDir ()
222+ lockPath := filepath .Join (dir , "lock" )
223+
224+ if err := os .WriteFile (lockPath , []byte ("original-live-holder" ), 0o600 ); err != nil {
225+ t .Fatal (err )
226+ }
227+
228+ ok , err := ReclaimStaleLock (lockPath , "race-tok" , func (reclaimedPath string ) bool {
229+ // Inside isLive callback (file is currently sidelined to reclaimedPath):
230+ // An unrelated process creates a new lock at lockPath via O_EXCL.
231+ f , err := os .OpenFile (lockPath , os .O_CREATE | os .O_EXCL | os .O_WRONLY , 0o600 )
232+ if err != nil {
233+ t .Errorf ("new claimant OpenFile failed: %v" , err )
234+ return false
235+ }
236+ if _ , err := f .WriteString ("new-concurrent-claimant" ); err != nil {
237+ t .Errorf ("new claimant WriteString failed: %v" , err )
238+ }
239+ _ = f .Close ()
240+ return true // original holder was also live
241+ })
242+
243+ if err != nil {
244+ t .Fatalf ("ReclaimStaleLock should handle ErrExist cleanly, got err: %v" , err )
245+ }
246+ if ok {
247+ t .Fatalf ("ReclaimStaleLock should return ok=false when live holder lost restore race, got ok=true" )
248+ }
249+
250+ // Verify the new claimant's file was NOT overwritten
251+ data , err := os .ReadFile (lockPath )
252+ if err != nil || string (data ) != "new-concurrent-claimant" {
253+ t .Fatalf ("lock file was overwritten or corrupted: got %q (err %v), want %q" , data , err , "new-concurrent-claimant" )
254+ }
255+
256+ // Verify the sidelined file was cleaned up and not leaked
257+ if matches , _ := filepath .Glob (lockPath + ".stale.*" ); len (matches ) != 0 {
258+ t .Fatalf ("sidelined file leaked after race: %v" , matches )
259+ }
260+ }
0 commit comments