-
Notifications
You must be signed in to change notification settings - Fork 179
fix(lockutil): non-destructive live-lock restoration under O_CREATE|O_EXCL #954
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,29 +6,10 @@ import ( | |
| ) | ||
|
|
||
| // restoreLockFile is swappable so tests can force the fail-closed path of | ||
| // ReclaimStaleLock, which requires both the fast restore and its no-replace | ||
| // fallback (with its own copy fallback) to fail; that cannot be provoked | ||
| // portably on a healthy filesystem. | ||
| var restoreLockFile = restoreLiveLock | ||
|
|
||
| // restoreLiveLock puts a lock that turned out to be live back at path after | ||
| // ReclaimStaleLock moved it aside to inspect it. It first tries a fast, | ||
| // replacing rename straight from reclaimed to path: a single syscall, which | ||
| // keeps the window during which path does not exist (and so is open to a | ||
| // fourth process's unrelated O_EXCL create landing on it) as short as | ||
| // possible. RestoreLockFile's no-replace restore (with its slower copy | ||
| // fallback on some failures) is a correctness-preserving fallback for when | ||
| // the fast path itself fails (e.g. a cross-device sidelined name): it is a | ||
| // much longer version of the identical race, but still detects rather than | ||
| // silently clobbers a competing lock, which the fast path's replacing rename | ||
| // cannot do. Neither path makes the race impossible, only unlikely; see | ||
| // ReclaimStaleLock's doc comment. | ||
| func restoreLiveLock(reclaimed, path string) error { | ||
| if err := os.Rename(reclaimed, path); err == nil { | ||
| return nil | ||
| } | ||
| return RestoreLockFile(reclaimed, path) | ||
| } | ||
| // ReclaimStaleLock, which requires both the primary no-replace restore and its | ||
| // copy fallback to fail; that cannot be provoked portably on a healthy | ||
| // filesystem. | ||
| var restoreLockFile = RestoreLockFile | ||
|
Comment on lines
+9
to
+12
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🔴 Critical | 🏗️ Heavy lift Do not allow a new claimant while a live lock is sidelined.
Later, Keep acquisition blocked through live-lock recovery with an atomic recovery fence that every claimant observes. Update As per coding guidelines, fail closed on ownership and lease checks and serialize the full read-modify-write sequence for lockfiles. 🤖 Prompt for AI AgentsSource: Coding guidelines |
||
|
|
||
| // ReclaimStaleLock atomically reclaims a suspected-stale lock file. It renames | ||
| // lockPath aside to "<lockPath>.stale.<suffix>" (only one racer can win the | ||
|
|
@@ -41,24 +22,21 @@ func restoreLiveLock(reclaimed, path string) error { | |
| // a lost race it returns false. A non-nil error means either the rename aside | ||
| // failed for a reason that is not contention (so retrying cannot help and the | ||
| // caller should fail fast instead of spinning to its deadline), or a live | ||
| // holder's lock could not be restored (both restoreLiveLock's fast path and | ||
| // its no-replace fallback failed), so lockPath may be missing; callers must | ||
| // fail closed instead of re-acquiring. The sidelined file is removed on every | ||
| // restore failure: once the restore has failed it has no protocol function | ||
| // (release only consults the lock path), so keeping it would only leak files. | ||
| // holder's lock could not be restored (both RestoreLockFile's primary | ||
| // no-replace primitive and its copy fallback failed), so lockPath may be | ||
| // missing; callers must fail closed instead of re-acquiring. The sidelined | ||
| // file is removed on every restore failure or when a new holder wins: once the | ||
| // restore has completed or failed it has no protocol function (release only | ||
| // consults the lock path), so keeping it would only leak files. | ||
| // | ||
| // The live-restore path has an inherent, unclosed race: between the rename | ||
| // aside above and restoreLiveLock putting the lock back, lockPath does not | ||
| // exist, so an unrelated caller's O_EXCL create can legitimately succeed | ||
| // there. restoreLiveLock's fast path then silently overwrites that new | ||
| // claimant's lock file, which does not corrupt release (it is | ||
| // ownership-aware, so the new claimant's later release safely no-ops against | ||
| // content it no longer owns) but does mean the new claimant can still run | ||
| // its critical section concurrently with the original live holder. Making | ||
| // this race actually impossible would need an OS-level advisory lock (flock | ||
| // / LockFileEx) held for a holder's whole critical section, checked | ||
| // non-destructively instead of by moving the file; restoreLiveLock only | ||
| // shrinks the window to roughly one syscall, it does not close it. | ||
| // When a live lock is restored, RestoreLockFile uses no-replace semantics | ||
| // (os.Link on POSIX, MoveFileEx without REPLACE_EXISTING on Windows, or an | ||
| // O_EXCL copy fallback). If an unrelated caller's O_EXCL create succeeded at | ||
| // lockPath in the window between the rename aside and the restore, the | ||
| // restore fails with os.ErrExist rather than silently clobbering that new | ||
| // claimant's lock file. ReclaimStaleLock treats this as a lost race (returns | ||
| // false, nil) and cleans up the sidelined file, preventing two concurrent | ||
| // holders from running simultaneously. | ||
| func ReclaimStaleLock(lockPath, suffix string, isLive func(reclaimedPath string) bool) (bool, error) { | ||
| reclaimed := lockPath + ".stale." + suffix | ||
| if err := os.Rename(lockPath, reclaimed); err != nil { | ||
|
|
@@ -71,7 +49,7 @@ func ReclaimStaleLock(lockPath, suffix string, isLive func(reclaimedPath string) | |
| // Put the live lock back instead of stealing it, and let the caller wait. | ||
| if rerr := restoreLockFile(reclaimed, lockPath); rerr != nil { | ||
| _ = RemoveLockFile(reclaimed) | ||
| if !errors.Is(rerr, os.ErrExist) { | ||
| if !errors.Is(rerr, os.ErrExist) && !errors.Is(rerr, os.ErrNotExist) && !isReclaimContended(rerr) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win Propagate cleanup failure on the benign-race path. When restoration returns a newly classified benign error, Line 51 ignores Treat only an explicitly confirmed already-removed result as successful cleanup. Return other cleanup errors. As per coding guidelines, never report success when cleanup or unlock failed. 🤖 Prompt for AI AgentsSource: Coding guidelines 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift 🔎 Supported by static analysis🏁 Script executed: #!/bin/bash
set -euo pipefail
rg -n -A45 -B5 \
'func RestoreLockFile|func restoreByCopy|func RemoveLockFile' \
internal/lockutilRepository: Gitlawb/zero Length of output: 11094 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- repository conventions ---'
head -5 /tmp/coderabbit-repo-knowledge/gitlawb-zero-238a126d/*/*.md 2>/dev/null || true
printf '%s\n' '--- reclaim implementation ---'
cat -n internal/lockutil/reclaim.go
printf '%s\n' '--- lockutil definitions and tests ---'
cat -n internal/lockutil/lockutil.go
rg -n -A80 -B10 'RestoreLockFile|ReclaimStaleLock|restoreByCopy|isReclaimContended' internal/lockutil --glob '*_test.go'Repository: Gitlawb/zero Length of output: 45279 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- lockutil learning ---'
cat /tmp/coderabbit-repo-knowledge/gitlawb-zero-238a126d/learnings/internal-lockutil.md
printf '%s\n' '--- lockutil callers ---'
rg -n -A12 -B8 'ReclaimStaleLock|RestoreLockFile|RemoveLockFile' --glob '*.go' --glob '!internal/lockutil/*'
printf '%s\n' '--- lockutil platform declarations ---'
cat -n internal/lockutil/lockutil_other.go
cat -n internal/lockutil/lockutil_windows.goRepository: Gitlawb/zero Length of output: 1039 Fail closed when live-lock restoration returns
🤖 Prompt for AI Agents |
||
| return false, rerr | ||
| } | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift
Publish a complete lock payload atomically.
If a process stops after
os.OpenFile(...O_EXCL...)succeeds and before it writes the PID, it leaves an empty lock file. Lines 70-76 treat that file as live for 30 seconds, and the two-pass loop returnsErrAlreadyRunningalthough no process holds the lock.Write the payload to a temporary file. Then install it with a non-replacing atomic operation that preserves exclusive acquisition semantics.
As per coding guidelines, write a complete temporary file, then atomically replace the destination so concurrent readers never see a partial write.
🤖 Prompt for AI Agents
Source: Coding guidelines