feat: add lock-mgr CLI tool and wire into install script

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
h z
2026-04-12 21:41:51 +01:00
parent 525436d64b
commit 311d9f4d9f
3 changed files with 326 additions and 1 deletions

314
lock-mgr/main.go Normal file
View File

@@ -0,0 +1,314 @@
package main
import (
"crypto/rand"
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
)
type LockEntry struct {
Locked bool `json:"locked"`
Key string `json:"key,omitempty"`
Time string `json:"time,omitempty"`
}
type LockFile map[string]*LockEntry
func generateUUID() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
b[6] = (b[6] & 0x0f) | 0x40 // version 4
b[8] = (b[8] & 0x3f) | 0x80 // variant bits
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]), nil
}
func readLockFile(path string) (LockFile, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return make(LockFile), nil
}
return nil, fmt.Errorf("read lock file: %w", err)
}
if len(data) == 0 {
return make(LockFile), nil
}
var lf LockFile
if err := json.Unmarshal(data, &lf); err != nil {
return make(LockFile), nil
}
if lf == nil {
return make(LockFile), nil
}
return lf, nil
}
func writeLockFile(path string, lf LockFile) error {
data, err := json.MarshalIndent(lf, "", " ")
if err != nil {
return fmt.Errorf("marshal lock file: %w", err)
}
return os.WriteFile(path, data, 0644)
}
// acquireMgrLock implements steps 3-8: acquire the meta-lock on the JSON file itself.
// Returns the mgr-key on success.
func acquireMgrLock(mgrPath string) (string, error) {
// Step 3: generate mgr-key once
mgrKey, err := generateUUID()
if err != nil {
return "", err
}
for {
// Step 4: record current time
startTime := time.Now()
// Steps 5-6: spin until the meta-lock is free or owned by us
for {
lf, err := readLockFile(mgrPath)
if err != nil {
return "", err
}
entry := lf[mgrPath]
if entry == nil || !entry.Locked || entry.Key == mgrKey {
break
}
if time.Since(startTime) > 5*time.Second {
return "", fmt.Errorf("timeout waiting to acquire manager lock")
}
time.Sleep(100 * time.Millisecond)
}
// Step 7: write locked=true, key=mgrKey
lf, err := readLockFile(mgrPath)
if err != nil {
return "", err
}
if lf[mgrPath] == nil {
lf[mgrPath] = &LockEntry{}
}
lf[mgrPath].Locked = true
lf[mgrPath].Key = mgrKey
if err := writeLockFile(mgrPath, lf); err != nil {
return "", err
}
// Step 8: verify we actually won the race
lf2, err := readLockFile(mgrPath)
if err != nil {
return "", err
}
if lf2[mgrPath] != nil && lf2[mgrPath].Key == mgrKey {
return mgrKey, nil
}
// Lost the race; go back to step 4 (keep same mgrKey, reset timer)
}
}
// releaseMgrLock removes the meta-lock entry and writes the file.
func releaseMgrLock(mgrPath string) error {
lf, err := readLockFile(mgrPath)
if err != nil {
return err
}
delete(lf, mgrPath)
return writeLockFile(mgrPath, lf)
}
func cmdAcquire(mgrPath, filePath string) error {
// Steps 3-8: acquire meta-lock
if _, err := acquireMgrLock(mgrPath); err != nil {
return err
}
// Step 9: record start time for file-lock wait timeout
startTime := time.Now()
for {
// Step 10: read current file lock state
lf, err := readLockFile(mgrPath)
if err != nil {
_ = releaseMgrLock(mgrPath)
return err
}
entry := lf[filePath]
xLocked := entry != nil && entry.Locked
xKey := ""
if entry != nil {
xKey = entry.Key
}
envKey := os.Getenv("LOCK_MGR_KEY")
// Step 11: if locked by someone else, wait and retry
if xLocked && xKey != envKey {
if time.Since(startTime) > 30*time.Second {
_ = releaseMgrLock(mgrPath)
return fmt.Errorf("timeout waiting to acquire lock on %s", filePath)
}
// Release meta-lock while sleeping so others can proceed
if err := releaseMgrLock(mgrPath); err != nil {
return err
}
time.Sleep(1 * time.Second)
// Re-acquire meta-lock before next read
if _, err := acquireMgrLock(mgrPath); err != nil {
return err
}
continue
}
// Step 12: not locked (or already owned by us) — acquire
var newKey string
if envKey != "" {
newKey = envKey
} else {
newKey, err = generateUUID()
if err != nil {
_ = releaseMgrLock(mgrPath)
return err
}
}
if lf[filePath] == nil {
lf[filePath] = &LockEntry{}
}
lf[filePath].Locked = true
lf[filePath].Key = newKey
lf[filePath].Time = time.Now().UTC().Format(time.RFC3339)
// Step 13: delete meta-lock entry and write atomically
delete(lf, mgrPath)
if err := writeLockFile(mgrPath, lf); err != nil {
return err
}
fmt.Println(newKey)
return nil
}
}
func cmdRelease(mgrPath, filePath string) error {
// Steps 3-8: acquire meta-lock
if _, err := acquireMgrLock(mgrPath); err != nil {
return err
}
lf, err := readLockFile(mgrPath)
if err != nil {
_ = releaseMgrLock(mgrPath)
return err
}
entry := lf[filePath]
xLocked := entry != nil && entry.Locked
xKey := ""
if entry != nil {
xKey = entry.Key
}
envKey := os.Getenv("LOCK_MGR_KEY")
// Step 14: validate preconditions
if envKey == "" {
_ = releaseMgrLock(mgrPath)
return fmt.Errorf("LOCK_MGR_KEY environment variable not set")
}
if !xLocked {
_ = releaseMgrLock(mgrPath)
return fmt.Errorf("file %s is not locked", filePath)
}
if xKey != envKey {
_ = releaseMgrLock(mgrPath)
return fmt.Errorf("LOCK_MGR_KEY does not match the lock key for %s", filePath)
}
// Step 15: delete file lock and meta-lock, write
delete(lf, filePath)
delete(lf, mgrPath)
return writeLockFile(mgrPath, lf)
}
func cmdForceUnlock(mgrPath, filePath string) error {
// Steps 3-8: acquire meta-lock
if _, err := acquireMgrLock(mgrPath); err != nil {
return err
}
lf, err := readLockFile(mgrPath)
if err != nil {
_ = releaseMgrLock(mgrPath)
return err
}
// Remove file lock and meta-lock unconditionally
delete(lf, filePath)
delete(lf, mgrPath)
return writeLockFile(mgrPath, lf)
}
func main() {
if len(os.Args) < 3 {
fmt.Fprintln(os.Stderr, "Usage: lock-mgr <acquire|release|force-unlock> <file>")
os.Exit(1)
}
action := os.Args[1]
fileArg := os.Args[2]
// Step 1: resolve tool directory and locate (or create) the lock file
execPath, err := os.Executable()
if err != nil {
fmt.Fprintf(os.Stderr, "error: cannot determine executable path: %v\n", err)
os.Exit(1)
}
toolDir := filepath.Dir(execPath)
rawMgrPath := filepath.Join(toolDir, "..", ".lock-mgr.json")
// Step 2: get absolute paths
mgrPath, err := filepath.Abs(rawMgrPath)
if err != nil {
fmt.Fprintf(os.Stderr, "error: cannot resolve lock file path: %v\n", err)
os.Exit(1)
}
filePath, err := filepath.Abs(fileArg)
if err != nil {
fmt.Fprintf(os.Stderr, "error: cannot resolve file path: %v\n", err)
os.Exit(1)
}
// Ensure lock file exists
if _, statErr := os.Stat(mgrPath); os.IsNotExist(statErr) {
if writeErr := os.WriteFile(mgrPath, []byte("{}"), 0644); writeErr != nil {
fmt.Fprintf(os.Stderr, "error: cannot create lock file: %v\n", writeErr)
os.Exit(1)
}
}
switch action {
case "acquire":
err = cmdAcquire(mgrPath, filePath)
case "release":
err = cmdRelease(mgrPath, filePath)
case "force-unlock":
err = cmdForceUnlock(mgrPath, filePath)
default:
fmt.Fprintf(os.Stderr, "error: unknown action %q\n", action)
fmt.Fprintln(os.Stderr, "Usage: lock-mgr <acquire|release|force-unlock> <file>")
os.Exit(1)
}
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
}