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
+12
View File
@@ -39,6 +39,11 @@ the app icon (experimental).**
longer than its own interval no longer accumulates an unbounded backlog that
then runs back-to-back indefinitely. The job details pane now shows the
queued-run count (", N queued") whenever it is non-zero.
- **Start-only jobs are no longer tied to the application's lifetime.** A job
with *Start only* checked is launched on an uncancelable context, so quitting
GoSentry (or a run context being cancelled) can no longer try to kill a
process it deliberately stopped waiting for. This also removes a goroutine
that leaked on every start-only run and lived until the app exited.
- The History tab no longer grows without bound: it keeps the newest 1000
records and drops the oldest, the way a job's own activity list is capped.
Column widths are also folded in one record at a time instead of being
@@ -64,6 +69,13 @@ the app icon (experimental).**
- App-side failure-notification timing is appended to `logs/notify-timing.log`
for diagnosing toast delay (OS latency excluded). `scripts/measure-windows-toast.ps1`
measures the PowerShell baseline on Windows.
- File I/O no longer happens while `Service.mu` is held — that is the lock the
UI thread takes on every job and runtime read, so a JSON write, the
post-run log cleanup, or the startup log scan used to make a UI refresh wait
on the disk. Saves are now prepared under the lock and written after it is
released, in preparation order, so `jobs.json` still ends up matching the
in-memory list. Seeding statistics from logs also opens each log file once
instead of twice.
## 1.0.1 - 2026-08-04