Final state persistence at shutdown depends on implicit fx hook ordering and is untested #114
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Surfaced while implementing #106 and deliberately kept out of that PR's scope.
The README promises (
README.md:454-456) that shutdown persists final state to disk. It does — but not for the reason the README implies, and nothing verifies it.Current state (audited against
origin/main, commit9347a28)The watcher does not save on its own shutdown path:
internal/watcher/watcher.go:93-98— the watcher'sOnStophook only callsw.cancel().internal/watcher/watcher.go:148-151— theRunloop'sctx.Done()branch logs"watcher stopped"and returns. NosaveState().The final persist comes entirely from a different package's hook:
internal/state/state.go:153-155registers anOnStopthat callsstate.Save().That works only because fx runs
OnStophooks in reverse registration order, andcmd/dnswatcher/main.go:41-47happens to providestate.Newbeforenotify.Newandwatcher.New. So state'sOnStopruns after the watcher's, and any in-memory mutation from the last check cycle is still captured.The behaviour is correct today. The problems are that it is implicit and unverified:
main.go. Reordering thosefx.Providelines — a change that looks entirely cosmetic — could silently invert the hook order and start losing the last cycle's observations.internal/watcher'sTestGracefulShutdowncovers clean exit, not durability of the last write.This is a durability property of a monitoring daemon — the last thing observed before a restart is exactly the baseline that determines whether the next run emits a false-positive change notification. It should not rest on an undocumented accident of declaration order.
Definition of done
The ordering guarantee is made explicit rather than incidental. Either:
ctx.Done()branch or itsOnStop), so correctness no longer depends on another package's hook running afterwards; orinternal/state/state.go:153-155andinternal/watcher/watcher.go:93-98naming the dependency, plus a comment incmd/dnswatcher/main.gowarning that provider order is load-bearing.Prefer the first — a guarantee enforced by code beats a guarantee enforced by a comment. If you pick it, make sure state is not saved twice in a way that could race;
State.Save()takes the lock, but confirm.A test asserts the durability property directly: run a check cycle that mutates state, trigger shutdown, and verify the mutation is present in the persisted file afterwards. This must fail if the save is removed.
If provider order in
main.goremains load-bearing after your change, a test or comment must make that explicit enough that a future reorder is caught.README wording updated so the shutdown description matches the actual mechanism.
make checkgreen;make testwall time stays well under the 20-second ceiling;TODO.mdupdated in the same commit.The finishing commit's title must end with
(closes #N)referencing this issue.Hard constraints
mockResolverandresolver.NewFromLoggerWithCliententirely and moves the watcher tests onto the real iterative resolver against live DNS, withTESTING.md's live-DNS policy applying to every package. Do not add, restore, or recreate any fake/stub DNS client, resolver, transport, or nameserver. The port checker, TLS checker, and notifier remain legitimate test doubles — they are not DNS..golangci.yml; do not change the golangci-lint pin.Sequencing
Blocked until PR #97 merges. #97 rewrites
internal/watcher/watcher_test.gowholesale (+409/-483), and the test required by item 2 lands in that file. Starting before #97 merges guarantees the test is destroyed in the rebase.Also sequenced after #106 (notification drain), which touches the same shutdown sequence from the
internal/notifyside.