Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions keploy/coverage_dial_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
53 changes: 52 additions & 1 deletion keploy/keploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"bufio"
"bytes"
"encoding/json"
"errors"
"fmt"
"log"
"net"
Expand All @@ -21,6 +22,8 @@ import (
"sort"
"strings"
"sync"
"syscall"
"time"

"golang.org/x/tools/cover"
)
Expand Down Expand Up @@ -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)
}
Expand All @@ -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) {
Expand Down
Loading