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 (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"time"
@ -9,6 +11,11 @@ import (
"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.
type DaemonState struct {
AgentID string `json:"agentId"`
@ -69,17 +76,31 @@ func WriteState(state *DaemonState) {
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 {
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())
if err != nil {
return nil
if errors.Is(err, os.ErrNotExist) {
return nil, ErrDaemonNotRunning
}
return nil, err
}
var state DaemonState
if json.Unmarshal(data, &state) != nil {
return nil
if err := json.Unmarshal(data, &state); err != 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).

View file

@ -1,6 +1,7 @@
package agent
import (
"errors"
"os"
"path/filepath"
"testing"
@ -104,3 +105,39 @@ func TestReadStateCorruptedJSON(t *testing.T) {
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.
// Used as fallback on platforms without a service manager (and as Windows implementation).
func stopDaemonByPID() error {
state := agent.ReadState()
if state == nil {
return fmt.Errorf("daemon does not appear to be running (state file not found)")
state, err := agent.LoadState()
if err != nil {
return err
}
return killPID(state.PID)
}

View file

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

View file

@ -9,6 +9,8 @@ import (
gosentry "github.com/getsentry/sentry-go"
"github.com/spf13/pflag"
"github.com/torrentclaw/unarr/internal/agent"
)
// 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 {
if errors.Is(err, agent.ErrDaemonNotRunning) {
return true
}
var notExist *pflag.NotExistError
var valueReq *pflag.ValueRequiredError
var invalidVal *pflag.InvalidValueError

View file

@ -1,6 +1,11 @@
package sentry
import "testing"
import (
"fmt"
"testing"
"github.com/torrentclaw/unarr/internal/agent"
)
func TestEnvironment(t *testing.T) {
tests := []struct {
@ -45,3 +50,13 @@ func TestSetUser(t *testing.T) {
// Should not panic without initialization
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")
}
}