Skip to content

Lifecycle hardening: non-fatal SIGHUP reload + fix shutdown WaitGroup (#155, #159) - #177

Merged
johanix merged 7 commits into
mainfrom
johani/issues/155/lifecycle-hardening
Aug 24, 2026
Merged

Lifecycle hardening: non-fatal SIGHUP reload + fix shutdown WaitGroup (#155, #159)#177
johanix merged 7 commits into
mainfrom
johani/issues/155/lifecycle-hardening

Conversation

@johanix

@johanix johanix commented Jun 3, 2026

Copy link
Copy Markdown
Collaborator

What

Lifecycle-robustness fixes in main.go / config.go, clear of the snapshot PR (#174) code paths.

#155 — SIGHUP reload must be faithful, validated, and non-fatal

mainloop's SIGHUP handler re-read the config via viper.ReadInConfig() and POPExiter'd on error — an operator's config typo could take POP down.

Fixed properly (after CodeRabbit caught that a naive single-file reload validates one file but reloads another, and isn't a faithful reload of the merged config at all):

  • ValidateConfig / ValidateBySection now return errors instead of calling POPExiter. A validator that crashes the process can't be reused on a live-daemon reload; returning errors makes it composable and removes a fatal-in-recoverable-path landmine (cf. [review] Runtime-reachable log.Fatalf/POPExiter/panic on non-fatal conditions #154). Startup (main) still treats the error as fatal itself — startup behaviour unchanged.
  • loadAllConfig(v *viper.Viper) factors out main()'s real four-file load (primary + merged sources/outputs/policy), used by both startup and reload, so the reload reads exactly the same files in the same order.
  • reloadConfig loads AND validates into a throwaway viper first; only on full success does it re-apply the same load sequence to the global viper. On any failure it returns an error (never exits) and leaves the running config untouched.

Scope/caveat (documented in code): the global viper is read concurrently by other goroutines, so the final re-apply is still a concurrent mutation of shared config state — the pre-existing config-access race (design doc §5 / #157), out of scope here. The guarantee delivered: a bad reload neither kills the daemon nor replaces the good config with a broken one.

#159 — shutdown WaitGroup double-Done panic

The signal dispatcher called wg.Done() in both the exit and APIStopCh cases inside a for-loop that never returned → two shutdown signals → panic: negative WaitGroup counter. Now return after wg.Done() in both cases.

Tests

main_test.go: ValidateConfig returns errors (not os.Exit) on invalid / bad-type config; loadAllConfig errors on missing files.

Notes

Closes #155, #159.


Update: adversarial review round 2 (commit 1c41183)

Addressed docs/2026-06-03-pop-177-adversarial-review.md:

  • §2.1 partial/TOCTOU apply + §2.2 orphan keys (High): the apply step no longer re-reads files onto the live viper. New applyToGlobalViper() does viper.Reset() + AutomaticEnv() + MergeConfigMap(vtmp.AllSettings()) — copies the already-validated in-memory settings (no second disk read → no partial/TOCTOU apply) into a freshly reset global (no orphan keys from removed config entries).
  • §2.3 env parity: the throwaway viper now also calls AutomaticEnv().
  • §2.5 misleading refresh: removed the no-op RpzRefresh{Name:""} send and the false "Forcing refresh of all configured zones" log; SIGHUP now states it reloaded viper config and that new sources/policy need a restart.
  • §2.6 runtime re-apply (deferred): SIGHUP reloads viper config onlyGconfig/pd.Policy/parsed sources are not re-applied, so new sources/policy require a restart. Documented in code + tracked as [robustness] SIGHUP reloads viper config only; Gconfig/pd.Policy/sources not re-applied (needs restart) #178.

New test TestApplyToGlobalViperReplacesAndDropsOrphans pins the replace-not-merge / orphan-drop behaviour.

Honest scope (per the review's bottom line): this delivers a safe, faithful, validated viper reload — not full POP re-initialization. The runtime re-apply (#178) and the config-access race (#157) remain deferred.

Summary by CodeRabbit

  • Enhancements

    • Configuration is now loaded consistently at startup and when reloaded.
    • Configuration reloads are validated before being applied, preventing invalid changes from disrupting the running service.
    • Successful reloads fully replace outdated settings, including removing settings no longer present.
    • Reloading configuration no longer triggers an unnecessary zone refresh.
  • Bug Fixes

    • Configuration errors now provide clearer, more actionable messages.
    • Missing or invalid configuration files are handled without replacing the active configuration.

…#155, #159)

#155: SIGHUP config reload could kill a running daemon. mainloop re-read the
config via viper.ReadInConfig() and called POPExiter on any error, so an
operator pushing a config typo and sending SIGHUP would take POP down — a
daemon serving security policy to third-party users must not die on a bad
reload. Extracted reloadConfig(): it reads the file into a THROWAWAY viper
first (so a malformed file cannot corrupt the live global config), and only
on success re-reads into the global viper. On any failure it returns an error
that the SIGHUP handler logs while keeping the running config unchanged.
(Full re-validation / re-apply of sources/outputs/policy on reload is left to
the larger config-application rework; the guarantee here is "a bad reload
neither kills the daemon nor corrupts the running config".)

#159: the mainloop signal dispatcher called wg.Done() in both the exit and
APIStopCh cases inside a for-loop that never returned, so if two shutdown
signals arrived (e.g. SIGTERM racing an API stop) wg.Done() would be called
more than wg.Add(1) -> panic: negative WaitGroup counter. Return after
wg.Done() in both shutdown cases so it is called exactly once.

Adds main_test.go: TestReloadConfig (valid reload applies; malformed reload
errors AND does not corrupt the live config; missing file errors).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@johanix
johanix requested a review from a team as a code owner June 3, 2026 11:00
@coderabbitai

coderabbitai Bot commented Jun 3, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

Next included review available in 57 minutes.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: b4427f98-2373-40b6-a427-4eff39ffb190

📥 Commits

Reviewing files that changed from the base of the PR and between fe680c9 and a193a3b.

📒 Files selected for processing (2)
  • main.go
  • main_test.go

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: d77dd7e5-10bd-4b2f-a4d5-9bdcb1e9a41f

📥 Commits

Reviewing files that changed from the base of the PR and between ef899a1 and fe680c9.

📒 Files selected for processing (3)
  • config.go
  • main.go
  • main_test.go

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

The PR centralizes startup and SIGHUP configuration loading. Temporary Viper validation runs before live settings change. Validation failures return errors and preserve the running configuration. MQTT setup and source parsing error handling are also updated.

Changes

Configuration loading and reload

Layer / File(s) Summary
Non-fatal configuration validation
config.go
ValidateConfig and ValidateBySection now return contextual errors for unmarshal and validation failures instead of terminating through POPExiter.
Shared loading and atomic reload
main.go
loadAllConfig validates complete configuration data before replacing live settings. Startup uses this loader. SIGHUP reloads configuration without forcing a zone refresh. MQTT startup uses SetupMqtt, and source parsing errors identify the sources configuration.
Validation and replacement tests
main_test.go
Tests cover returned validation errors, missing configuration files, and replacement semantics that remove orphaned keys from the live configuration.

Estimated code review effort: 4 (Complex) | ~45 minutes

Merge Risk: ⚪ Minimal · up to fe680

The change makes configuration reload failures non-fatal and fixes duplicate shutdown signaling while preserving the existing startup behavior; no actionable merge-blocking risk remains after normal checks and review.

Suggested reviewers: berrabou

Poem

🐰 Viper checks the files before settings change,
Invalid values stay outside the running range.
SIGHUP reloads with careful control,
Orphaned keys leave the configuration whole.
MQTT starts through its setup call. ✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main changes: non-fatal SIGHUP reloads and shutdown WaitGroup handling.
Docstring Coverage ✅ Passed Docstring check was indeterminate for this PR — some files could not be analyzed in time. Not blocking.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@johanix

johanix commented Jun 3, 2026

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 3, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@main.go`:
- Around line 65-76: reloadConfig validates the provided configfile using a
temporary viper (vtmp) but then calls viper.ReadInConfig() on the global viper
without directing it to the same file, which can cause a mismatch; modify
reloadConfig so that after vtmp.ReadInConfig() succeeds you set the global viper
to use the same validated file (e.g., call viper.SetConfigFile(configfile))
before calling viper.ReadInConfig(), or alternatively read values from vtmp and
merge/unmarshal them into the global viper, and preserve the existing error
wrapping for both read attempts (referencing reloadConfig, vtmp, and
viper.ReadInConfig).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ef845788-b9a7-4dfa-ac63-657d64ac9d65

📥 Commits

Reviewing files that changed from the base of the PR and between 321e644 and ef899a1.

📒 Files selected for processing (2)
  • main.go
  • main_test.go

Comment thread main.go
CodeRabbit on PR #177 caught that the first cut of reloadConfig validated
`configfile` in a throwaway viper but then re-read the GLOBAL viper without
setting the file — so it validated one file and reloaded whatever the global
viper last had set (PopPolicyCfgFile). Worse, a single ReadInConfig is not a
faithful reload at all: main() builds the live config by MERGING four files
(primary + sources + outputs + policy), so reloading one of them silently
ignored the other three.

Rather than ship a misleading partial reload, make it correct:

- ValidateConfig / ValidateBySection now RETURN errors instead of calling
  POPExiter. A validator that crashes the process could never be reused on a
  live-daemon reload; returning errors makes it composable (and removes a
  fatal-in-recoverable-path landmine, cf. #154). Startup callers (main) still
  treat the error as fatal themselves, so startup behaviour is unchanged.

- Factor main()'s four-file load sequence into loadAllConfig(v *viper.Viper),
  used by BOTH startup and reload, so the reload reads exactly the same files
  in the same order.

- reloadConfig now loads AND validates into a throwaway viper first; only if
  that fully succeeds does it re-apply the same load sequence to the global
  viper. On any failure it returns an error (never exits) and the running
  config is left untouched.

Scope/caveat (documented at reloadConfig): the global viper is read
concurrently by other goroutines, so the final re-apply is still a concurrent
mutation of shared config state — the pre-existing config-access race
(design doc §5 / #157), out of scope here. The guarantee: a bad reload neither
kills the daemon nor replaces the good config with a broken one.

Tests retargeted to the now-testable units: ValidateConfig returns errors
(not os.Exit) on invalid/bad-type config; loadAllConfig errors on missing
files.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@johanix

johanix commented Jun 3, 2026

Copy link
Copy Markdown
Collaborator Author

Adversarial review (expanded SIGHUP scope)

Reviewed pr-177-review (commits through de84691) vs main. Local: go test -vet=off ./... passes. Context: architectural review §3.2, refactoring proposal §4.


Executive summary

The expanded PR is a material improvement over the first cut and addresses the CodeRabbit critique correctly:

Verdict: LGTM to merge for #155 (no fatal SIGHUP) and #159 (WaitGroup), as a solid refactoring-proposal §4 partialfaithful viper reload with validate-then-apply.

Do not treat this as “operators can SIGHUP and POP fully adopts new config” until the runtime gaps below are documented or fixed in follow-ups.

The throwaway path is sound; the live global apply path still has correctness and operational holes (non-atomic apply, possible stale viper keys, no Gconfig refresh, empty zone refresh is a no-op).


What holds up

Claim Assessment
#155 — bad SIGHUP does not POPExiter Fixed
Faithful file reload (four files, same order as startup) Fixed via loadAllConfig
Validate before applying to live config Fixed on throwaway + ValidateConfig(vtmp, …)
#159 — double wg.Done() Fixed (return after Done)
Startup unchanged Yesmain() still fatals on load/validate failure
Validator composable on live daemon Fixed
CI / unit tests Green; validator + missing-file tests

Findings (in scope for #177)

1. Live global apply is not atomic (can violate “unchanged on failure”) — High

reloadConfig validates on a fresh vtmp, then runs loadAllConfig(viper.GetViper()) in four steps without viper.Reset():

loadAllConfig(vtmp)              // all-or-nothing on empty viper
ValidateConfig(vtmp, ...)
loadAllConfig(viper.GetViper())  // stepwise on LIVE global

If global re-apply fails on step 2–4 (TOCTOU, transient I/O), global viper may already be partially overwritten by the primary ReadInConfig. The error path may not preserve “keeping running config unchanged.”

Suggestion: viper.Reset() + viper.AutomaticEnv() (match startup), then loadAllConfig on global; or copy merged state from vtmp in one step. At minimum document the partial-apply edge.

2. Global reload may leave stale viper keys — High

vtmp starts empty (consistent validated view). Global viper is not cleared before re-apply. Keys removed from all YAML files can linger in global viper after a “successful” reload.

Suggestion: same viper.Reset() before global apply.

3. Throwaway validation omits AutomaticEnv()Low–medium

Startup: AutomaticEnv() then loadAllConfig. Reload validates vtmp without env. Validation is files-only; live Get() still applies env overrides — possible pass-on-disk vs different-runtime behaviour.

Suggestion: vtmp.AutomaticEnv() in reloadConfig, or document.

4. SIGHUP zone refresh is a no-op — Medium

After success:

conf.PopData.RpzRefreshCh <- RpzRefresh{Name: ""}

In refreshengine.go, if zone != "" guards all refresh logic — empty name does nothing, while the log says “Forcing refresh of all configured zones.” Pre-existing; misleading after “full reload” messaging.

5. Gconfig / runtime state not updated — documented, still important

Successful SIGHUP updates global viper only — not Unmarshal(&Gconfig), SetupLogging, ParseSourcesNG, ParseOutputs, or pd.Policy. Much of POP reads viper at runtime; other paths use startup-filled Gconfig / PopData. Operator “full reload” often means new sources/policy take effect — not delivered here; keep explicit in PR/release notes (rest of §4 + engine re-apply).

6. Concurrent viper mutation during apply — documented

Pre-existing #157 / design §5 race; validate-then-apply does not fix it. Fine out of scope if documented.

7. Logs / tests — Low–medium

  • reloadConfig(configfile) — path is diagnostic only; consider logging “full config reload (primary + sources + outputs + policy)”
  • First commit had TestReloadConfig; expanded PR dropped integration coverage for reloadConfig (orphan keys, reject-without-corrupt). Validator tests are good; a temp four-file tree test would help.

Merge gates (scoped to #177)

Met: no fatal SIGHUP; same four files as startup; invalid config rejected on throwaway; validator returns errors; #159 WaitGroup.

Recommend before/just after merge (or immediate follow-up):

Item Severity
viper.Reset() (+ AutomaticEnv) before global loadAllConfig in reloadConfig High
Document or fix SIGHUP zone refresh (Name: "" no-op) Medium
reloadConfig integration test Medium

Still deferred (proposal §4 / §1): Gconfig / lists / engine re-apply; config-access race #157; graceful shutdown §7.


Bottom line

Expanded scope is the right fix for #155 at the configuration layer. Merge with eyes open: operators get safe, validated viper reload, not full POP re-initialization. viper.Reset() before live apply (and honest SIGHUP refresh messaging) are the highest-value follow-ups so “full reload” is not overstated.


Re-reviewed after commit de84691 (“SIGHUP reload: full, validated, non-fatal config reload”).

…ing (#155)

Addresses the adversarial review of PR #177 (docs/2026-06-03-pop-177-adversarial-review.md):

- §2.1 (partial/TOCTOU apply) + §2.2 (orphan keys): the previous code applied
  the validated config by re-running loadAllConfig() on the LIVE global viper —
  a second disk read (could fail mid-sequence after partially overwriting the
  live config) that also MERGED rather than replaced (keys deleted from the new
  files lingered as orphans). Both broke the "running config unchanged on
  failure / faithfully replaced on success" guarantee. New applyToGlobalViper()
  does viper.Reset() + AutomaticEnv() + MergeConfigMap(vtmp.AllSettings()):
  copies the already-validated in-memory settings (no second file read -> no
  TOCTOU/partial apply) into a freshly reset global (no orphans).

- §2.3 (env parity): the throwaway viper now also calls AutomaticEnv(), so
  validation sees the same env overrides startup does.

- §2.5 (misleading log / no-op refresh): SIGHUP previously logged "Forcing
  refresh of all configured zones" and sent RpzRefresh{Name: ""}, which the
  engine ignores (guards on zone != ""). Removed the no-op send and corrected
  the message to state that viper config was reloaded and that new
  sources/policy require a restart to take effect.

- §2.6 (deferred, now documented + tracked as #178): SIGHUP reloads viper
  config only; Gconfig/pd.Policy/parsed sources are not re-applied. Documented
  at reloadConfig and called out in the SIGHUP log.

Adds TestApplyToGlobalViperReplacesAndDropsOrphans (new value applies, orphan
key dropped).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@johanix

johanix commented Jun 3, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed the round-2 adversarial review (docs/2026-06-03-pop-177-adversarial-review.md) in 1c41183:

  • §2.1 partial/TOCTOU + §2.2 orphans (High): apply no longer re-reads files onto the live viper. applyToGlobalViper() now does viper.Reset() + AutomaticEnv() + MergeConfigMap(vtmp.AllSettings()) — copies the already-validated in-memory settings (no second disk read → no partial/TOCTOU) into a freshly reset global (no orphan keys). New test TestApplyToGlobalViperReplacesAndDropsOrphans pins it.
  • §2.3: throwaway viper now calls AutomaticEnv() for env parity.
  • §2.5: removed the no-op RpzRefresh{Name:""} and the false 'forcing refresh' log; SIGHUP now truthfully says it reloaded viper config and that new sources/policy need a restart.
  • §2.6 (deferred + tracked [robustness] SIGHUP reloads viper config only; Gconfig/pd.Policy/sources not re-applied (needs restart) #178): documented that SIGHUP reloads viper config only; Gconfig/pd.Policy/sources re-apply is follow-up work.

Scope is now stated honestly in the PR body: a safe, faithful, validated viper reload — not full re-initialization. Thanks again — §2.1/§2.2 were genuine holes in my own 'unchanged on failure' claim.

@morkrost morkrost left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks ok

TestLoadAllConfigMissingFileErrors asserted that loading fails because pop's
real config paths do not exist. That is an assertion about the machine, not
about the code, and it does not hold on any host where pop is configured.

It collides head-on with the integration rig. pop insists its configuration
lives in one hardcoded directory -- deliberately, to avoid stale and conflicting
configs -- so the rig has nowhere else to write it. Run the rig and then the
unit suite on the same machine, or simply run `go test ./...` with both present,
and this test fails while nothing is wrong:

    main_test.go:66: loadAllConfig with no config files present = nil, want error

The file list moves into a package var so the test can point it at a directory
it owns and leaves empty. Hermetic, and it no longer matters what is in /etc.
@johanix
johanix merged commit e7e2f51 into main Aug 24, 2026
6 checks passed
@johanix
johanix deleted the johani/issues/155/lifecycle-hardening branch August 24, 2026 14:29
johanix added a commit that referenced this pull request Aug 24, 2026
rig: verify the #177 reload guarantees against a live daemon

Bypassing rules: this adds zero code to POP, it is only testing infrastructure.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[review] SIGHUP config reload aborts via POPExiter — operator typo kills daemon

2 participants