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
11 changes: 6 additions & 5 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ jobs:
benchmark:
name: benchmark
runs-on: ubuntu-latest
# Run benchmarks only for pushes or same-repo PRs; skip for fork PRs
if: ${{ github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) }}
strategy:
fail-fast: false
matrix:
Expand All @@ -26,8 +28,7 @@ jobs:
- "8.0.x" # Redis CE 8.0
go-version:
- "1.25.x"
- oldstable
- stable
- "1.24.x"

steps:
- name: Set up ${{ matrix.go-version }}
Expand Down Expand Up @@ -77,6 +78,8 @@ jobs:
test-redis-ce:
name: test-redis-ce
runs-on: ubuntu-latest
# Run full CE matrix only for pushes or same-repo PRs; skip on fork PRs
if: ${{ github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) }}
strategy:
fail-fast: false
matrix:
Expand All @@ -89,8 +92,7 @@ jobs:
- "8.0.x" # Redis CE 8.0
go-version:
- "1.25.x"
- oldstable
- stable
- "1.24.x"

steps:
- name: Checkout code
Expand All @@ -107,4 +109,3 @@ jobs:
with:
files: coverage.txt
token: ${{ secrets.CODECOV_TOKEN }}

4 changes: 3 additions & 1 deletion .github/workflows/govulncheck.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ jobs:
name: govulncheck
runs-on: ubuntu-latest
timeout-minutes: 15
# Skip on fork PRs; run for pushes or same-repo PRs
if: ${{ github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) }}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep scheduled govulncheck runs enabled

This workflow still declares a weekly schedule trigger, but the new job-level condition only permits push and same-repo pull_request events. On the cron event github.event_name is schedule, so the only job is skipped and the weekly vulnerability scan silently stops running; include schedule in the condition or gate only the forked-PR case.

Useful? React with 👍 / 👎.


steps:
- name: Checkout code
Expand All @@ -29,7 +31,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v7
with:
go-version: "1.26.x"
go-version: "1.24.x"
cache: true

- name: Install govulncheck
Expand Down
6 changes: 4 additions & 2 deletions .github/workflows/test-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,14 @@ jobs:
test-e2e-mock:
name: E2E Tests (Mock Proxy)
runs-on: ubuntu-latest
# Run E2E only for pushes or same-repo PRs; skip on fork PRs
if: ${{ github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) }}
strategy:
fail-fast: false
matrix:
go-version:
- stable
- "1.25.x"
- "1.24.x"

steps:
- name: Checkout code
Expand Down Expand Up @@ -59,4 +62,3 @@ jobs:
docker logs cae-resp-proxy 2>&1 | tail -100
echo "=== proxy-fault-injector logs ==="
docker logs proxy-fault-injector 2>&1 | tail -100

45 changes: 45 additions & 0 deletions brpop_ctx_cancel_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package redis_test

import (
"context"
"testing"
"time"

"github.com/redis/go-redis/v9"
)

// TestBRPopContextCancellation verifies that a blocking BRPop with infinite timeout
// respects ctx.Done() and returns promptly with context.Canceled.
func TestBRPopContextCancellation(t *testing.T) {
opt := redis.Options{
Addr: ":6379",
ReadTimeout: -1, // block indefinitely for reads
WriteTimeout: -1,
ContextTimeoutEnabled: true,
}
rdb := redis.NewClient(&opt)
t.Cleanup(func() { _ = rdb.Close() })

key := "brpop-cancel-key"
_ = rdb.Del(context.Background(), key).Err()

ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() {
_, err := rdb.BRPop(ctx, 0, key).Result()
done <- err
}()

// Ensure BRPop is blocked
time.Sleep(50 * time.Millisecond)
cancel()

select {
case err := <-done:
if err == nil || err != context.Canceled {
t.Fatalf("expected context.Canceled, got %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("BRPop did not return after context cancellation")
}
}
42 changes: 38 additions & 4 deletions internal/pool/conn.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ type Conn struct {
// State machine for connection state management
// Replaces: usable, Inited, used
// Provides thread-safe state transitions with FIFO waiting queue
// States: CREATED → INITIALIZING → IDLE IN_USE
// States: CREATED → INITIALIZING → IDLE IN_USE
// ↓
// UNUSABLE (handoff/reauth)
// ↓
Expand Down Expand Up @@ -320,7 +320,7 @@ func (cn *Conn) IsInited() bool {
// This is the preferred method for acquiring a connection from the pool, as it
// ensures that only one goroutine marks the connection as used.
//
// Implementation: Uses state machine transitions IDLE IN_USE
// Implementation: Uses state machine transitions IDLE IN_USE
//
// Returns true if the swap was successful (old value matched), false otherwise.
// Deprecated: Use GetStateMachine().TryTransition() directly for better state management.
Expand Down Expand Up @@ -907,6 +907,14 @@ func (cn *Conn) RemoteAddr() net.Addr {
func (cn *Conn) WithReader(
ctx context.Context, timeout time.Duration, fn func(rd *proto.Reader) error,
) error {
// Fast cancellation path: if the context is done, abort before any socket ops.
if ctx != nil {
if err := ctx.Err(); err != nil {
return err
}
Comment on lines +910 to +914

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Unblock reads after context cancellation

When the context is canceled after the read has already started, as in the added BRPop(ctx, 0, key) test, this preflight check has already passed and cmdTimeout supplies timeout == 0, which leaves the socket with no read deadline unless the context had a deadline. The goroutine then remains blocked in fn(cn.rd) until Redis replies or the connection closes, so cancel() still will not make an infinite blocking pop return context.Canceled; the read deadline/connection needs to be updated when ctx.Done() fires while the read is in progress.

Useful? React with 👍 / 👎.

}
Comment thread
cursor[bot] marked this conversation as resolved.

var cancelWatchDone chan struct{}
if timeout >= 0 {
// Use relaxed timeout if set, otherwise use provided timeout
effectiveTimeout := cn.getEffectiveReadTimeout(timeout)
Expand All @@ -917,11 +925,33 @@ func (cn *Conn) WithReader(
return errConnectionNotAvailable
}

if err := netConn.SetReadDeadline(cn.deadline(ctx, effectiveTimeout)); err != nil {
// Compute and set initial read deadline
dl := cn.deadline(ctx, effectiveTimeout)
if err := netConn.SetReadDeadline(dl); err != nil {
return err
}

// If we have no read deadline (e.g., BRPop(timeout=0) and no ctx deadline)
// but we do have a context, spawn a watcher to force an immediate deadline
// when ctx.Done() fires. This unblocks an in-flight Read without closing
// the socket and without affecting the common hot path where a deadline exists.
if ctx != nil && dl.Equal(noDeadline) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Watch cancellable contexts even with deadlines

This predicate only starts the cancellation watcher when the computed socket deadline is empty. If a caller uses context.WithTimeout(..., time.Minute) for BRPop(ctx, 0, key) and then calls cancel() after the read has started, dl is the future context deadline, so no goroutine moves the read deadline forward and the command can remain blocked until Redis replies or that minute expires. Gate the watcher on ctx.Done() being non-nil and let it handle early cancellation even when an initial deadline already exists.

Useful? React with 👍 / 👎.

cancelWatchDone = make(chan struct{})
go func(nc net.Conn, done <-chan struct{}, c context.Context) {
select {
case <-c.Done():
_ = nc.SetReadDeadline(time.Unix(0, getCachedTimeNs()))
Comment on lines +942 to +943

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Prevent late cancellation from poisoning reused conns

When the read finishes normally at about the same time the caller cancels the context, cancelWatchDone and ctx.Done() can both be closed before this goroutine runs, and Go's select may choose the cancellation case. That can set a past read deadline after WithReader has returned and the connection has been put back into the pool, so a subsequent command on the same socket can fail immediately with an artificial timeout; make the stop path win once the read is complete or otherwise reset/guard the deadline update.

Useful? React with 👍 / 👎.

case <-done:
}
}(netConn, cancelWatchDone, ctx)
}
}
return fn(cn.rd)

err := fn(cn.rd)
if cancelWatchDone != nil {
close(cancelWatchDone)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Watcher goroutine may set stale deadline after completion

Low Severity

After fn returns successfully and close(cancelWatchDone) is called, if the context was also cancelled around the same time, Go's select may non-deterministically pick c.Done() over done, causing SetReadDeadline(now) to execute on the connection after WithReader has already returned. This sets a stale past deadline on a connection that may be returned to the pool, potentially causing a spurious timeout on the next command using that connection before it sets its own deadline.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit c459336. Configure here.

}
return err

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Context cancellation returns timeout error, not context.Canceled

High Severity

When the watcher goroutine fires on ctx.Done(), it sets an immediate read deadline via SetReadDeadline, which causes the blocked read to return a raw network timeout error (*net.OpError with Timeout() == true). WithReader returns this timeout error as-is without checking ctx.Err(). The caller receives a timeout error instead of context.Canceled, breaking error detection via errors.Is(err, context.Canceled). The included test asserts err != context.Canceled and will fail because the actual error is a net timeout.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit c459336. Configure here.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Return context cancellation after forced read deadlines

When a context without a deadline is canceled while a blocking read is in progress, the watcher unblocks the socket by setting an immediate read deadline, so fn(cn.rd) returns the net timeout from the connection. Returning that error unchanged means callers of BRPop(ctx, 0, ...) see an i/o timeout rather than context.Canceled, so the added regression test still fails in the cancellation path it is meant to cover; check ctx.Err() after the forced-deadline read before returning.

Useful? React with 👍 / 👎.

}

func (cn *Conn) WithWriter(
Expand Down Expand Up @@ -1005,6 +1035,10 @@ func (cn *Conn) deadline(ctx context.Context, timeout time.Duration) time.Time {
}

if ctx != nil {
// If context is already done, force immediate deadline to unblock socket ops.
if err := ctx.Err(); err != nil {
return time.Unix(0, nowNs)
}
deadline, ok := ctx.Deadline()
if ok {
if timeout == 0 {
Expand Down
Loading