Skip to content

Commit 387c560

Browse files
authored
Merge pull request #2585 from sean-/worktree-asyncclose-drain
pgconn: drain socket in asyncClose to avoid RST on context cancel
2 parents 97a2421 + 574f170 commit 387c560

3 files changed

Lines changed: 163 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
# Unreleased
2+
3+
## Fixes
4+
5+
* pgconn: drain socket before close in `asyncClose` so context cancellation produces a TCP FIN instead of RST, avoiding "connection reset by peer" on the server / proxy (Sean Chittenden at CrowdStrike, Inc.)
6+
17
# 5.10.0 (June 3, 2026)
28

39
This release includes a significant amount of hardening against malicious or compromised PostgreSQL servers,

pgconn/pgconn.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -790,6 +790,16 @@ func (pgConn *PgConn) asyncClose() {
790790

791791
pgConn.frontend.Send(&pgproto3.Terminate{})
792792
pgConn.flushWithPotentialWriteReadDeadlock()
793+
794+
// Drain any data already in flight from the server (DataRows that were sent before the
795+
// CancelRequest landed, the resulting ErrorResponse, ReadyForQuery, and finally the server's
796+
// own close after it processes Terminate). Closing a TCP socket while unread data remains in
797+
// the kernel receive buffer causes the OS to send RST instead of FIN, which surfaces on the
798+
// server or proxy as "connection reset by peer". The deadline set above bounds how long this
799+
// will block; on timeout we fall through to Close() and accept the abortive close.
800+
//
801+
// See https://github.com/jackc/pgx/issues/2584
802+
io.Copy(io.Discard, pgConn.conn)
793803
}()
794804
}
795805

pgconn/pgconn_test.go

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4704,6 +4704,153 @@ func mustEncode(buf []byte, err error) []byte {
47044704
return buf
47054705
}
47064706

4707+
// https://github.com/jackc/pgx/issues/2584
4708+
//
4709+
// When a context is canceled while the server is mid-stream, asyncClose must drain the socket
4710+
// before closing it. Closing a TCP socket with unread data in the kernel receive buffer causes
4711+
// the OS to send RST instead of FIN, which surfaces on the server / proxy as "connection reset by
4712+
// peer". This test floods the client with DataRows, cancels the context, and asserts the mock
4713+
// server sees Terminate followed by a clean EOF rather than ECONNRESET.
4714+
func TestAsyncCloseDrainsBeforeClose(t *testing.T) {
4715+
t.Parallel()
4716+
4717+
ln, err := net.Listen("tcp", "127.0.0.1:")
4718+
require.NoError(t, err)
4719+
defer ln.Close()
4720+
4721+
type serverResult struct {
4722+
gotTerminate bool
4723+
readErr error
4724+
}
4725+
serverDone := make(chan serverResult, 1)
4726+
4727+
go func() {
4728+
var res serverResult
4729+
defer func() { serverDone <- res }()
4730+
4731+
conn, err := ln.Accept()
4732+
if err != nil {
4733+
res.readErr = err
4734+
return
4735+
}
4736+
defer conn.Close()
4737+
conn.SetDeadline(time.Now().Add(30 * time.Second))
4738+
4739+
// asyncClose dials a second connection to deliver CancelRequest and then blocks on a Read
4740+
// until the server closes (libpq does the same). Accept it, consume the request, and close
4741+
// promptly so asyncClose proceeds to Terminate. Spawned after the primary Accept() so there
4742+
// is no race over which goroutine gets the first connection.
4743+
go func() {
4744+
c, err := ln.Accept()
4745+
if err != nil {
4746+
return
4747+
}
4748+
defer c.Close()
4749+
c.SetDeadline(time.Now().Add(5 * time.Second))
4750+
io.ReadFull(c, make([]byte, 16)) // len + code + pid + 4-byte secret
4751+
}()
4752+
4753+
backend := pgproto3.NewBackend(conn, conn)
4754+
4755+
if _, err := backend.ReceiveStartupMessage(); err != nil {
4756+
res.readErr = err
4757+
return
4758+
}
4759+
backend.Send(&pgproto3.AuthenticationOk{})
4760+
backend.Send(&pgproto3.BackendKeyData{ProcessID: 0, SecretKey: []byte{0, 0, 0, 0}})
4761+
backend.Send(&pgproto3.ReadyForQuery{TxStatus: 'I'})
4762+
if err := backend.Flush(); err != nil {
4763+
res.readErr = err
4764+
return
4765+
}
4766+
4767+
// Read the simple Query message.
4768+
if _, err := backend.Receive(); err != nil {
4769+
res.readErr = err
4770+
return
4771+
}
4772+
4773+
backend.Send(&pgproto3.RowDescription{Fields: []pgproto3.FieldDescription{{Name: []byte("n")}}})
4774+
if err := backend.Flush(); err != nil {
4775+
res.readErr = err
4776+
return
4777+
}
4778+
4779+
// Flood DataRows. Use a payload large enough that the client's kernel receive buffer holds
4780+
// unread data at the moment of cancellation; without the drain in asyncClose, the client's
4781+
// Close() would emit RST and the server's subsequent Receive() would see ECONNRESET. Stop
4782+
// when the client closes its write side or the socket buffers are full enough that we have
4783+
// blocked past the cancel point; either way fall through to read Terminate.
4784+
row := &pgproto3.DataRow{Values: [][]byte{bytes.Repeat([]byte("x"), 1024)}}
4785+
conn.SetWriteDeadline(time.Now().Add(2 * time.Second))
4786+
for i := 0; i < 16*1024; i++ {
4787+
backend.Send(row)
4788+
if err := backend.Flush(); err != nil {
4789+
break
4790+
}
4791+
}
4792+
conn.SetWriteDeadline(time.Time{})
4793+
4794+
// The client's asyncClose sends Terminate and then drains until we close. Read until we see
4795+
// Terminate, then half-close our write side so the client's drain observes EOF and proceeds
4796+
// to Close() its end.
4797+
conn.SetReadDeadline(time.Now().Add(20 * time.Second))
4798+
for {
4799+
msg, err := backend.Receive()
4800+
if err != nil {
4801+
res.readErr = err
4802+
return
4803+
}
4804+
if _, ok := msg.(*pgproto3.Terminate); ok {
4805+
res.gotTerminate = true
4806+
break
4807+
}
4808+
}
4809+
4810+
// Half-close: send FIN to the client so its io.Copy(io.Discard, ...) returns io.EOF, while
4811+
// keeping our read side open to observe how the client closes. A clean client close yields
4812+
// io.EOF here; an abortive close (RST due to unread data) yields ECONNRESET.
4813+
conn.(*net.TCPConn).CloseWrite()
4814+
buf := make([]byte, 1)
4815+
_, res.readErr = conn.Read(buf)
4816+
}()
4817+
4818+
host, port, _ := strings.Cut(ln.Addr().String(), ":")
4819+
connStr := fmt.Sprintf("sslmode=disable host=%s port=%s", host, port)
4820+
4821+
conn, err := pgconn.Connect(context.Background(), connStr)
4822+
require.NoError(t, err)
4823+
4824+
queryCtx, queryCancel := context.WithCancel(context.Background())
4825+
mrr := conn.Exec(queryCtx, "select n from generate_series(1,1000000) n")
4826+
require.True(t, mrr.NextResult())
4827+
rr := mrr.ResultReader()
4828+
4829+
// Read a handful of rows so the server is definitely streaming, then cancel.
4830+
for i := 0; i < 50 && rr.NextRow(); i++ {
4831+
}
4832+
queryCancel()
4833+
for rr.NextRow() {
4834+
}
4835+
rr.Close()
4836+
mrr.Close()
4837+
4838+
// asyncClose runs in a background goroutine; wait for it (and the drain) to finish.
4839+
select {
4840+
case <-conn.CleanupDone():
4841+
case <-time.After(30 * time.Second):
4842+
t.Fatal("timed out waiting for client cleanup")
4843+
}
4844+
4845+
select {
4846+
case res := <-serverDone:
4847+
require.True(t, res.gotTerminate, "server never received Terminate message")
4848+
require.ErrorIs(t, res.readErr, io.EOF, "server saw %v, expected clean EOF (FIN); RST indicates the client closed with unread data", res.readErr)
4849+
case <-time.After(30 * time.Second):
4850+
t.Fatal("timed out waiting for server")
4851+
}
4852+
}
4853+
47074854
func TestDeadlineContextWatcherHandler(t *testing.T) {
47084855
t.Parallel()
47094856

0 commit comments

Comments
 (0)