perf: keep file I/O off Service.mu and untie StartOnly from the app context
Phase 7 of the whole-project review (findings 3.2 and 3.3). Service.mu is the lock the Fyne main thread takes on every Jobs() and Runtime() call, so anything blocking inside it makes a UI refresh wait on the disk. Three things did: - Every SaveJobs/SaveConfig was a marshal, fsync, and rename under mu. Writes are now prepared under the lock (Store.PrepareSaveJobs / PrepareSaveConfig snapshot the payload and target path) and run after it is released. deferSaveLocked takes saveMu while mu is still held, so writes still reach the file in the order their snapshots were taken and an older snapshot can never land on top of a newer one. - executeRun ran runner.CleanupLogs under mu after every run. It needs only the values already snapshotted into runEnv, so it now runs after the unlock — including when the job is gone, since the run still wrote a log file that retention covers. - adoptJobsLocked ran runner.SeedStats under mu, reached from UpdateSettings on the UI thread. Seeding moved out into applySeededStatsLocked; UpdateSettings now reads the new jobs file and seeds its statistics before taking the lock, and re-checks the "no jobs-file switch while running" guard once it has it. SeedStats also opened every log file twice — once to find the job, again to read the result. readLogSummary reads job_id, state, and duration in one pass, so each log is opened once. StartOnly runs were built with exec.CommandContext on the app's lifecycle context. os/exec keeps a watcher goroutine alive until Wait returns or the context is done, and StartOnly never calls Wait, so one goroutine leaked per run and would then try to kill a process whose handle startJobOnly had already released. The invocation now uses context.Background(), whose nil Done channel means no watcher is started at all. Regression tests: TestRunJobStartOnlyLeavesNoContextWatcher (fails with 5 leaked goroutines on the old code), TestConcurrentJobOperationsLeaveTheFileMatchingMemory, and TestUpdateSettingsSeedsAdoptedJobsFromLogs. STANDARDS gains the no-I/O-under-mu rule and the "a StartOnly process outlives GoSentry" entry. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,8 +3,10 @@ package app
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -725,6 +727,102 @@ func TestUpdateSettingsRefusesJobsFileSwitchWhileRunning(t *testing.T) {
|
||||
waitRecord(t, done)
|
||||
}
|
||||
|
||||
// Adoption reconstructs the adopted jobs' aggregate statistics from the log
|
||||
// files the new configuration points at. That scan opens every log in the
|
||||
// directory, so UpdateSettings runs it before taking the state lock; this pins
|
||||
// that its result still reaches the runtime map.
|
||||
func TestUpdateSettingsSeedsAdoptedJobsFromLogs(t *testing.T) {
|
||||
svc := newTempService(t, []domain.Job{{ID: 1, Name: "Local", Schedule: "@every 1m", Command: "echo local", Enabled: true}})
|
||||
|
||||
logsDir := svc.store.Paths.LogsDir
|
||||
if err := os.MkdirAll(logsDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
log := "time: 2026-08-05 10:00:00\njob_id: 7\njob_name: Adopted\ntrigger: Schedule\nstate: Failed\ndetail: boom\nduration: 1500\n\nstdout:\n<empty>\n"
|
||||
if err := os.WriteFile(filepath.Join(logsDir, "20260805-100000_Adopted.log"), []byte(log), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
shared := filepath.Join(svc.store.Paths.AppDir, "shared.json")
|
||||
data, err := json.Marshal(domain.JobsFile{Jobs: []domain.Job{
|
||||
{ID: 7, Name: "Adopted", Schedule: "@every 5m", Command: "echo adopted", Enabled: true},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(shared, data, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
config := svc.store.Config
|
||||
config.JobsFile = shared
|
||||
if err := svc.UpdateSettings(config); err != nil {
|
||||
t.Fatalf("UpdateSettings: %v", err)
|
||||
}
|
||||
|
||||
runtime := svc.Runtime(7)
|
||||
if runtime == nil {
|
||||
t.Fatal("the adopted job has no runtime")
|
||||
}
|
||||
if runtime.RunCount != 1 || runtime.FailCount != 1 || runtime.LastDurationMS != 1500 {
|
||||
t.Errorf("seeded stats: RunCount=%d FailCount=%d LastDurationMS=%d, want 1/1/1500",
|
||||
runtime.RunCount, runtime.FailCount, runtime.LastDurationMS)
|
||||
}
|
||||
}
|
||||
|
||||
// Job saves run after mu is released, so one operation can be writing while
|
||||
// another mutates state. deferSaveLocked takes its own lock while mu is still
|
||||
// held, which is what keeps writes in mutation order: whatever changed the list
|
||||
// last also wrote it last, so the file ends up matching memory instead of
|
||||
// holding an older snapshot.
|
||||
func TestConcurrentJobOperationsLeaveTheFileMatchingMemory(t *testing.T) {
|
||||
svc := newTempService(t, nil)
|
||||
|
||||
const workers = 8
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < workers; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
job, err := svc.CreateJob(domain.Job{Name: fmt.Sprintf("Job %d", i), Schedule: "@every 1m", Command: "echo hi", Enabled: true})
|
||||
if err != nil {
|
||||
t.Errorf("CreateJob %d: %v", i, err)
|
||||
return
|
||||
}
|
||||
if err := svc.SetEnabled(job.ID, false); err != nil {
|
||||
t.Errorf("SetEnabled %d: %v", job.ID, err)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
memory := svc.Jobs()
|
||||
if len(memory) != workers {
|
||||
t.Fatalf("jobs in memory = %d, want %d", len(memory), workers)
|
||||
}
|
||||
saved, found, err := storage.LoadJobsFile(svc.store.Paths.JobsPath)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("read jobs file: found=%v err=%v", found, err)
|
||||
}
|
||||
if len(saved) != len(memory) {
|
||||
t.Fatalf("jobs on disk = %d, want %d: the last write must be the last mutation", len(saved), len(memory))
|
||||
}
|
||||
onDisk := make(map[int]domain.Job, len(saved))
|
||||
for _, job := range saved {
|
||||
onDisk[job.ID] = job
|
||||
}
|
||||
for _, job := range memory {
|
||||
got, ok := onDisk[job.ID]
|
||||
if !ok {
|
||||
t.Errorf("job %d (%q) is in memory but missing from the file", job.ID, job.Name)
|
||||
continue
|
||||
}
|
||||
if got.Name != job.Name || got.Enabled != job.Enabled {
|
||||
t.Errorf("job %d on disk = %q/%v, want %q/%v", job.ID, got.Name, got.Enabled, job.Name, job.Enabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrependLogCapsActivityList(t *testing.T) {
|
||||
runtime := &domain.JobRuntime{}
|
||||
for i := 0; i < maxJobLogs+10; i++ {
|
||||
|
||||
Reference in New Issue
Block a user