18da021526
Phase 11 of PROJECT_REVIEW_PLAN.md: the themed cleanup pass over every low-severity finding still open (2.2-2.3, 3.4-3.6, 4.3-4.7, 6.4-6.7, 7.1-7.3, 8.2-8.3, 9.1-9.4, and the under-documented decisions in §10/§11). Behavioral fixes: - Reassign duplicate job IDs in a hand-edited jobs.json instead of letting two jobs share one runtime, schedule entry, and SeedStats bucket. - Disambiguate run-log file names that collide within the same second. - Compute AvgDurationMS as DurationSumMS/TimedRunCount instead of an incremental integer mean, so it always matches the seeded-from-logs average instead of drifting from truncation error. - Clean absolute paths in ResolveConfiguredPath so two spellings of the same jobs file do not trigger a spurious adoption. - Report InstallDesktopIcon failures through ErrorOccurred instead of discarding them silently. - Move settingsView's blocking AutostartStatus (PowerShell on Windows) off the UI thread. - Give notify-timing.tsv its own extension so CleanupLogs no longer manages it as a run log. - Replace the settingsView Save handler's second copy of validateConfig's rules with a bare parse, letting the Service's own error surface. Cleanups: - Delete collectActivity, the dead yaml tags on RunRecord, and the logArguments/LogArguments alias. - Fold the two systemTrayRegistered/mainWindowHidden globals into one trayState instance Run owns and threads through Settings and the single-instance reveal path. - Fix stale comments/docs: the frozen window-size restore claim, a reference to a renamed recordRun, README's "Pause all" and notification wording, the PowerShell quoting note for TESTS.md's coverage command, and scripts/test.bat's UTF-8 checkmarks under a non-UTF-8 code page. - Document the single-instance fallback's consequence and the unauthenticated instance-channel port in STANDARDS.md; record the config-shim retirement plan in ROADMAP.md. 3.5, 7.3, and 9.4 turned out to already be fixed by earlier phases; no change needed for those three. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
253 lines
9.5 KiB
Go
253 lines
9.5 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"sync"
|
|
"time"
|
|
|
|
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
|
"gitea.mixdep.ru/mix/gosentry/src/platform/autostart"
|
|
"gitea.mixdep.ru/mix/gosentry/src/runner"
|
|
"gitea.mixdep.ru/mix/gosentry/src/scheduler"
|
|
"gitea.mixdep.ru/mix/gosentry/src/storage"
|
|
)
|
|
|
|
// Service is the application-service layer: the single owner of GoSentry's
|
|
// in-memory state. It holds the durable jobs slice, the transient runtime map
|
|
// keyed by Job.ID, and a reference to the store that persists them. All access
|
|
// to that state goes through a mutex so the GUI and the scheduler can no longer
|
|
// race on a shared *[]Job.
|
|
//
|
|
// Mutations live in operations.go; scheduling and run dispatch live in run.go;
|
|
// typed events live in events.go. The scheduler is a thin timing loop that calls
|
|
// RunDue on every tick and holds no job state of its own.
|
|
//
|
|
// Locking contract: mu is a plain, non-reentrant mutex. Exported methods take
|
|
// it; unexported helpers ending in "Locked" assume the caller already holds it.
|
|
// The Service must never call back into the UI (or any code that might re-enter
|
|
// the Service) while holding mu — in particular emit() is always called after
|
|
// mu is released. Blocking file I/O follows the same rule: mu is the lock the
|
|
// Fyne main thread takes on every Jobs() and Runtime() call, so a JSON write, a
|
|
// log-directory scan, or a pass over every log header must not happen inside it
|
|
// (see deferSaveLocked, executeRun, and applySeededStatsLocked).
|
|
type Service struct {
|
|
mu sync.Mutex
|
|
store *storage.Store
|
|
jobs []domain.Job
|
|
runtimes map[int]*domain.JobRuntime
|
|
|
|
// schedules caches a parsed Schedule per job ID so timing math does not
|
|
// re-parse the schedule string on every use. paused is the global pause flag.
|
|
// Both are guarded by mu.
|
|
schedules map[int]domain.Schedule
|
|
paused bool
|
|
|
|
// runJob is the run seam. It defaults to runner.RunJob and is overridden in
|
|
// tests with a fake so the run paths can be exercised without spawning real
|
|
// processes. ctx is the lifecycle context passed to runs; Start replaces it
|
|
// with a cancelable context so Stop can abort in-flight runs, and until Start
|
|
// it is context.Background().
|
|
runJob func(ctx context.Context, job *domain.Job, trigger string, logsDir string, timeout time.Duration) (domain.RunRecord, error)
|
|
ctx context.Context
|
|
|
|
// sched is the timing loop installed by Start; cancel tears down ctx on Stop.
|
|
// Both are guarded by mu.
|
|
sched *scheduler.Scheduler
|
|
cancel context.CancelFunc
|
|
|
|
// manager is the platform autostart implementation. It is nil in tests that
|
|
// do not exercise autostart; Open() wires it via autostart.New().
|
|
manager autostart.Manager
|
|
|
|
// saveMu serializes the store writes that operations prepare under mu and run
|
|
// after releasing it. It is taken while mu is still held and released once the
|
|
// write is done, so writes reach the file in the same order their snapshots
|
|
// were taken and an older snapshot can never land on top of a newer one.
|
|
// Nothing may take mu while holding saveMu.
|
|
saveMu sync.Mutex
|
|
|
|
// observers and their guard live in events.go. dispatchMu is separate from mu
|
|
// so that emitting an event never requires (or is held under) the state lock:
|
|
// the Service must release mu before dispatching, per the locking contract.
|
|
dispatchMu sync.Mutex
|
|
observers []Observer
|
|
}
|
|
|
|
// deferSaveLocked prepares the store writes for the caller to run after mu is
|
|
// released, and takes saveMu now so a later operation's write cannot overtake
|
|
// this one. The caller must hold mu, must unlock it before calling the returned
|
|
// function, and must call that function exactly once. Keeping the marshal, the
|
|
// fsync, and the rename out of the critical section is what stops a settings
|
|
// change or a job edit from blocking a scheduler tick or a finishing run. The
|
|
// writes run in the order given and stop at the first error.
|
|
func (s *Service) deferSaveLocked(writes ...func() error) func() error {
|
|
s.saveMu.Lock()
|
|
return func() error {
|
|
defer s.saveMu.Unlock()
|
|
for _, write := range writes {
|
|
if err := write(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// NewService wires the Service to a loaded store and its jobs. It builds the
|
|
// initial runtime map from the durable jobs so every job has transient state
|
|
// from the moment the Service exists, and parses each job's schedule once. The
|
|
// store is the Service's sole channel to persistence.
|
|
func NewService(store *storage.Store, jobs []domain.Job) *Service {
|
|
s := &Service{
|
|
store: store,
|
|
runJob: runner.RunJob,
|
|
ctx: context.Background(),
|
|
paused: store.Config.Paused,
|
|
}
|
|
// No lock is needed here: construction is single-threaded, before Start
|
|
// launches the timing loop.
|
|
s.adoptJobsLocked(jobs)
|
|
s.applySeededStatsLocked(runner.SeedStats(store.Paths.LogsDir, s.jobs, store.Config.MaxLogFiles))
|
|
return s
|
|
}
|
|
|
|
// adoptJobsLocked makes jobs the Service's durable state and rebuilds everything
|
|
// derived from it: the runtime map, the parsed-schedule cache, and each job's
|
|
// first next-run — so the Service is ready to schedule the moment it exists,
|
|
// mirroring the old scheduler's reset-on-construction.
|
|
//
|
|
// It backs both construction and a Settings change that points at a different
|
|
// jobs file. Statistics seeded from existing log files are applied separately by
|
|
// applySeededStatsLocked, because reconstructing them is file I/O. The caller
|
|
// must hold mu.
|
|
func (s *Service) adoptJobsLocked(jobs []domain.Job) {
|
|
s.jobs = jobs
|
|
s.runtimes = domain.NewRuntimes(jobs)
|
|
s.schedules = make(map[int]domain.Schedule, len(jobs))
|
|
|
|
now := time.Now()
|
|
for index := range s.jobs {
|
|
job := &s.jobs[index]
|
|
s.parseScheduleLocked(job)
|
|
s.refreshNextRunFromLocked(job, s.runtimes[job.ID], now)
|
|
}
|
|
}
|
|
|
|
// applySeededStatsLocked folds statistics reconstructed from existing log files
|
|
// into the runtime map, so the details panel shows accumulated run history
|
|
// immediately rather than only runs since this process started. It is separate
|
|
// from adoptJobsLocked because producing the seeds opens every log file in the
|
|
// directory, which must not happen under mu: callers compute the map first and
|
|
// apply it here. The caller must hold mu.
|
|
func (s *Service) applySeededStatsLocked(seeds map[int]runner.SeededStats) {
|
|
for id, seed := range seeds {
|
|
runtime := s.runtimes[id]
|
|
if runtime == nil {
|
|
continue
|
|
}
|
|
runtime.RunCount = seed.RunCount
|
|
runtime.FailCount = seed.FailCount
|
|
runtime.LastDurationMS = seed.LastDurationMS
|
|
runtime.AvgDurationMS = seed.AvgDurationMS
|
|
runtime.MaxDurationMS = seed.MaxDurationMS
|
|
runtime.TimedRunCount = seed.TimedRunCount
|
|
runtime.DurationSumMS = seed.DurationSumMS
|
|
}
|
|
}
|
|
|
|
// Start begins scheduling with the real wall clock. It is the production entry
|
|
// point; tests should call StartWith and supply a fake clock instead. Start is
|
|
// expected once, during setup, before any concurrent use.
|
|
func (s *Service) Start() {
|
|
s.StartWith(scheduler.NewRealClock())
|
|
}
|
|
|
|
// StartWith begins scheduling driven by the given clock; every tick calls
|
|
// RunDue. Used by tests to inject a fake clock.
|
|
func (s *Service) StartWith(clock scheduler.Clock) {
|
|
s.mu.Lock()
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
s.ctx = ctx
|
|
s.cancel = cancel
|
|
s.sched = scheduler.NewScheduler(clock, s.RunDue)
|
|
sched := s.sched
|
|
s.mu.Unlock()
|
|
|
|
sched.Start()
|
|
}
|
|
|
|
// Stop halts scheduling and cancels the run context so in-flight runs see a
|
|
// canceled context. It is safe to call when Start was never called.
|
|
func (s *Service) Stop() {
|
|
s.mu.Lock()
|
|
sched := s.sched
|
|
cancel := s.cancel
|
|
s.mu.Unlock()
|
|
|
|
if sched != nil {
|
|
sched.Stop()
|
|
}
|
|
if cancel != nil {
|
|
cancel()
|
|
}
|
|
}
|
|
|
|
// Open loads the store and constructs a Service from it in one step. It is the
|
|
// convenience entry point for the application; tests inject a pre-built store
|
|
// via NewService instead.
|
|
func Open() (*Service, error) {
|
|
store, jobs, err := storage.OpenStore()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
svc := NewService(store, jobs)
|
|
svc.manager = autostart.New()
|
|
return svc, nil
|
|
}
|
|
|
|
// Config returns a copy of the current application configuration, safe to
|
|
// call from any goroutine. UpdateSettings, SetGlobalPause, and SetJobListView
|
|
// are the only writers and all mutate store.Config under mu; copying under the
|
|
// same lock is what keeps a UI read from racing them, instead of holding onto
|
|
// the *storage.Store this used to hand out (see STANDARDS: the UI reads
|
|
// Service state through typed events and accessors, never shared mutable
|
|
// state).
|
|
func (s *Service) Config() domain.Config {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.store.Config
|
|
}
|
|
|
|
// Paths returns a copy of the store's resolved filesystem paths. AppDir and
|
|
// ConfigPath are fixed for the process; JobsPath, JobsDir, and LogsDir are
|
|
// re-derived under mu on every settings save (storage.Store.applyConfigPaths),
|
|
// so this copies under the same lock as Config for the same reason.
|
|
func (s *Service) Paths() storage.Paths {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.store.Paths
|
|
}
|
|
|
|
// Jobs returns a copy of the durable jobs slice. Returning a copy keeps callers
|
|
// from mutating Service-owned state behind its back: the Service stays the sole
|
|
// writer.
|
|
func (s *Service) Jobs() []domain.Job {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
jobs := make([]domain.Job, len(s.jobs))
|
|
copy(jobs, s.jobs)
|
|
return jobs
|
|
}
|
|
|
|
// Runtime returns the transient runtime state for a job ID, or nil if no job
|
|
// with that ID is loaded. The returned pointer is the live runtime; reads of it
|
|
// are only safe while no concurrent mutation is in flight. The UI listener
|
|
// marshals reads onto the main thread via fyne.Do.
|
|
func (s *Service) Runtime(id int) *domain.JobRuntime {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
return s.runtimes[id]
|
|
}
|