fix(sentry): skip "daemon not running" stop/reload errors

This commit is contained in:
Deivid Soto 2026-05-27 16:50:16 +02:00
parent fceadd2009
commit 4d7444ef5b
6 changed files with 90 additions and 12 deletions

View file

@ -2,6 +2,8 @@ package agent
import ( import (
"encoding/json" "encoding/json"
"errors"
"fmt"
"os" "os"
"path/filepath" "path/filepath"
"time" "time"
@ -9,6 +11,11 @@ import (
"github.com/torrentclaw/unarr/internal/config" "github.com/torrentclaw/unarr/internal/config"
) )
// ErrDaemonNotRunning is returned by callers that need a running daemon but
// find no state file on disk. Sentinel so user-facing commands (stop/reload)
// can wrap it and Sentry can filter it out as a non-bug.
var ErrDaemonNotRunning = errors.New("daemon does not appear to be running (state file not found)")
// DaemonState is written to disk every heartbeat for external tools to read. // DaemonState is written to disk every heartbeat for external tools to read.
type DaemonState struct { type DaemonState struct {
AgentID string `json:"agentId"` AgentID string `json:"agentId"`
@ -69,17 +76,31 @@ func WriteState(state *DaemonState) {
os.Rename(tmp, path) os.Rename(tmp, path)
} }
// ReadState reads the daemon state from disk. Returns nil if not found. // ReadState reads the daemon state from disk. Returns nil if not found or
// unreadable. Use LoadState when callers need to distinguish "not running"
// from "state file corrupted".
func ReadState() *DaemonState { func ReadState() *DaemonState {
state, _ := LoadState()
return state
}
// LoadState reads the daemon state and returns explicit errors:
// - ErrDaemonNotRunning when the state file does not exist
// - a wrapped json error when the file exists but cannot be decoded
// (a real bug worth reporting to Sentry)
func LoadState() (*DaemonState, error) {
data, err := os.ReadFile(StateFilePath()) data, err := os.ReadFile(StateFilePath())
if err != nil { if err != nil {
return nil if errors.Is(err, os.ErrNotExist) {
return nil, ErrDaemonNotRunning
}
return nil, err
} }
var state DaemonState var state DaemonState
if json.Unmarshal(data, &state) != nil { if err := json.Unmarshal(data, &state); err != nil {
return nil return nil, fmt.Errorf("decode daemon state %s: %w", StateFilePath(), err)
} }
return &state return &state, nil
} }
// RemoveState deletes the state file (called on clean shutdown). // RemoveState deletes the state file (called on clean shutdown).

View file

@ -1,6 +1,7 @@
package agent package agent
import ( import (
"errors"
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
@ -104,3 +105,39 @@ func TestReadStateCorruptedJSON(t *testing.T) {
t.Errorf("ReadState() should return nil for corrupted JSON, got %+v", state) t.Errorf("ReadState() should return nil for corrupted JSON, got %+v", state)
} }
} }
func TestLoadStateNotFound(t *testing.T) {
tmpDir := t.TempDir()
origFn := stateFilePathFn
stateFilePathFn = func() string { return filepath.Join(tmpDir, "nonexistent.json") }
defer func() { stateFilePathFn = origFn }()
state, err := LoadState()
if state != nil {
t.Errorf("LoadState() state = %+v, want nil", state)
}
if !errors.Is(err, ErrDaemonNotRunning) {
t.Errorf("LoadState() err = %v, want ErrDaemonNotRunning", err)
}
}
func TestLoadStateCorruptedJSON(t *testing.T) {
tmpDir := t.TempDir()
origFn := stateFilePathFn
path := filepath.Join(tmpDir, "daemon.state.json")
stateFilePathFn = func() string { return path }
defer func() { stateFilePathFn = origFn }()
os.WriteFile(path, []byte("not valid json{{{"), 0o644)
state, err := LoadState()
if state != nil {
t.Errorf("LoadState() state = %+v, want nil", state)
}
if err == nil {
t.Fatal("LoadState() err = nil, want decode error")
}
if errors.Is(err, ErrDaemonNotRunning) {
t.Error("corrupt state must not be reported as ErrDaemonNotRunning — it would be filtered from Sentry")
}
}

View file

@ -262,9 +262,9 @@ func runDaemonReload() error {
// stopDaemonByPID reads the state file and sends a graceful stop to the daemon PID. // stopDaemonByPID reads the state file and sends a graceful stop to the daemon PID.
// Used as fallback on platforms without a service manager (and as Windows implementation). // Used as fallback on platforms without a service manager (and as Windows implementation).
func stopDaemonByPID() error { func stopDaemonByPID() error {
state := agent.ReadState() state, err := agent.LoadState()
if state == nil { if err != nil {
return fmt.Errorf("daemon does not appear to be running (state file not found)") return err
} }
return killPID(state.PID) return killPID(state.PID)
} }

View file

@ -43,9 +43,9 @@ func startReloadWatcher(rc *ReloadableConfig) {
// sendReloadSignal sends SIGUSR1 to the running daemon process. // sendReloadSignal sends SIGUSR1 to the running daemon process.
func sendReloadSignal() error { func sendReloadSignal() error {
state := agent.ReadState() state, err := agent.LoadState()
if state == nil { if err != nil {
return fmt.Errorf("daemon does not appear to be running (state file not found)") return err
} }
p, err := os.FindProcess(state.PID) p, err := os.FindProcess(state.PID)
if err != nil { if err != nil {

View file

@ -9,6 +9,8 @@ import (
gosentry "github.com/getsentry/sentry-go" gosentry "github.com/getsentry/sentry-go"
"github.com/spf13/pflag" "github.com/spf13/pflag"
"github.com/torrentclaw/unarr/internal/agent"
) )
// dsn is injected at build time via ldflags. If empty, Sentry is disabled. // dsn is injected at build time via ldflags. If empty, Sentry is disabled.
@ -63,6 +65,9 @@ func CaptureError(err error, command string) {
} }
func isUserInputError(err error) bool { func isUserInputError(err error) bool {
if errors.Is(err, agent.ErrDaemonNotRunning) {
return true
}
var notExist *pflag.NotExistError var notExist *pflag.NotExistError
var valueReq *pflag.ValueRequiredError var valueReq *pflag.ValueRequiredError
var invalidVal *pflag.InvalidValueError var invalidVal *pflag.InvalidValueError

View file

@ -1,6 +1,11 @@
package sentry package sentry
import "testing" import (
"fmt"
"testing"
"github.com/torrentclaw/unarr/internal/agent"
)
func TestEnvironment(t *testing.T) { func TestEnvironment(t *testing.T) {
tests := []struct { tests := []struct {
@ -45,3 +50,13 @@ func TestSetUser(t *testing.T) {
// Should not panic without initialization // Should not panic without initialization
SetUser("agent-123") SetUser("agent-123")
} }
func TestIsUserInputErrorDaemonNotRunning(t *testing.T) {
if !isUserInputError(agent.ErrDaemonNotRunning) {
t.Error("ErrDaemonNotRunning should be treated as user-input error")
}
wrapped := fmt.Errorf("stop daemon: %w", agent.ErrDaemonNotRunning)
if !isUserInputError(wrapped) {
t.Error("wrapped ErrDaemonNotRunning should be treated as user-input error")
}
}