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:
+13
-9
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -11,6 +11,15 @@ in [ARCHITECTURE.md](ARCHITECTURE.md); test conventions in [TESTS.md](TESTS.md).
|
||||
- Fixes with severity ≥ medium → regression test.
|
||||
- Documented intentional behavior → section below, not a backlog bug.
|
||||
- UI view constructors accept `*app.Service`; call `app.Open()` only from `run.go`.
|
||||
- **No blocking file I/O under `Service.mu`.** It 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 inside it makes a UI
|
||||
refresh wait on the disk. Mutate state under the lock, snapshot what the I/O
|
||||
needs, and run the I/O after `mu.Unlock()` — the way `emit()` already is.
|
||||
Store writes go through `Service.deferSaveLocked` and `Store.PrepareSaveJobs` /
|
||||
`Store.PrepareSaveConfig`, which take `saveMu` while `mu` is still held so
|
||||
writes still reach the file in the order their snapshots were taken; log
|
||||
cleanup and `runner.SeedStats` run from plain snapshots.
|
||||
- A size that must follow the theme is **measured at build time, not written as
|
||||
a pixel constant.** `theme.Padding()` and text metrics depend on the running
|
||||
app's theme, text size, and DPI, so a hand-tuned number is only correct for
|
||||
@@ -62,6 +71,14 @@ change to their shape has to stay compatible on its own.
|
||||
= 0) and is overridable per job (`Job.TimeoutSeconds *int`: unset = inherit the
|
||||
global default, 0 = no timeout, positive = seconds). Neither zero may be
|
||||
normalized away on load — 0 is a value, not a missing field.
|
||||
- **A `StartOnly` process is expected to outlive GoSentry.** The option exists to
|
||||
launch something and let go of it, so the runner builds that invocation on
|
||||
`context.Background()`, not on the application's lifecycle context: quitting
|
||||
GoSentry (or cancelling a run) does not stop a process it started this way, and
|
||||
`Service.Stop()` reaches only jobs the runner is still waiting on. The
|
||||
uncancelable context is also what keeps `os/exec` from leaving a watcher
|
||||
goroutine per run — it only starts one when the context can be done, and
|
||||
`StartOnly` never calls `Wait` to end it.
|
||||
- **History tab is session-only.** `JobRuntime.Logs` exists only in memory for the
|
||||
current process. Log files on disk feed aggregate statistics via `SeedStats`
|
||||
only. See [ARCHITECTURE.md](ARCHITECTURE.md).
|
||||
|
||||
@@ -166,6 +166,8 @@ Tests all mutating operations on the Service, scheduler integration, and setting
|
||||
| `TestUpdateSettingsAdoptsExistingJobsFile` | Verifies that selecting a jobs file that already exists replaces the job list with its contents, rebuilds runtimes, and emits `JobsLoaded`. |
|
||||
| `TestUpdateSettingsKeepsJobsWhenTheNewFileIsMissing` | Verifies that a path with no file behind it receives the current jobs instead (the rename/relocate case). |
|
||||
| `TestUpdateSettingsRefusesJobsFileSwitchWhileRunning` | Verifies that switching the jobs file is refused (and not persisted) while a job runs, while unrelated settings still save. |
|
||||
| `TestUpdateSettingsSeedsAdoptedJobsFromLogs` | Verifies that statistics reconstructed from the new logs directory still reach the runtime map, now that the log scan happens before `UpdateSettings` takes `mu`. |
|
||||
| `TestConcurrentJobOperationsLeaveTheFileMatchingMemory` | Verifies that saves prepared under `mu` and run after it is released still land in mutation order, so `jobs.json` matches the in-memory list after concurrent create/disable operations. |
|
||||
| `TestSetJobListViewPersistsToConfigFile` | Verifies the Jobs-list density preference reaches `gosentry.json`, so the chosen view reopens after a restart. |
|
||||
| `TestSetJobListViewNormalizesUnknownValue` | Verifies anything but `"compact"` is stored as `"detailed"`, so the config never gains a value no reader understands. |
|
||||
| `TestPrependLogCapsActivityList` | Verifies that the activity log never grows beyond its maximum cap. |
|
||||
@@ -319,6 +321,7 @@ Tests command execution, exit code handling, output capture, and the run timeout
|
||||
|------|---------|
|
||||
| `TestRunJobStartOnlyDoesNotWaitForExitCode` | Verifies that `StartOnly: true` jobs launch and return "OK" immediately without waiting for the process to exit. |
|
||||
| `TestRunJobStartOnlyReportsStartFailure` | Verifies that `StartOnly: true` jobs still report "Failed" if the process cannot be started. |
|
||||
| `TestRunJobStartOnlyLeavesNoContextWatcher` | Verifies that a start-only run leaves no `os/exec` context-watcher goroutine behind, since it never calls `Wait` and the started process is meant to outlive the app. |
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user