From 2b9d576aeefe4d66d775c0ddd9c4a1d127434133 Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Thu, 11 Jun 2026 17:18:01 +0200 Subject: [PATCH] =?UTF-8?q?feat(daemon):=20lock=20de=20instancia=20=C3=BAn?= =?UTF-8?q?ica=20por=20config=20dir=20(flock)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dos daemons compartiendo el mismo config.toml corren sobre el mismo agentId/agentHash/streamSecret y corrompen el estado de sync del otro. flock advisory en /unarr.lock al arrancar: el 2º start se niega con mensaje claro. El kernel suelta el lock al morir el proceso (incluido SIGKILL) → sin problema de lock obsoleto. Scope = config dir, no máquina: un UNARR_CONFIG_DIR distinto (p.ej. el agente dev) tiene su propio lock y corre en paralelo. No bloquea una 2ª instalación con config separada — solo el cross-talk de config compartida. --- go.mod | 1 + go.sum | 2 ++ internal/cmd/daemon.go | 26 ++++++++++++++++++++++++++ internal/config/paths.go | 9 +++++++++ internal/config/paths_test.go | 8 ++++++++ 5 files changed, 46 insertions(+) diff --git a/go.mod b/go.mod index f8d42d8..e5276b6 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/charmbracelet/huh v1.0.0 github.com/fatih/color v1.19.0 github.com/getsentry/sentry-go v0.44.1 + github.com/gofrs/flock v0.13.0 github.com/google/uuid v1.6.0 github.com/huin/goupnp v1.3.0 github.com/olekukonko/tablewriter v1.1.4 diff --git a/go.sum b/go.sum index d1c9fe6..97bb150 100644 --- a/go.sum +++ b/go.sum @@ -207,6 +207,8 @@ github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6Wezm github.com/gobwas/ws v1.2.1/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY= github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= +github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= diff --git a/internal/cmd/daemon.go b/internal/cmd/daemon.go index 75b226a..543272f 100644 --- a/internal/cmd/daemon.go +++ b/internal/cmd/daemon.go @@ -13,6 +13,7 @@ import ( "time" "github.com/fatih/color" + "github.com/gofrs/flock" "github.com/spf13/cobra" "github.com/torrentclaw/unarr/internal/acme" "github.com/torrentclaw/unarr/internal/agent" @@ -118,6 +119,31 @@ func runDaemonStart() error { return fmt.Errorf("no download directory — run 'unarr init' first") } + // Single-instance lock: refuse to start if another daemon already holds + // this config dir. Two daemons sharing one config.toml race over the same + // agentId / agentHash / streamSecret and corrupt each other's sync state. + // flock is advisory + kernel-released on process death (even SIGKILL), so + // there's no stale-lock problem. A separate UNARR_CONFIG_DIR gets its own + // lock path and runs concurrently (this is how the dev agent coexists). + lockDir := config.Dir() + if err := os.MkdirAll(lockDir, 0o755); err != nil { + return fmt.Errorf("create config dir: %w", err) + } + instanceLock := flock.New(config.LockPath()) + locked, err := instanceLock.TryLock() + if err != nil { + return fmt.Errorf("acquire instance lock %s: %w", config.LockPath(), err) + } + if !locked { + return fmt.Errorf("another unarr daemon is already running for this config (%s).\n"+ + "Stop it with 'unarr stop', or use a separate UNARR_CONFIG_DIR to run a second agent", lockDir) + } + defer func() { + if err := instanceLock.Unlock(); err != nil { + log.Printf("[lock] release %s: %v", config.LockPath(), err) + } + }() + // Validate configured paths are safe if err := cfg.ValidatePaths(); err != nil { return fmt.Errorf("unsafe configuration: %w", err) diff --git a/internal/config/paths.go b/internal/config/paths.go index 19f06b9..3f9fdde 100644 --- a/internal/config/paths.go +++ b/internal/config/paths.go @@ -38,6 +38,15 @@ func FilePath() string { return filepath.Join(Dir(), "config.toml") } +// LockPath returns the daemon single-instance lock file, alongside config.toml. +// Scoped to the config dir so a separate UNARR_CONFIG_DIR (e.g. a dev agent) +// gets its own lock and can run concurrently; two daemons sharing one config +// dir cannot — that's the case that causes cross-talk (same agentId/hash/secret +// racing each other). +func LockPath() string { + return filepath.Join(Dir(), "unarr.lock") +} + // DataDir returns the data directory for logs, cache, etc. // - Linux: ~/.local/share/unarr // - macOS: ~/Library/Application Support/unarr diff --git a/internal/config/paths_test.go b/internal/config/paths_test.go index ed93044..06e1905 100644 --- a/internal/config/paths_test.go +++ b/internal/config/paths_test.go @@ -23,6 +23,14 @@ func TestFilePath(t *testing.T) { } } +func TestLockPath(t *testing.T) { + t.Setenv("UNARR_CONFIG_DIR", "/custom/path") + path := LockPath() + if path != "/custom/path/unarr.lock" { + t.Errorf("LockPath() = %q, want /custom/path/unarr.lock", path) + } +} + func TestDataDir(t *testing.T) { dir := DataDir() if dir == "" {