Skip to content

Commit bf2f6e3

Browse files
committed
fix(cli): serialize self-update per target with an OS-level advisory lock
Related #957 Two concurrent 'mcpproxy update' runs could read each other's mid-swap sentinel state, misclassify it, and delete the only known-good backup. The whole recover-and-swap sequence now runs under an exclusive non-blocking flock (LockFileEx on Windows) on <target>.update-lock; the second invocation fails fast with 'another update is already in progress'. The lock file is never unlinked (unlink+relock races two holders onto different inodes); the kernel drops the lock with the process, so a crash cannot wedge future updates.
1 parent 026f268 commit bf2f6e3

5 files changed

Lines changed: 154 additions & 0 deletions

File tree

cmd/mcpproxy/update_apply.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,16 @@ func applyNewBinary(target, staged string, verify func(path string) error) (err
228228
backup := target + ".old"
229229
sentinel := target + swapSentinelSuffix
230230

231+
// The whole recover-and-swap sequence runs under an exclusive per-target
232+
// lock: a concurrent invocation reading a sibling's sentinel/backup state
233+
// mid-swap would misclassify it and could delete the only known-good
234+
// backup. Crash-safe (the kernel releases it with the process).
235+
release, lockErr := acquireUpdateLock(target)
236+
if lockErr != nil {
237+
return lockErr
238+
}
239+
defer release()
240+
231241
if recoverErr := recoverInterruptedSwap(target, backup, sentinel, verify); recoverErr != nil {
232242
return recoverErr
233243
}

cmd/mcpproxy/update_apply_test.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -611,3 +611,54 @@ func TestReportsVersion(t *testing.T) {
611611
}
612612
}
613613
}
614+
615+
func TestAcquireUpdateLock_SecondHolderIsRefused(t *testing.T) {
616+
target := filepath.Join(t.TempDir(), "mcpproxy")
617+
618+
release, err := acquireUpdateLock(target)
619+
if err != nil {
620+
t.Fatalf("first acquire: %v", err)
621+
}
622+
// flock/LockFileEx conflict even between two opens in the same process,
623+
// so this stands in for a concurrent `mcpproxy update` invocation.
624+
if _, err := acquireUpdateLock(target); !errors.Is(err, errUpdateInProgress) {
625+
t.Fatalf("second acquire: want errUpdateInProgress, got %v", err)
626+
}
627+
628+
release()
629+
release2, err := acquireUpdateLock(target)
630+
if err != nil {
631+
t.Fatalf("re-acquire after release: %v", err)
632+
}
633+
release2()
634+
}
635+
636+
func TestApplyNewBinary_RefusedWhileAnotherSwapHoldsTheLock(t *testing.T) {
637+
dir := t.TempDir()
638+
target := filepath.Join(dir, "mcpproxy")
639+
staged := filepath.Join(dir, "staged")
640+
if err := os.WriteFile(target, []byte("current"), 0o755); err != nil {
641+
t.Fatalf("write target: %v", err)
642+
}
643+
if err := os.WriteFile(staged, []byte("new"), 0o600); err != nil {
644+
t.Fatalf("write staged: %v", err)
645+
}
646+
647+
release, err := acquireUpdateLock(target)
648+
if err != nil {
649+
t.Fatalf("acquire: %v", err)
650+
}
651+
defer release()
652+
653+
if err := applyNewBinary(target, staged, nil); !errors.Is(err, errUpdateInProgress) {
654+
t.Fatalf("applyNewBinary under a held lock: want errUpdateInProgress, got %v", err)
655+
}
656+
// The refused attempt must not have touched anything.
657+
got, readErr := os.ReadFile(target)
658+
if readErr != nil {
659+
t.Fatalf("read target: %v", readErr)
660+
}
661+
if string(got) != "current" {
662+
t.Fatalf("target changed by refused swap: %q", got)
663+
}
664+
}

cmd/mcpproxy/update_lock.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package main
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"os"
7+
)
8+
9+
// errUpdateInProgress reports that another mcpproxy process holds the
10+
// self-update lock for the same target binary.
11+
var errUpdateInProgress = errors.New("another mcpproxy update is already updating this binary")
12+
13+
// updateLockSuffix names the advisory lock file that serializes the whole
14+
// recover-and-swap sequence per target. The file is deliberately NEVER
15+
// unlinked: removing a held lock file lets a third process create-and-lock a
16+
// fresh inode while a second still holds the old one, which is two "exclusive"
17+
// holders. A leftover zero-byte <target>.update-lock is the documented cost.
18+
const updateLockSuffix = ".update-lock"
19+
20+
// acquireUpdateLock takes an exclusive, non-blocking, OS-level advisory lock
21+
// scoped to the target binary. It guards recoverInterruptedSwap AND the swap
22+
// itself: without it, a concurrent `mcpproxy update` can read a half-finished
23+
// sibling's sentinel/backup state, misclassify it, and delete the only
24+
// known-good backup. The kernel releases the lock if the process dies, so a
25+
// crash can never wedge future updates.
26+
func acquireUpdateLock(target string) (release func(), err error) {
27+
path := target + updateLockSuffix
28+
f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) // #nosec G304 -- derived from the caller-resolved target path
29+
if err != nil {
30+
return nil, fmt.Errorf("open update lock %s: %w", path, err)
31+
}
32+
if lockErr := lockFileExclusiveNB(f); lockErr != nil {
33+
_ = f.Close()
34+
if errors.Is(lockErr, errWouldBlock) {
35+
return nil, fmt.Errorf("%w (lock: %s)", errUpdateInProgress, path)
36+
}
37+
return nil, fmt.Errorf("lock %s: %w", path, lockErr)
38+
}
39+
return func() { _ = f.Close() }, nil // closing the fd releases the lock
40+
}

cmd/mcpproxy/update_lock_unix.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
//go:build !windows
2+
3+
package main
4+
5+
import (
6+
"errors"
7+
"os"
8+
9+
"golang.org/x/sys/unix"
10+
)
11+
12+
// errWouldBlock is what lockFileExclusiveNB returns when another process
13+
// already holds the lock.
14+
var errWouldBlock = unix.EWOULDBLOCK
15+
16+
// lockFileExclusiveNB takes a non-blocking exclusive flock on f. flock locks
17+
// belong to the open file description, so they conflict even between two
18+
// opens in the same process, and the kernel drops them when the process exits.
19+
func lockFileExclusiveNB(f *os.File) error {
20+
err := unix.Flock(int(f.Fd()), unix.LOCK_EX|unix.LOCK_NB)
21+
if errors.Is(err, unix.EAGAIN) {
22+
return errWouldBlock
23+
}
24+
return err
25+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
//go:build windows
2+
3+
package main
4+
5+
import (
6+
"errors"
7+
"os"
8+
9+
"golang.org/x/sys/windows"
10+
)
11+
12+
// errWouldBlock is what lockFileExclusiveNB returns when another process
13+
// already holds the lock.
14+
var errWouldBlock = errors.New("update lock held by another process")
15+
16+
// lockFileExclusiveNB takes a non-blocking exclusive LockFileEx on the first
17+
// byte of f. Windows releases the region lock when the handle is closed or
18+
// the process exits.
19+
func lockFileExclusiveNB(f *os.File) error {
20+
ol := new(windows.Overlapped)
21+
err := windows.LockFileEx(windows.Handle(f.Fd()),
22+
windows.LOCKFILE_EXCLUSIVE_LOCK|windows.LOCKFILE_FAIL_IMMEDIATELY,
23+
0, 1, 0, ol)
24+
if errors.Is(err, windows.ERROR_LOCK_VIOLATION) {
25+
return errWouldBlock
26+
}
27+
return err
28+
}

0 commit comments

Comments
 (0)