From 59f42cb0abc27bafa1d499f69c0288a017799310 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 29 Apr 2026 14:43:53 +0100 Subject: [PATCH] fix(cli): serialize discrawl sync runs --- go.mod | 2 +- internal/cli/admin_commands.go | 6 ++++ internal/cli/cli_test.go | 28 ++++++++++++++++ internal/cli/sync_lock.go | 37 +++++++++++++++++++++ internal/cli/sync_lock_other.go | 9 ++++++ internal/cli/sync_lock_unix.go | 53 +++++++++++++++++++++++++++++++ internal/cli/sync_lock_windows.go | 51 +++++++++++++++++++++++++++++ 7 files changed, 185 insertions(+), 1 deletion(-) create mode 100644 internal/cli/sync_lock.go create mode 100644 internal/cli/sync_lock_other.go create mode 100644 internal/cli/sync_lock_unix.go create mode 100644 internal/cli/sync_lock_windows.go diff --git a/go.mod b/go.mod index 388d825..958251d 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/gorilla/websocket v1.5.3 github.com/pelletier/go-toml/v2 v2.3.0 github.com/stretchr/testify v1.11.1 + golang.org/x/sys v0.43.0 golang.org/x/text v0.36.0 modernc.org/sqlite v1.50.0 ) @@ -22,7 +23,6 @@ require ( github.com/pmezard/go-difflib v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect golang.org/x/crypto v0.50.0 // indirect - golang.org/x/sys v0.43.0 // indirect golang.org/x/tools v0.44.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/internal/cli/admin_commands.go b/internal/cli/admin_commands.go index b49dad9..f5058f4 100644 --- a/internal/cli/admin_commands.go +++ b/internal/cli/admin_commands.go @@ -143,6 +143,12 @@ func (r *runtime) runSync(args []string) error { SkipMembers: syncSkipsMembers(*skipMembers, defaultLatest), LatestOnly: syncLatestOnly(*latestOnly, defaultLatest), } + return r.withSyncLock(func() error { + return r.runSyncLocked(sources, opts) + }) +} + +func (r *runtime) runSyncLocked(sources syncSources, opts syncer.SyncOptions) error { var apiStats *syncer.SyncStats if sources.discord { shouldClose := r.client == nil diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 83fd548..e3e1fde 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -10,6 +10,7 @@ import ( "os" "os/exec" "path/filepath" + goruntime "runtime" "testing" "time" @@ -655,6 +656,33 @@ func TestSyncImportsGitShareBeforeLiveDiscord(t *testing.T) { require.Contains(t, contents, "live discord filled the delta") } +func TestSyncLockSerializesConcurrentRuns(t *testing.T) { + if goruntime.GOOS == "windows" { + t.Skip("sync lock is currently a no-op on Windows") + } + ctx := context.Background() + dir := t.TempDir() + cfg := config.Default() + cfg.DBPath = filepath.Join(dir, "discrawl.db") + cfgPath := filepath.Join(dir, "config.toml") + require.NoError(t, config.Write(cfgPath, cfg)) + + rt := &runtime{ + ctx: ctx, + configPath: cfgPath, + cfg: cfg, + } + firstRelease, err := acquireSyncLock(ctx, filepath.Join(dir, ".discrawl-sync.lock")) + require.NoError(t, err) + defer func() { _ = firstRelease() }() + + waitCtx, cancel := context.WithTimeout(ctx, 25*time.Millisecond) + defer cancel() + rt.ctx = waitCtx + err = rt.withSyncLock(func() error { return nil }) + require.ErrorIs(t, err, context.DeadlineExceeded) +} + func seedCLIStore(t *testing.T, path string) *store.Store { t.Helper() ctx := context.Background() diff --git a/internal/cli/sync_lock.go b/internal/cli/sync_lock.go new file mode 100644 index 0000000..f543b90 --- /dev/null +++ b/internal/cli/sync_lock.go @@ -0,0 +1,37 @@ +package cli + +import ( + "context" + "fmt" + "path/filepath" + + "github.com/steipete/discrawl/internal/config" +) + +func (r *runtime) withSyncLock(fn func() error) error { + lockPath, err := r.syncLockPath() + if err != nil { + return err + } + release, err := acquireSyncLock(r.ctx, lockPath) + if err != nil { + return err + } + defer func() { _ = release() }() + return fn() +} + +func (r *runtime) syncLockPath() (string, error) { + dbPath, err := config.ExpandPath(r.cfg.DBPath) + if err != nil { + return "", configErr(err) + } + return filepath.Join(filepath.Dir(dbPath), ".discrawl-sync.lock"), nil +} + +func syncLockErr(ctx context.Context, path string) error { + if ctx.Err() != nil { + return fmt.Errorf("wait for sync lock %s: %w", path, ctx.Err()) + } + return nil +} diff --git a/internal/cli/sync_lock_other.go b/internal/cli/sync_lock_other.go new file mode 100644 index 0000000..d321c82 --- /dev/null +++ b/internal/cli/sync_lock_other.go @@ -0,0 +1,9 @@ +//go:build !unix && !windows + +package cli + +import "context" + +func acquireSyncLock(context.Context, string) (func() error, error) { + return func() error { return nil }, nil +} diff --git a/internal/cli/sync_lock_unix.go b/internal/cli/sync_lock_unix.go new file mode 100644 index 0000000..cc6d797 --- /dev/null +++ b/internal/cli/sync_lock_unix.go @@ -0,0 +1,53 @@ +//go:build unix + +package cli + +import ( + "context" + "errors" + "fmt" + "os" + "time" + + "golang.org/x/sys/unix" +) + +func acquireSyncLock(ctx context.Context, path string) (func() error, error) { + file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return nil, fmt.Errorf("open sync lock: %w", err) + } + locked := false + defer func() { + if !locked { + _ = file.Close() + } + }() + ticker := time.NewTicker(200 * time.Millisecond) + defer ticker.Stop() + for { + err = unix.Flock(int(file.Fd()), unix.LOCK_EX|unix.LOCK_NB) + if err == nil { + locked = true + _, _ = file.Seek(0, 0) + _ = file.Truncate(0) + _, _ = fmt.Fprintf(file, "pid=%d\n", os.Getpid()) + return func() error { + unlockErr := unix.Flock(int(file.Fd()), unix.LOCK_UN) + closeErr := file.Close() + if unlockErr != nil { + return unlockErr + } + return closeErr + }, nil + } + if !errors.Is(err, unix.EWOULDBLOCK) && !errors.Is(err, unix.EAGAIN) { + return nil, fmt.Errorf("acquire sync lock: %w", err) + } + select { + case <-ctx.Done(): + return nil, syncLockErr(ctx, path) + case <-ticker.C: + } + } +} diff --git a/internal/cli/sync_lock_windows.go b/internal/cli/sync_lock_windows.go new file mode 100644 index 0000000..f596753 --- /dev/null +++ b/internal/cli/sync_lock_windows.go @@ -0,0 +1,51 @@ +//go:build windows + +package cli + +import ( + "context" + "fmt" + "os" + "time" + + "golang.org/x/sys/windows" +) + +func acquireSyncLock(ctx context.Context, path string) (func() error, error) { + file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return nil, fmt.Errorf("open sync lock: %w", err) + } + locked := false + defer func() { + if !locked { + _ = file.Close() + } + }() + ticker := time.NewTicker(200 * time.Millisecond) + defer ticker.Stop() + handle := windows.Handle(file.Fd()) + overlapped := &windows.Overlapped{} + for { + err = windows.LockFileEx(handle, windows.LOCKFILE_EXCLUSIVE_LOCK|windows.LOCKFILE_FAIL_IMMEDIATELY, 0, 1, 0, overlapped) + if err == nil { + locked = true + _, _ = file.Seek(0, 0) + _ = file.Truncate(0) + _, _ = fmt.Fprintf(file, "pid=%d\n", os.Getpid()) + return func() error { + unlockErr := windows.UnlockFileEx(handle, 0, 1, 0, overlapped) + closeErr := file.Close() + if unlockErr != nil { + return unlockErr + } + return closeErr + }, nil + } + select { + case <-ctx.Done(): + return nil, syncLockErr(ctx, path) + case <-ticker.C: + } + } +}