fix(healthcheck): prevent race condition on the healthchecker (#3400)

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Quentin McGaw <quentin.mcgaw@gmail.com>

Thanks also to @arturhoo for also trying to resolve this issue in #3410
This commit is contained in:
Matt Van Horn
2026-07-28 19:22:20 -07:00
committed by Quentin McGaw
parent cccd7c4c1b
commit c0073c9567
3 changed files with 18 additions and 8 deletions
-6
View File
@@ -8,7 +8,6 @@ import (
"net" "net"
"net/netip" "net/netip"
"strings" "strings"
"sync"
"time" "time"
"github.com/qdm12/gluetun/internal/healthcheck/dns" "github.com/qdm12/gluetun/internal/healthcheck/dns"
@@ -24,7 +23,6 @@ type Checker struct {
icmpTargetIPs []netip.Addr icmpTargetIPs []netip.Addr
smallCheckType string smallCheckType string
startupOnFail bool startupOnFail bool
configMutex sync.Mutex
icmpNotPermitted *bool icmpNotPermitted *bool
@@ -55,8 +53,6 @@ func NewChecker(logger Logger) *Checker {
func (c *Checker) SetConfig(tlsDialAddrs []string, icmpTargets []netip.Addr, func (c *Checker) SetConfig(tlsDialAddrs []string, icmpTargets []netip.Addr,
smallCheckType string, startupOnFail bool, smallCheckType string, startupOnFail bool,
) { ) {
c.configMutex.Lock()
defer c.configMutex.Unlock()
c.tlsDialAddrs = tlsDialAddrs c.tlsDialAddrs = tlsDialAddrs
c.icmpTargetIPs = icmpTargets c.icmpTargetIPs = icmpTargets
c.smallCheckType = smallCheckType c.smallCheckType = smallCheckType
@@ -166,10 +162,8 @@ func (c *Checker) Stop() error {
} }
func (c *Checker) smallPeriodicCheck(ctx context.Context) error { func (c *Checker) smallPeriodicCheck(ctx context.Context) error {
c.configMutex.Lock()
icmpTargetIPs := make([]netip.Addr, len(c.icmpTargetIPs)) icmpTargetIPs := make([]netip.Addr, len(c.icmpTargetIPs))
copy(icmpTargetIPs, c.icmpTargetIPs) copy(icmpTargetIPs, c.icmpTargetIPs)
c.configMutex.Unlock()
tryTimeouts := []time.Duration{ tryTimeouts := []time.Duration{
5 * time.Second, 5 * time.Second,
5 * time.Second, 5 * time.Second,
+7
View File
@@ -43,6 +43,7 @@ type Loop struct {
start <-chan struct{} start <-chan struct{}
running chan<- models.LoopStatus running chan<- models.LoopStatus
userTrigger bool userTrigger bool
healthDone <-chan struct{}
// Internal constant values // Internal constant values
backoffTime time.Duration backoffTime time.Duration
} }
@@ -68,6 +69,11 @@ func NewLoop(vpnSettings settings.VPN, ipv6Supported bool, vpnInputPorts []uint1
statusManager := loopstate.New(constants.Stopped, start, running, stop, stopped) statusManager := loopstate.New(constants.Stopped, start, running, stop, stopped)
state := state.New(statusManager, vpnSettings) state := state.New(statusManager, vpnSettings)
// Initialize healthDone channel to a closed channel so that the first time
// we call <-l.healthDone it does not block
healthDone := make(chan struct{})
close(healthDone)
return &Loop{ return &Loop{
statusManager: statusManager, statusManager: statusManager,
state: state, state: state,
@@ -95,6 +101,7 @@ func NewLoop(vpnSettings settings.VPN, ipv6Supported bool, vpnInputPorts []uint1
stop: stop, stop: stop,
stopped: stopped, stopped: stopped,
userTrigger: true, userTrigger: true,
healthDone: healthDone,
backoffTime: defaultBackoffTime, backoffTime: defaultBackoffTime,
} }
} }
+11 -2
View File
@@ -31,6 +31,7 @@ func (l *Loop) onTunnelUp(ctx, loopCtx context.Context, data tunnelUpData) {
} }
} }
<-l.healthDone // make sure the health checker is stopped before restarting it
icmpTargetIPs := l.healthSettings.ICMPTargetIPs icmpTargetIPs := l.healthSettings.ICMPTargetIPs
if len(icmpTargetIPs) == 1 && icmpTargetIPs[0].IsUnspecified() { if len(icmpTargetIPs) == 1 && icmpTargetIPs[0].IsUnspecified() {
icmpTargetIPs = []netip.Addr{data.serverIP} icmpTargetIPs = []netip.Addr{data.serverIP}
@@ -54,7 +55,12 @@ func (l *Loop) onTunnelUp(ctx, loopCtx context.Context, data tunnelUpData) {
// Start collecting health errors asynchronously, since // Start collecting health errors asynchronously, since
// we should not wait for the code below to complete // we should not wait for the code below to complete
// to start monitoring health and auto-healing. // to start monitoring health and auto-healing.
go l.collectHealthErrors(ctx, loopCtx, healthErrCh) // We keep track of when this goroutine is done with the healthDone
// channel to avoid a race condition where the health checker is stopped
// after being reconfigured and restarted, instead of before.
healthDone := make(chan struct{})
l.healthDone = healthDone
go l.collectHealthErrors(ctx, loopCtx, healthDone, healthErrCh)
if *l.dnsLooper.GetSettings().ServerEnabled { if *l.dnsLooper.GetSettings().ServerEnabled {
_, _ = l.dnsLooper.ApplyStatus(ctx, constants.Running) _, _ = l.dnsLooper.ApplyStatus(ctx, constants.Running)
@@ -86,7 +92,10 @@ func (l *Loop) onTunnelUp(ctx, loopCtx context.Context, data tunnelUpData) {
} }
} }
func (l *Loop) collectHealthErrors(ctx, loopCtx context.Context, healthErrCh <-chan error) { func (l *Loop) collectHealthErrors(ctx, loopCtx context.Context,
done chan<- struct{}, healthErrCh <-chan error,
) {
defer close(done)
var previousHealthErr error var previousHealthErr error
for { for {
select { select {