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:
2026-08-06 21:44:51 +03:00
parent 98c820e3bd
commit 0c8442a8d1
12 changed files with 406 additions and 115 deletions
+13 -9
View File
@@ -136,10 +136,13 @@ example window-maximized detection, which would need per-OS native calls).
`UpdateSettings` has one extra step: when the configured jobs file changes
and a file already exists at the new path, that file is authoritative. The
Service loads it, calls `adoptJobsLocked` to rebuild the jobs slice, runtime
map, schedule cache, next-run times, and log-seeded statistics around it, and
emits `JobsLoaded` plus a broad `JobChanged`. A path with no file behind it
receives the current jobs instead. Adoption drops all runtime state, so it is
refused while a job is running.
map, schedule cache, and next-run times around it, applies the statistics
seeded from the new logs directory, and emits `JobsLoaded` plus a broad
`JobChanged`. A path with no file behind it receives the current jobs instead.
Adoption drops all runtime state, so it is refused while a job is running.
Reading the new file and seeding its statistics both happen before `mu` is
taken (the no-I/O-under-`mu` rule in [STANDARDS.md](STANDARDS.md)), so the
running-job check is re-evaluated under the lock before anything is replaced.
3. Scheduled run:
`scheduler.Scheduler` fires a tick every second. On each tick it calls
@@ -163,8 +166,9 @@ example window-maximized detection, which would need per-OS native calls).
6. History update:
When a run goroutine completes, `Service` updates the job's runtime
(including the statistics aggregate), saves JSON, triggers log cleanup, and
emits `RunRecorded`. The UI observer appends the record to the History tab.
(including the statistics aggregate) under `mu`, then — after releasing it —
runs log cleanup and emits `RunRecorded`. Nothing is saved: a run changes only
`JobRuntime`, which is never persisted. The UI observer appends the record to the History tab.
History rows exist only for the current process session; restarting the app
clears the table (aggregate stats in the details panel are still seeded from
log files).
@@ -220,9 +224,9 @@ resolves the effective duration under `mu` and `startRunLocked` snapshots it int
resolved duration as an argument, so the runner stays ignorant of the global
config: a positive duration applies the timeout via `context.WithTimeout` and
reports `Timed out after <timeout>` on expiry; a non-positive duration runs
without a deadline, bounded only by `ctx` (app shutdown). `StartOnly` jobs run on
the untimed context and so measure launch latency only, unaffected by the run
timeout.
without a deadline, bounded only by `ctx` (app shutdown). `StartOnly` jobs are
built on `context.Background()` instead — neither the timeout nor app shutdown
applies to them — and so measure launch latency only.
### Run-time statistics