From ab3d6133c4160d32a983341d4e676f241711228c Mon Sep 17 00:00:00 2001 From: slayerjain Date: Sun, 28 Jun 2026 13:58:13 +0530 Subject: [PATCH] fix(coverage): retry the data-socket connect to survive a briefly-unavailable receiver The coverage reporter did a single net.Dial to keploy's unix data socket; under CPU starvation keploy's accept is momentarily unavailable (ENOENT/ECONNREFUSED), so that test's coverage report was dropped permanently ('could not connect to keploy data socket'), failing the lane's coverage-completeness check. Wrap the connect in a bounded retry (capped exponential backoff, transient errors only, 5s budget under keploy's 10s ACK deadline). Retries only the idempotent connect -- never re-derives coverage data, so a genuinely-down socket still errors. Signed-off-by: slayerjain --- keploy/coverage_dial_test.go | 57 ++++++++++++++++++++++++++++++++++++ keploy/keploy.go | 53 ++++++++++++++++++++++++++++++++- 2 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 keploy/coverage_dial_test.go diff --git a/keploy/coverage_dial_test.go b/keploy/coverage_dial_test.go new file mode 100644 index 0000000..53dc205 --- /dev/null +++ b/keploy/coverage_dial_test.go @@ -0,0 +1,57 @@ +//go:build linux || darwin +// +build linux darwin + +package keploy + +import ( + "net" + "path/filepath" + "testing" + "time" +) + +// TestDialDataSocketWithRetry_DeliversWhenListenerAppearsLate guards the +// coverage-sync "could not connect to keploy data socket" flake: a one-shot +// net.Dial loses the report if the receiver's socket is briefly absent/refusing +// (ENOENT/ECONNREFUSED) under CPU starvation. The retry must connect once the +// listener appears. +func TestDialDataSocketWithRetry_DeliversWhenListenerAppearsLate(t *testing.T) { + path := filepath.Join(t.TempDir(), "cov.sock") + ready := make(chan net.Listener, 1) + go func() { + time.Sleep(200 * time.Millisecond) // socket absent for the first ~200ms + ln, err := net.Listen("unix", path) + if err != nil { + t.Errorf("listen: %v", err) + return + } + ready <- ln + if c, err := ln.Accept(); err == nil { + _ = c.Close() + } + }() + + conn, err := dialDataSocketWithRetry(path, 5*time.Second) + if err != nil { + t.Fatalf("expected retry to connect once the listener appeared, got: %v", err) + } + _ = conn.Close() + ln := <-ready + _ = ln.Close() +} + +// TestDialDataSocketWithRetry_FailsWithinBudgetWhenNoListener confirms a socket +// that stays down still errors (no masking) and the retry respects its bounded +// budget (cannot hang past the ACK deadline). +func TestDialDataSocketWithRetry_FailsWithinBudgetWhenNoListener(t *testing.T) { + path := filepath.Join(t.TempDir(), "absent.sock") + start := time.Now() + conn, err := dialDataSocketWithRetry(path, 600*time.Millisecond) + if err == nil { + _ = conn.Close() + t.Fatal("expected an error when no listener is present") + } + if elapsed := time.Since(start); elapsed > 2*time.Second { + t.Fatalf("dialDataSocketWithRetry exceeded its budget: took %v", elapsed) + } +} diff --git a/keploy/keploy.go b/keploy/keploy.go index 1bca4dd..074de7e 100644 --- a/keploy/keploy.go +++ b/keploy/keploy.go @@ -11,6 +11,7 @@ import ( "bufio" "bytes" "encoding/json" + "errors" "fmt" "log" "net" @@ -21,6 +22,8 @@ import ( "sort" "strings" "sync" + "syscall" + "time" "golang.org/x/tools/cover" ) @@ -179,9 +182,14 @@ func reportCoverage(testID string) error { return sendToSocket(jsonData) } +// dataSocketDialBudget bounds how long sendToSocket retries the connect, kept +// under Keploy's 10s ACK read deadline (the END handler writes its ACK only after +// sendToSocket returns) with headroom for the preceding covdata processing. +const dataSocketDialBudget = 5 * time.Second + // sendToSocket connects to the Keploy data socket and writes the JSON payload. func sendToSocket(data []byte) error { - conn, err := net.Dial("unix", dataSocketPath) + conn, err := dialDataSocketWithRetry(dataSocketPath, dataSocketDialBudget) if err != nil { return fmt.Errorf("could not connect to keploy data socket at %s: %w", dataSocketPath, err) } @@ -195,6 +203,49 @@ func sendToSocket(data []byte) error { return err } +// dialDataSocketWithRetry dials the unix data socket, retrying TRANSIENT transport +// errors with capped exponential backoff. Keploy's receiver runs a single accept +// loop and re-creates the socket file each run, so under CPU starvation the connect +// can briefly see ECONNREFUSED / ENOENT (or a dial timeout) before the accept drains +// its backlog — a one-shot net.Dial dropped that test's coverage report permanently +// ("could not connect to keploy data socket"). This retries only the idempotent +// CONNECT; it never re-derives or fabricates coverage data, so it cannot mask a +// genuine miss — a socket that stays down still errors after the bounded budget. +func dialDataSocketWithRetry(path string, budget time.Duration) (net.Conn, error) { + deadline := time.Now().Add(budget) + delay := 25 * time.Millisecond + var lastErr error + for { + // A local AF_UNIX connect succeeds in microseconds or fails instantly + // (ECONNREFUSED/ENOENT); the short timeout only caps a pathological hang. + conn, err := net.DialTimeout("unix", path, 250*time.Millisecond) + if err == nil { + return conn, nil + } + lastErr = err + // A non-transient error won't fix itself — fail fast (don't burn the budget). + if !isTransientDialErr(err) { + return nil, lastErr + } + if time.Now().Add(delay).After(deadline) { + return nil, lastErr + } + time.Sleep(delay) + if delay < 400*time.Millisecond { + delay *= 2 + } + } +} + +// isTransientDialErr reports whether a unix-socket dial error is a transient +// condition worth retrying (the receiver's accept is briefly starved, or the +// socket file is momentarily absent/being recreated), vs a permanent failure. +func isTransientDialErr(err error) bool { + return errors.Is(err, syscall.ECONNREFUSED) || + errors.Is(err, syscall.ENOENT) || + errors.Is(err, os.ErrDeadlineExceeded) +} + // processCoverageProfilesUsingCovdata uses the covdata tool to convert binary coverage data to text format // and then processes it using the standard cover package. func processCoverageProfilesUsingCovdata(dir string) (map[string][]int, error) {