@@ -17,6 +17,7 @@ package check
1717
1818import (
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
3336var 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.
3644type 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
336356func (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.
371456func (c * Checker ) SetCredential (cred * syscall.Credential ) {
0 commit comments