fix(ipc): serialize socket bind and stale recovery with a sibling lock
- commit
- d2968ddeed90cf5cd8f9e6e0f7ba52b945862dda
- author
- brice · · 2026-09-23 21:37 UTC
- parent
- 665fa7b
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
1 file · +27 -0
M internal/adapters/ipc/listener.go +27 -0
| @@ -76,6 +76,15 @@ func Listen(dir string, opts ...Option) (wire.Listener, error) { | |||
| 76 | 76 | // changed, the bind is retried (and typically reports the racing owner live) | |
| 77 | 77 | // instead of removing it. | |
| 78 | 78 | func listenUnixStaleSafe(sockPath string) (*net.UnixListener, error) { | |
| 79 | + | // A socket file exists before its owner calls listen(2), and a dial in | |
| 80 | + | // that gap is refused exactly like a dial to a dead socket. Serialize | |
| 81 | + | // bind and stale recovery on a sibling lock so no contender can mistake | |
| 82 | + | // another's half-bound socket for a stale one and unlink it. | |
| 83 | + | unlock, err := lockSocketPath(sockPath) | |
| 84 | + | if err != nil { | |
| 85 | + | return nil, err | |
| 86 | + | } | |
| 87 | + | defer unlock() | |
| 79 | 88 | for attempt := 0; attempt < staleRecoveryAttempts; attempt++ { | |
| 80 | 89 | ln, err := bindUnix(sockPath) | |
| 81 | 90 | if err == nil { | |
| @@ -126,6 +135,24 @@ func listenUnixStaleSafe(sockPath string) (*net.UnixListener, error) { | |||
| 126 | 135 | return nil, ErrDaemonRunning | |
| 127 | 136 | } | |
| 128 | 137 | ||
| 138 | + | // lockSocketPath takes an exclusive flock on sockPath + ".lock" and returns | |
| 139 | + | // its release. The lock file is owner-only and never removed, so every | |
| 140 | + | // contender locks the same inode. | |
| 141 | + | func lockSocketPath(sockPath string) (func(), error) { | |
| 142 | + | file, err := os.OpenFile(sockPath+".lock", os.O_CREATE|os.O_RDWR|syscall.O_NOFOLLOW|syscall.O_CLOEXEC, 0o600) | |
| 143 | + | if err != nil { | |
| 144 | + | return nil, fmt.Errorf("ipc: opening socket lock for %s: %w", sockPath, err) | |
| 145 | + | } | |
| 146 | + | if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX); err != nil { | |
| 147 | + | _ = file.Close() | |
| 148 | + | return nil, fmt.Errorf("ipc: locking socket %s: %w", sockPath, err) | |
| 149 | + | } | |
| 150 | + | return func() { | |
| 151 | + | _ = syscall.Flock(int(file.Fd()), syscall.LOCK_UN) | |
| 152 | + | _ = file.Close() | |
| 153 | + | }, nil | |
| 154 | + | } | |
| 155 | + | ||
| 129 | 156 | // bindUnix binds and listens on sockPath. | |
| 130 | 157 | func bindUnix(sockPath string) (*net.UnixListener, error) { | |
| 131 | 158 | addr, err := net.ResolveUnixAddr("unix", sockPath) | |
| @@ -76,6 +76,15 @@ func Listen(dir string, opts ...Option) (wire.Listener, error) { | |||||
| 76 | // changed, the bind is retried (and typically reports the racing owner live) | 76 | // changed, the bind is retried (and typically reports the racing owner live) | ||
| 77 | // instead of removing it. | 77 | // instead of removing it. | ||
| 78 | func listenUnixStaleSafe(sockPath string) (*net.UnixListener, error) { | 78 | func listenUnixStaleSafe(sockPath string) (*net.UnixListener, error) { | ||
| 79 | + | // A socket file exists before its owner calls listen(2), and a dial in | |||
| 80 | + | // that gap is refused exactly like a dial to a dead socket. Serialize | |||
| 81 | + | // bind and stale recovery on a sibling lock so no contender can mistake | |||
| 82 | + | // another's half-bound socket for a stale one and unlink it. | |||
| 83 | + | unlock, err := lockSocketPath(sockPath) | |||
| 84 | + | if err != nil { | |||
| 85 | + | return nil, err | |||
| 86 | + | } | |||
| 87 | + | defer unlock() | |||
| 79 | for attempt := 0; attempt < staleRecoveryAttempts; attempt++ { | 88 | for attempt := 0; attempt < staleRecoveryAttempts; attempt++ { | ||
| 80 | ln, err := bindUnix(sockPath) | 89 | ln, err := bindUnix(sockPath) | ||
| 81 | if err == nil { | 90 | if err == nil { | ||
| @@ -126,6 +135,24 @@ func listenUnixStaleSafe(sockPath string) (*net.UnixListener, error) { | |||||
| 126 | return nil, ErrDaemonRunning | 135 | return nil, ErrDaemonRunning | ||
| 127 | } | 136 | } | ||
| 128 | 137 | ||||
| 138 | + | // lockSocketPath takes an exclusive flock on sockPath + ".lock" and returns | |||
| 139 | + | // its release. The lock file is owner-only and never removed, so every | |||
| 140 | + | // contender locks the same inode. | |||
| 141 | + | func lockSocketPath(sockPath string) (func(), error) { | |||
| 142 | + | file, err := os.OpenFile(sockPath+".lock", os.O_CREATE|os.O_RDWR|syscall.O_NOFOLLOW|syscall.O_CLOEXEC, 0o600) | |||
| 143 | + | if err != nil { | |||
| 144 | + | return nil, fmt.Errorf("ipc: opening socket lock for %s: %w", sockPath, err) | |||
| 145 | + | } | |||
| 146 | + | if err := syscall.Flock(int(file.Fd()), syscall.LOCK_EX); err != nil { | |||
| 147 | + | _ = file.Close() | |||
| 148 | + | return nil, fmt.Errorf("ipc: locking socket %s: %w", sockPath, err) | |||
| 149 | + | } | |||
| 150 | + | return func() { | |||
| 151 | + | _ = syscall.Flock(int(file.Fd()), syscall.LOCK_UN) | |||
| 152 | + | _ = file.Close() | |||
| 153 | + | }, nil | |||
| 154 | + | } | |||
| 155 | + | ||||
| 129 | // bindUnix binds and listens on sockPath. | 156 | // bindUnix binds and listens on sockPath. | ||
| 130 | func bindUnix(sockPath string) (*net.UnixListener, error) { | 157 | func bindUnix(sockPath string) (*net.UnixListener, error) { | ||
| 131 | addr, err := net.ResolveUnixAddr("unix", sockPath) | 158 | addr, err := net.ResolveUnixAddr("unix", sockPath) | ||