diff --git a/reaper.go b/reaper.go index b4bde903b6..61cb6418a5 100644 --- a/reaper.go +++ b/reaper.go @@ -536,8 +536,13 @@ func (r *Reaper) useTermSignal() chan bool { // It returns a channel that can be sent true to terminate the connection. // Returns an error if config.RyukDisabled is true. func (r *Reaper) connect(ctx context.Context) (chan bool, error) { + // Bound the dial: with dropped SYNs it would sit in the kernel's ~2min + // connect timeout and eat the spawner's 20s backoff budget in one attempt. + dialCtx, cancel := context.WithTimeout(ctx, time.Second*5) + defer cancel() + var d net.Dialer - conn, err := d.DialContext(ctx, "tcp", r.Endpoint) + conn, err := d.DialContext(dialCtx, "tcp", r.Endpoint) if err != nil { return nil, fmt.Errorf("dial reaper %s: %w", r.Endpoint, err) } diff --git a/reaper_dial_bound_test.go b/reaper_dial_bound_test.go new file mode 100644 index 0000000000..075f367e76 --- /dev/null +++ b/reaper_dial_bound_test.go @@ -0,0 +1,33 @@ +package testcontainers + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/cenkalti/backoff/v4" +) + +// A blackholed endpoint (SYNs dropped, no RST) must fail within the 5s dial +// bound and be classified retryable, so the spawner's 20s backoff can retry. +func TestReaperConnectDialBounded(t *testing.T) { + r := &Reaper{Endpoint: "192.0.2.1:8080"} // TEST-NET-1, not routed + start := time.Now() + _, err := r.connect(context.Background()) + elapsed := time.Since(start) + + if err == nil { + t.Fatal("expected dial error") + } + if elapsed > 10*time.Second { + t.Fatalf("dial not bounded: took %v", elapsed) + } + s := &reaperSpawner{} + rerr := s.retryError(err) + var perm *backoff.PermanentError + if errors.As(rerr, &perm) { + t.Fatalf("timeout classified permanent, backoff would stop: %v", rerr) + } + t.Logf("dial failed after %v, retryable: %v", elapsed, err) +}