Skip to content

Commit 7a854c2

Browse files
committed
BUG/MINOR: integrate exec probes with daemon reaper
Previously, exec health check probes could pile up as zombie processes or have their exit statuses stolen by the main daemon's reap loop. This caused probes to starve or fail incorrectly, which could trigger unnecessary service restarts. This change: - Integrates the `Checker` with the `reaper.Registry` to ensure exec probe children are properly reaped and their exit statuses are correctly delivered back to the checker. - Introduces `ErrInconclusive` to handle lost probe results gracefully, logging them without incrementing the failure streak. - Refactors the failure observation logic in `Checker.Run` into a dedicated `observe` method. - Adds an E2E test to verify that a childless daemon properly reaps exec probes without leaving zombies.
1 parent 48743f7 commit 7a854c2

8 files changed

Lines changed: 718 additions & 30 deletions

File tree

check/check.go

Lines changed: 110 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ package check
1717

1818
import (
1919
"context"
20+
"errors"
2021
"fmt"
2122
"io"
2223
"log"
@@ -28,10 +29,17 @@ import (
2829
"sync/atomic"
2930
"syscall"
3031
"time"
32+
33+
"github.com/haproxytech/gopherd/internal/reaper"
3134
)
3235

3336
var errNoCheckType = fmt.Errorf("no check type configured")
3437

38+
// ErrInconclusive marks a probe whose result was lost (e.g. the reap loop
39+
// stole the exec child's exit status). Carries no health data: the run loop
40+
// logs it without touching the failure streak.
41+
var ErrInconclusive = fmt.Errorf("probe result lost")
42+
3543
// HTTP defines an HTTP health check.
3644
type HTTP struct {
3745
URL string
@@ -67,6 +75,7 @@ type Checker struct {
6775
onFailureFn func(checkName string) // called when threshold breached
6876
metricsFn func(checkName string, ok bool) // called after every check
6977
credential *syscall.Credential // optional: run exec checks as this user
78+
reaper *reaper.Registry // optional: reap loop delivers exec exit statuses
7079
httpClient *http.Client
7180
httpReq *http.Request // cached base request, cloned per-check
7281
name string
@@ -183,31 +192,7 @@ func (c *Checker) Run() {
183192
defer ticker.Stop()
184193

185194
for {
186-
err := c.Execute()
187-
188-
c.mu.Lock()
189-
var callFailure bool
190-
if err != nil {
191-
c.failures++
192-
if c.metricsFn != nil {
193-
c.metricsFn(c.name, false)
194-
}
195-
if c.failures >= c.threshold && c.healthy {
196-
c.healthy = false
197-
log.Printf("check %s: unhealthy (%d consecutive failures): %v", c.name, c.failures, err)
198-
callFailure = true
199-
}
200-
} else {
201-
if c.metricsFn != nil {
202-
c.metricsFn(c.name, true)
203-
}
204-
if !c.healthy {
205-
log.Printf("check %s: healthy again", c.name)
206-
}
207-
c.failures = 0
208-
c.healthy = true
209-
}
210-
c.mu.Unlock()
195+
callFailure := c.observe(c.Execute())
211196
// Call onFailureFn outside c.mu to avoid lock-order inversion:
212197
// onFailureFn acquires d.mu, which is also held when stopChecks()
213198
// calls c.Stop(). The stopped guard prevents a callback queued just
@@ -231,6 +216,41 @@ func (c *Checker) Run() {
231216
}()
232217
}
233218

219+
// observe applies one probe result to the health state and reports whether
220+
// the failure threshold was just crossed.
221+
func (c *Checker) observe(err error) bool {
222+
// A lost result is not a failed probe: counting it toward the threshold
223+
// would restart healthy services (the bug this guards against).
224+
if err != nil && errors.Is(err, ErrInconclusive) {
225+
log.Printf("check %s: inconclusive probe (not counted): %v", c.name, err)
226+
return false
227+
}
228+
c.mu.Lock()
229+
defer c.mu.Unlock()
230+
var callFailure bool
231+
if err != nil {
232+
c.failures++
233+
if c.metricsFn != nil {
234+
c.metricsFn(c.name, false)
235+
}
236+
if c.failures >= c.threshold && c.healthy {
237+
c.healthy = false
238+
log.Printf("check %s: unhealthy (%d consecutive failures): %v", c.name, c.failures, err)
239+
callFailure = true
240+
}
241+
} else {
242+
if c.metricsFn != nil {
243+
c.metricsFn(c.name, true)
244+
}
245+
if !c.healthy {
246+
log.Printf("check %s: healthy again", c.name)
247+
}
248+
c.failures = 0
249+
c.healthy = true
250+
}
251+
return callFailure
252+
}
253+
234254
// WaitReady runs the check in a loop until it passes once or ctx is cancelled.
235255
// The poll interval is capped at 1s so a long period (e.g. 30s) does not stall
236256
// startup for a full period between tries.
@@ -334,6 +354,12 @@ func (c *Checker) checkTCP(ctx context.Context) error {
334354
}
335355

336356
func (c *Checker) checkExec(ctx context.Context) error {
357+
// The reap loop's Wait4(-1) steals exit statuses from cmd.Wait (ECHILD),
358+
// so once it runs, statuses must come from it. Before Activate (startup
359+
// sequence) there is no competing waiter and cmd.Run is safe.
360+
if c.reaper != nil && c.reaper.Active() {
361+
return c.checkExecReaped(ctx)
362+
}
337363
cmd := exec.CommandContext(ctx, c.cfg.Exec.Command, c.cfg.Exec.Args...)
338364
// Setsid gives the check its own process group (so Kill(-pid) on cancel
339365
// reaches all descendants, I2) and no controlling TTY (blocks TIOCSTI
@@ -361,11 +387,70 @@ func (c *Checker) checkExec(ctx context.Context) error {
361387
}
362388
cmd.WaitDelay = c.timeout
363389
if err := cmd.Run(); err != nil {
390+
// ECHILD means a concurrent Wait4(-1) (the reap loop) stole the exit
391+
// status: the probe's real outcome is unknowable, not a failure.
392+
if errors.Is(err, syscall.ECHILD) {
393+
return fmt.Errorf("exec check: %w (%v)", ErrInconclusive, err)
394+
}
364395
return fmt.Errorf("exec check: %w", err)
365396
}
366397
return nil
367398
}
368399

400+
// checkExecReaped forks the probe and takes its exit status from the reap
401+
// loop via the registry, never waiting on the pid itself.
402+
func (c *Checker) checkExecReaped(ctx context.Context) error {
403+
cmd := exec.Command(c.cfg.Exec.Command, c.cfg.Exec.Args...)
404+
// Same containment as the standalone path: own process group, no TTY.
405+
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
406+
if c.credential != nil {
407+
cmd.SysProcAttr.Credential = c.credential
408+
}
409+
pid, status, err := c.reaper.Start(func() (int, error) {
410+
if err := cmd.Start(); err != nil {
411+
return 0, err
412+
}
413+
return cmd.Process.Pid, nil
414+
})
415+
if err != nil {
416+
return fmt.Errorf("exec check: %w", err)
417+
}
418+
defer c.reaper.Forget(pid)
419+
// The reap loop owns the wait; just close the process handle.
420+
defer func() { _ = cmd.Process.Release() }()
421+
422+
select {
423+
case code := <-status:
424+
if code != 0 {
425+
return fmt.Errorf("exec check: exit status %d", code)
426+
}
427+
return nil
428+
case <-ctx.Done():
429+
// Kill the whole group so forked grandchildren die too. ESRCH means
430+
// the leader already exited; its status is still in flight below.
431+
_ = syscall.Kill(-pid, syscall.SIGKILL)
432+
timer := time.NewTimer(c.timeout)
433+
defer timer.Stop()
434+
select {
435+
case code := <-status:
436+
if code == 0 {
437+
// Finished cleanly right at the deadline: keep the real result.
438+
return nil
439+
}
440+
return fmt.Errorf("exec check: %w", ctx.Err())
441+
case <-timer.C:
442+
// SIGKILLed but never reaped: status lost, health unknowable.
443+
return fmt.Errorf("exec check: %w (child not reaped after kill)", ErrInconclusive)
444+
}
445+
}
446+
}
447+
448+
// SetReaper routes exec probe exit statuses through the daemon reap loop,
449+
// the sole Wait4(-1) owner; a targeted cmd.Wait would race it and lose.
450+
func (c *Checker) SetReaper(r *reaper.Registry) {
451+
c.reaper = r
452+
}
453+
369454
// SetCredential sets the credential for exec health checks so they run
370455
// as the associated service's user instead of as root.
371456
func (c *Checker) SetCredential(cred *syscall.Credential) {

0 commit comments

Comments
 (0)