Repository: redis/go-redis

Issue:
queuedNewConn panic: cannot create context from nil parent
Hi Redis team,

I have noticed some error logs in our application using the library. I don't have a reproducible scenario for it, but I decided to file an issue anyway so the team can investigate if it's a real issue and maybe post a fix for it:

```
2025-11-25 18:33:42.097
redis: 2025/11/25 18:33:40 pool.go:590: queuedNewConn panic: cannot create context from nil parent
2025-11-25 18:33:43.735
redis: 2025/11/25 18:33:42 pool.go:590: queuedNewConn panic: cannot create context from nil parent
2025-11-25 18:33:59.134
redis: 2025/11/25 18:33:45 pool.go:590: queuedNewConn panic: cannot create context from nil parent
2025-11-25 18:33:59.935
redis: 2025/11/25 18:33:43 pool.go:590: queuedNewConn panic: cannot create context from nil parent
2025-11-25 18:34:01.730
redis: 2025/11/25 18:32:59 pool.go:590: queuedNewConn 
2025-11-25 18:35:08.562
redis: 2025/11/25 18:35:08 pool.go:422: redis: connection pool: failed to dial after 1 attempts: dial tcp: lookup <master node domain>: i/o timeout
2025-11-25 18:35:08.562
redis: 2025/11/25 18:35:08 pool.go:422: redis: connection pool: failed to dial after 1 attempts: dial tcp: lookup <master node domain>: i/o timeout
2025-11-25 18:35:08.562
redis: 2025/11/25 18:35:08 pool.go:422: redis: connection pool: failed to dial after 1 attempts: dial tcp: lookup <master node domain>: i/o timeout
2025-11-25 18:35:08.562
redis: 2025/11/25 18:35:08 pool.go:422: redis: connection pool: failed to dial after 1 attempts: dial tcp: lookup <master node domain>: i/o timeout
2025-11-25 18:35:08.562
redis: 2025/11/25 18:35:08 pool.go:422: redis: connection pool: failed to dial after 1 attempts: dial tcp: lookup <master node domain>: i/o timeout
2025-11-25 18:35:55.779
redis: 2025/11/25 18:33:59 pool.go:590: queuedNewConn 
2025-11-25 18:36:31.117
redis: 2025/11/25 18:33:59 pool.go:590: queuedNewConn panic: cannot create context from nil parent
2025-11-25 18:36:31.117
redis: 2025/11/25 18:33:59 pool.go:590: queuedNewConn panic: cannot create context from nil parent
2025-11-25 18:36:31.148
redis: 2025/11/25 18:33:37 pool.go:590: queuedNewConn panic: cannot create context from nil parent
2025-11-25 18:36:31.206
redis: 2025/11/25 18:33:37 pool.go:590: queuedNewConn panic: cannot create context from nil parent
```

Hints from issue comments:
@cyningsun: hi @schmittjoaopedro thank you for reporting this issue and providing the logs.

I reviewed the panic stack trace and the root cause should be: a race condition in the connection creation logic where a nil context can be passed to context.WithTimeout after a request times out. Here is the details:

```
T1: The main goroutine calls queuedNewConn(ctx), creating a background goroutine.
T2: The background goroutine starts but has not yet executed getCtxForDial().
T3: The main goroutine times out waiting (ctx.Done()), sets an error, and prepares to return.
T4: The main goroutine executes defer, calls w.cancel(), and sets w.ctx to nil.
T5: The background goroutine executes line where getCtxForDial() returns nil.
T6: The background goroutine calls p.newConn(nil, true).
T7: Inside newConn, a call to context.WithTimeout(nil, ...) triggers a panic.
```@ndyakov: Hello @schmittjoaopedro, can you state the version that panics. Which version is that, I can see it in the latest patch as well if I am not mistaken. 

@cyningsun Let me know what you think and if you would like to handle this or should I?

This panic is handled which is good and makes this not a critical issue. Maybe we can handle this specific case explicitly and return an error or even fallback to background context in `newConn`? @cyningsun: @ndyakov, thanks for looking into this. You're correct — **based on the code review, I think this race condition also exists in the latest version.**

I agree that since the panic is already recovered upstream, it's not a critical production issue. For the fix,how about we avoid the race entirely by moving all context handling inside the background goroutine? Since it doesn’t actually need the original request context for anything (like tracing or deadlines) now, we could just create a fresh background context with the pool’s `DialTimeout` inside the background goroutine. This would cleanly decouple it from the main goroutine’s lifecycle and eliminate the nil pointer risk.

On timing: If you have cycles to lead the fix, that would likely be fastest — my week is quite packed. I'm happy to review or pick it up later this week if needed.@ndyakov: @cyningsun I can try to fit it around end of week or start of next one. 
Your suggestion sounds good, will investigate where exactly it will make sense to create the new context and open a PR to continue the discussion.@schmittjoaopedro: Hi @ndyakov we observed that in version 6.17.0@ndyakov: @schmittjoaopedro i assume this is `9.17.0` not `6.17.0`, thank you. You may want to try with the latest `9.17.3`, there is a fix included related to connection creation, but the bug you are reporting to is still present. @ndyakov: @cyningsun feel free to take a look at #3649 

Solution patch:
diff --git a/internal/pool/pool.go b/internal/pool/pool.go
index d757d1f..77cecc6 100644
--- a/internal/pool/pool.go
+++ b/internal/pool/pool.go
@@ -321,6 +321,12 @@ func (p *ConnPool) newConn(ctx context.Context, pooled bool) (*Conn, error) {
 		return nil, ErrPoolExhausted
 	}
 
+	// Protect against nil context due to race condition in queuedNewConn
+	// where the context can be set to nil after timeout/cancellation
+	if ctx == nil {
+		ctx = context.Background()
+	}
+
 	dialCtx, cancel := context.WithTimeout(ctx, p.cfg.DialTimeout)
 	defer cancel()
 	cn, err := p.dialConn(dialCtx, pooled)



Test patch:
diff --git a/commands_test.go b/commands_test.go
index c5fa40e..57518c3 100644
--- a/commands_test.go
+++ b/commands_test.go
@@ -8870,11 +8870,15 @@ var _ = Describe("Commands", func() {
 		It("returns latencies", func() {
 			const key = "latency-monitor-threshold"
 
+			// reset all latencies first to ensure clean state
+			err := client.LatencyReset(ctx).Err()
+			Expect(err).NotTo(HaveOccurred())
+
 			old := client.ConfigGet(ctx, key).Val()
 			client.ConfigSet(ctx, key, "1")
 			defer client.ConfigSet(ctx, key, old[key])
 
-			err := client.Do(ctx, "DEBUG", "SLEEP", 0.01).Err()
+			err = client.Do(ctx, "DEBUG", "SLEEP", 0.01).Err()
 			Expect(err).NotTo(HaveOccurred())
 
 			result, err := client.Latency(ctx).Result()
@@ -8921,6 +8925,10 @@ var _ = Describe("Commands", func() {
 		It("reset latencies by add event name args", func() {
 			const key = "latency-monitor-threshold"
 
+			// reset all latencies first to ensure clean state
+			err := client.LatencyReset(ctx).Err()
+			Expect(err).NotTo(HaveOccurred())
+
 			old := client.ConfigGet(ctx, key).Val()
 			client.ConfigSet(ctx, key, "1")
 			defer client.ConfigSet(ctx, key, old[key])
diff --git a/internal/pool/pool_test.go b/internal/pool/pool_test.go
index 8e2f039..757057b 100644
--- a/internal/pool/pool_test.go
+++ b/internal/pool/pool_test.go
@@ -1037,6 +1037,64 @@ var _ = Describe("queuedNewConn", func() {
 		testPool.Put(ctx, reqBConn)
 		Eventually(func() int { return testPool.QueueLen() }, "600ms").Should(Equal(0))
 	})
+	// Test for race condition where nil context can be passed to newConn
+	// This reproduces the issue reported in GitHub where queuedNewConn panics
+	// with "cannot create context from nil parent"
+	It("should handle nil context race condition in queuedNewConn", func() {
+		// Create a pool with very short timeouts to trigger the race condition
+		testPool := pool.NewConnPool(&pool.Options{
+			Dialer: func(ctx context.Context) (net.Conn, error) {
+				// Add a small delay to increase chance of race condition
+				time.Sleep(50 * time.Millisecond)
+				return dummyDialer(ctx)
+			},
+			PoolSize:           int32(10),
+			MaxConcurrentDials: 10,
+			PoolTimeout:        10 * time.Millisecond, // Very short timeout
+			DialTimeout:        100 * time.Millisecond,
+			ConnMaxIdleTime:    time.Millisecond,
+		})
+		defer testPool.Close()
+
+		// Try to trigger the race condition by making many concurrent requests
+		// with short timeouts
+		const numGoroutines = 50
+		var wg sync.WaitGroup
+		errors := make(chan error, numGoroutines)
+
+		for i := 0; i < numGoroutines; i++ {
+			wg.Add(1)
+			go func() {
+				defer GinkgoRecover()
+				defer wg.Done()
+
+				// Use a very short context timeout to trigger the race
+				ctx, cancel := context.WithTimeout(context.Background(), 5*time.Millisecond)
+				defer cancel()
+
+				_, err := testPool.Get(ctx)
+				if err != nil {
+					// We expect timeout errors, but not panics
+					errors <- err
+				}
+			}()
+		}
+
+		wg.Wait()
+		close(errors)
+
+		// Check that we got timeout errors (expected) but no panics
+		// The test passes if it doesn't panic
+		timeoutCount := 0
+		for err := range errors {
+			if err == context.DeadlineExceeded || err == pool.ErrPoolTimeout {
+				timeoutCount++
+			}
+		}
+
+		// We should have at least some timeouts due to the short timeout
+		Expect(timeoutCount).To(BeNumerically(">", 0))
+	})
 })
 
 func init() {
diff --git a/internal/pool/want_conn_test.go b/internal/pool/want_conn_test.go
index 9526f70..09e8627 100644
--- a/internal/pool/want_conn_test.go
+++ b/internal/pool/want_conn_test.go
@@ -442,3 +442,56 @@ func BenchmarkWantConnQueue_EnqueueDequeue(b *testing.B) {
 		q.dequeue()
 	}
 }
+
+// TestWantConn_RaceConditionNilContext tests the race condition where
+// getCtxForDial can return nil after the context is cancelled.
+// This test verifies that the fix in newConn handles nil context gracefully.
+func TestWantConn_RaceConditionNilContext(t *testing.T) {
+	// This test simulates the race condition described in the issue:
+	// 1. Main goroutine creates a wantConn with a context
+	// 2. Background goroutine starts but hasn't called getCtxForDial yet
+	// 3. Main goroutine times out and calls cancel(), setting w.ctx to nil
+	// 4. Background goroutine calls getCtxForDial() and gets nil
+	// 5. Background goroutine calls newConn(nil, true) which should not panic
+
+	dialCtx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
+	defer cancel()
+
+	w := &wantConn{
+		ctx:       dialCtx,
+		cancelCtx: cancel,
+		result:    make(chan wantConnResult, 1),
+	}
+
+	// Simulate the race condition by canceling the context
+	// and then trying to get it
+	var wg sync.WaitGroup
+	wg.Add(1)
+
+	go func() {
+		defer wg.Done()
+		// Small delay to ensure cancel happens first
+		time.Sleep(10 * time.Millisecond)
+
+		// This should return nil after cancel
+		ctx := w.getCtxForDial()
+
+		// Verify that we got nil context
+		if ctx != nil {
+			t.Errorf("Expected nil context after cancel, got %v", ctx)
+		}
+	}()
+
+	// Cancel the context immediately
+	w.cancel()
+
+	wg.Wait()
+
+	// Verify the wantConn state
+	if !w.done {
+		t.Error("wantConn should be marked as done after cancel")
+	}
+	if w.ctx != nil {
+		t.Error("wantConn.ctx should be nil after cancel")
+	}
+}