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:
parent
1e61d1e546
commit
2b9d576aee
5 changed files with 46 additions and 0 deletions
1
go.mod
1
go.mod
|
|
@ -10,6 +10,7 @@ require (
|
||||||
github.com/charmbracelet/huh v1.0.0
|
github.com/charmbracelet/huh v1.0.0
|
||||||
github.com/fatih/color v1.19.0
|
github.com/fatih/color v1.19.0
|
||||||
github.com/getsentry/sentry-go v0.44.1
|
github.com/getsentry/sentry-go v0.44.1
|
||||||
|
github.com/gofrs/flock v0.13.0
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/huin/goupnp v1.3.0
|
github.com/huin/goupnp v1.3.0
|
||||||
github.com/olekukonko/tablewriter v1.1.4
|
github.com/olekukonko/tablewriter v1.1.4
|
||||||
|
|
|
||||||
2
go.sum
2
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/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 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
|
||||||
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
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.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||||
github.com/gogo/protobuf v1.2.0/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=
|
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/fatih/color"
|
"github.com/fatih/color"
|
||||||
|
"github.com/gofrs/flock"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/torrentclaw/unarr/internal/acme"
|
"github.com/torrentclaw/unarr/internal/acme"
|
||||||
"github.com/torrentclaw/unarr/internal/agent"
|
"github.com/torrentclaw/unarr/internal/agent"
|
||||||
|
|
@ -118,6 +119,31 @@ func runDaemonStart() error {
|
||||||
return fmt.Errorf("no download directory — run 'unarr init' first")
|
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
|
// Validate configured paths are safe
|
||||||
if err := cfg.ValidatePaths(); err != nil {
|
if err := cfg.ValidatePaths(); err != nil {
|
||||||
return fmt.Errorf("unsafe configuration: %w", err)
|
return fmt.Errorf("unsafe configuration: %w", err)
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,15 @@ func FilePath() string {
|
||||||
return filepath.Join(Dir(), "config.toml")
|
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.
|
// DataDir returns the data directory for logs, cache, etc.
|
||||||
// - Linux: ~/.local/share/unarr
|
// - Linux: ~/.local/share/unarr
|
||||||
// - macOS: ~/Library/Application Support/unarr
|
// - macOS: ~/Library/Application Support/unarr
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
func TestDataDir(t *testing.T) {
|
||||||
dir := DataDir()
|
dir := DataDir()
|
||||||
if dir == "" {
|
if dir == "" {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue