feat(daemon): lock de instancia única por config dir (flock)

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 <configDir>/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.
This commit is contained in:
Deivid Soto 2026-06-11 17:18:01 +02:00
parent 1e61d1e546
commit 2b9d576aee
5 changed files with 46 additions and 0 deletions

View file

@ -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)

View file

@ -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

View file

@ -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 == "" {