From a1983d6c8177ad01665aadec03b9fe56dba65edf Mon Sep 17 00:00:00 2001 From: Jun Futagawa Date: Tue, 23 Jun 2026 22:24:20 +0900 Subject: [PATCH] feat: prevent duplicate precd instances Co-authored-by: Codex --- README.md | 5 +++ cmd/precd/main.go | 10 +++++ cmd/precd/singleton_lock.go | 68 ++++++++++++++++++++++++++++++++ cmd/precd/singleton_lock_test.go | 49 +++++++++++++++++++++++ 4 files changed, 132 insertions(+) create mode 100644 cmd/precd/singleton_lock.go create mode 100644 cmd/precd/singleton_lock_test.go diff --git a/README.md b/README.md index 3c25311..6e6fa27 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,11 @@ Default config path: `/etc/prec/precd.conf` See: [packaging/precd.conf.example](packaging/precd.conf.example) +Single-instance behavior: + +- `precd` acquires an exclusive `flock` on `.lock` at startup +- if the lock is already held, startup fails immediately to prevent duplicate collection + Compression modes: - `compress = "no"` plain JSONL diff --git a/cmd/precd/main.go b/cmd/precd/main.go index 7b711bc..a2d6289 100644 --- a/cmd/precd/main.go +++ b/cmd/precd/main.go @@ -4,6 +4,7 @@ package main import ( + "errors" "fmt" "log" "os" @@ -62,6 +63,15 @@ func main() { log.Fatalf("load config: %v", err) } + lock, err := acquireSingletonLock(cfg.LogPath) + if err != nil { + if errors.Is(err, errPrecdAlreadyRunning) { + log.Fatalf("another precd instance is already running: %v", err) + } + log.Fatalf("init singleton lock: %v", err) + } + defer lock.Close() + w, err := logger.NewJSONLWriter(cfg.LogPath, cfg.Compress, cfg.CompressLevel) if err != nil { log.Fatalf("init logger: %v", err) diff --git a/cmd/precd/singleton_lock.go b/cmd/precd/singleton_lock.go new file mode 100644 index 0000000..0cf51ce --- /dev/null +++ b/cmd/precd/singleton_lock.go @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright contributors to the prec project. + +package main + +import ( + "errors" + "fmt" + "os" + "path/filepath" + + "golang.org/x/sys/unix" +) + +var errPrecdAlreadyRunning = errors.New("precd already running") + +// singletonLock holds a process-wide lock file descriptor to prevent duplicate daemons. +type singletonLock struct { + f *os.File +} + +func lockPathFromLogPath(logPath string) string { + return logPath + ".lock" +} + +func acquireSingletonLock(logPath string) (*singletonLock, error) { + lockPath := lockPathFromLogPath(logPath) + lockDir := filepath.Dir(lockPath) + if err := os.MkdirAll(lockDir, 0o750); err != nil { + return nil, fmt.Errorf("create lock dir: %w", err) + } + + f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o640) + if err != nil { + return nil, fmt.Errorf("open lock file: %w", err) + } + if err := f.Chmod(0o640); err != nil { + f.Close() + return nil, fmt.Errorf("chmod lock file: %w", err) + } + + if err := unix.Flock(int(f.Fd()), unix.LOCK_EX|unix.LOCK_NB); err != nil { + f.Close() + if errors.Is(err, unix.EWOULDBLOCK) || errors.Is(err, unix.EAGAIN) { + return nil, fmt.Errorf("%w: %s", errPrecdAlreadyRunning, lockPath) + } + return nil, fmt.Errorf("acquire lock: %w", err) + } + + return &singletonLock{f: f}, nil +} + +func (l *singletonLock) Close() error { + if l == nil || l.f == nil { + return nil + } + + var firstErr error + if err := unix.Flock(int(l.f.Fd()), unix.LOCK_UN); err != nil { + firstErr = err + } + if err := l.f.Close(); err != nil && firstErr == nil { + firstErr = err + } + + l.f = nil + return firstErr +} diff --git a/cmd/precd/singleton_lock_test.go b/cmd/precd/singleton_lock_test.go new file mode 100644 index 0000000..3e52c87 --- /dev/null +++ b/cmd/precd/singleton_lock_test.go @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright contributors to the prec project. + +package main + +import ( + "errors" + "path/filepath" + "testing" +) + +func TestAcquireSingletonLockRejectsSecondInstance(t *testing.T) { + logPath := filepath.Join(t.TempDir(), "prec.log") + + first, err := acquireSingletonLock(logPath) + if err != nil { + t.Fatalf("acquire first lock: %v", err) + } + defer first.Close() + + second, err := acquireSingletonLock(logPath) + if second != nil { + second.Close() + t.Fatalf("second lock must be nil when lock acquisition fails") + } + if !errors.Is(err, errPrecdAlreadyRunning) { + t.Fatalf("second lock error=%v, want errPrecdAlreadyRunning", err) + } +} + +func TestAcquireSingletonLockAllowsNextAfterClose(t *testing.T) { + logPath := filepath.Join(t.TempDir(), "prec.log") + + first, err := acquireSingletonLock(logPath) + if err != nil { + t.Fatalf("acquire first lock: %v", err) + } + if err := first.Close(); err != nil { + t.Fatalf("close first lock: %v", err) + } + + second, err := acquireSingletonLock(logPath) + if err != nil { + t.Fatalf("acquire second lock after close: %v", err) + } + if err := second.Close(); err != nil { + t.Fatalf("close second lock: %v", err) + } +}