Skip to content

fix(healthcheck): guard periodic checks against divide-by-zero when config is cleared#3400

Open
mvanhorn wants to merge 1 commit into
passteque:masterfrom
mvanhorn:fix/3398-healthcheck-divide-by-zero
Open

fix(healthcheck): guard periodic checks against divide-by-zero when config is cleared#3400
mvanhorn wants to merge 1 commit into
passteque:masterfrom
mvanhorn:fix/3398-healthcheck-divide-by-zero

Conversation

@mvanhorn

Copy link
Copy Markdown

Description

The healthcheck goroutine can panic with panic: runtime error: integer divide by zero (stack at internal/healthcheck/checker.go in fullPeriodicCheck).

fullPeriodicCheck indexed the shared config slice directly with c.tlsDialAddrs[try%len(c.tlsDialAddrs)]. Checker.Stop() clears the config by setting c.tlsDialAddrs = nil (and icmpTargetIPs / smallCheckType) — but did so without holding c.configMutex. A fullPeriodicCheck still in flight during or just after Stop() then evaluates try % len(c.tlsDialAddrs) with len == 0 and panics. It is also a data race: fullPeriodicCheck read c.tlsDialAddrs without the mutex while Stop/SetConfig wrote it. smallPeriodicCheck already copies c.icmpTargetIPs under the mutex, but its icmpTargetIPs[try%len(icmpTargetIPs)] had the same latent divide-by-zero when that copy is empty (Stop nils icmpTargetIPs too).

This change:

  • Stop() now takes c.configMutex while clearing the config, synchronizing the clear with the readers.
  • fullPeriodicCheck() copies c.tlsDialAddrs into a local slice under c.configMutex (mirroring smallPeriodicCheck) and returns early when the copy is empty — there is nothing to dial — instead of dividing by zero. The check closure indexes the local copy.
  • smallPeriodicCheck() returns early when the (already-copied) ICMP target slice is empty and the check type is not DNS, so the copied-slice modulo cannot divide by zero either. The DNS-fallback path is unchanged.

Behavior is identical for a normally-running checker with non-empty config; the guards only affect the teardown / empty-config window.

Verified with go test ./internal/healthcheck/..., go test -race ./internal/healthcheck/..., go build ./..., and gofmt -l (clean). A regression test exercises the periodic checks with empty target slices and asserts they no longer panic.

Issue

Closes #3398

Assertions

  • I am aware that any changes to settings should be reflected in the wiki — N/A, this change adds no settings and does not alter existing settings.

Comment on lines +162 to +163
c.configMutex.Lock()
defer c.configMutex.Unlock()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Because above we run c.stop() to cancel the long running goroutine and wait for <-c.done, the long running goroutine should be finished and so there should be no need to hold a mutex here I think. I'll dig into this, to avoid these AI-bandaid-fixes (that do the job, but it does bloat code whereas it does not fix the real source of the problem)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

You're right that after c.stop() and <-c.done the periodic goroutine can't touch these fields, so the lock in Stop isn't about that goroutine. What it guards is SetConfig: Stop runs on the collectHealthErrors goroutine (started at tunnelup.go:107, calls Stop at 148/156) while SetConfig runs on the VPN loop goroutine in onTunnelUp (tunnelup.go:88). If the loop state machine already guarantees those never overlap across a restart cycle, then the Stop hunk is redundant and I'm happy to drop it.

The part I'd keep either way is fullPeriodicCheck: on master it indexes c.tlsDialAddrs unlocked, while smallPeriodicCheck copies under the same mutex for the same fields, so this just makes the two checks consistent. The empty-slice guards also protect the try%len modulo from a divide-by-zero panic when the config is empty.

Happy to trim the PR to whichever subset you want after you've dug in.

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.

Bug: panic: runtime error: integer divide by zero

2 participants