Skip to content

Commit e26bed4

Browse files
committed
pgconn: fix panic on Pipeline.Close when server sends multiple FATAL errors
When the server sends multiple FATAL ErrorResponse messages that are buffered together (as PgBouncer can do when terminating idle-in-transaction connections), Pipeline.Close() would panic with "close of closed channel" or spin indefinitely. The fix adds a guard at the top of PgConn.receiveMessage() to return an error immediately when the connection is already closed. This prevents pipeline mode from processing buffered messages on a dead connection. Additionally, Pipeline.Close() now checks for nil results from getResults() to break out of the loop when the request queue is exhausted but ExpectedReadyForQuery is still positive. Fixes #2470
1 parent f56ca73 commit e26bed4

2 files changed

Lines changed: 118 additions & 1 deletion

File tree

pgconn/pgconn.go

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -584,6 +584,10 @@ func (pgConn *PgConn) peekMessage() (pgproto3.BackendMessage, error) {
584584

585585
// receiveMessage receives a message without setting up context cancellation
586586
func (pgConn *PgConn) receiveMessage() (pgproto3.BackendMessage, error) {
587+
if pgConn.status == connStatusClosed {
588+
return nil, &connLockError{status: "conn closed"}
589+
}
590+
587591
msg, err := pgConn.peekMessage()
588592
if err != nil {
589593
return nil, err
@@ -2821,14 +2825,23 @@ func (p *Pipeline) Close() error {
28212825
}
28222826

28232827
for p.state.ExpectedReadyForQuery() > 0 {
2824-
_, err := p.getResults()
2828+
results, err := p.getResults()
28252829
if err != nil {
28262830
p.err = err
28272831
var pgErr *PgError
28282832
if !errors.As(err, &pgErr) {
28292833
p.conn.asyncClose()
28302834
break
28312835
}
2836+
} else if results == nil {
2837+
// getResults returns (nil, nil) when the request queue is exhausted but
2838+
// ExpectedReadyForQuery is still > 0. This can happen when FATAL errors consume
2839+
// queued request slots without the server ever sending ReadyForQuery.
2840+
p.conn.asyncClose()
2841+
if p.err == nil {
2842+
p.err = errors.New("pipeline: no more results but expected ReadyForQuery")
2843+
}
2844+
break
28322845
}
28332846
}
28342847

pgconn/pgconn_test.go

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4389,6 +4389,110 @@ func TestFatalErrorReceivedInPipelineMode(t *testing.T) {
43894389
require.Error(t, err)
43904390
}
43914391

4392+
// https://github.com/jackc/pgx/issues/2470
4393+
// When the server sends multiple FATAL errors in a single batch (as PgBouncer can do when
4394+
// terminating idle-in-transaction connections), Pipeline.Close() must not panic with
4395+
// "close of closed channel" on cleanupDone. The first FATAL triggers OnPgError which closes
4396+
// the connection and cleanupDone. The second FATAL, still in the read buffer, must not
4397+
// attempt to close cleanupDone again.
4398+
//
4399+
// This test sends all server responses in a single TCP write to guarantee both FATAL errors
4400+
// are in the chunkReader buffer simultaneously.
4401+
func TestPipelineCloseDoesNotPanicOnMultipleFatalErrors(t *testing.T) {
4402+
t.Parallel()
4403+
4404+
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
4405+
defer cancel()
4406+
4407+
ln, err := net.Listen("tcp", "127.0.0.1:")
4408+
require.NoError(t, err)
4409+
defer ln.Close()
4410+
4411+
serverErrChan := make(chan error, 1)
4412+
go func() {
4413+
defer close(serverErrChan)
4414+
4415+
conn, err := ln.Accept()
4416+
if err != nil {
4417+
serverErrChan <- err
4418+
return
4419+
}
4420+
defer conn.Close()
4421+
4422+
err = conn.SetDeadline(time.Now().Add(59 * time.Second))
4423+
if err != nil {
4424+
serverErrChan <- err
4425+
return
4426+
}
4427+
4428+
backend := pgproto3.NewBackend(conn, conn)
4429+
4430+
// Handle startup
4431+
_, err = backend.ReceiveStartupMessage()
4432+
if err != nil {
4433+
serverErrChan <- err
4434+
return
4435+
}
4436+
backend.Send(&pgproto3.AuthenticationOk{})
4437+
backend.Send(&pgproto3.BackendKeyData{ProcessID: 0, SecretKey: 0})
4438+
backend.Send(&pgproto3.ReadyForQuery{TxStatus: 'I'})
4439+
err = backend.Flush()
4440+
if err != nil {
4441+
serverErrChan <- err
4442+
return
4443+
}
4444+
4445+
// Read all client pipeline messages (Parse, Describe, Parse, Describe, Sync)
4446+
for i := 0; i < 5; i++ {
4447+
_, err = backend.Receive()
4448+
if err != nil {
4449+
serverErrChan <- err
4450+
return
4451+
}
4452+
}
4453+
4454+
// Send ALL responses in a single write so they all end up in the chunkReader buffer.
4455+
// This simulates PgBouncer sending a FATAL and then the real PostgreSQL also sending
4456+
// a FATAL, both arriving in the same TCP segment.
4457+
backend.Send(&pgproto3.ParseComplete{})
4458+
backend.Send(&pgproto3.ParameterDescription{})
4459+
backend.Send(&pgproto3.RowDescription{Fields: []pgproto3.FieldDescription{
4460+
{Name: []byte("mock")},
4461+
}})
4462+
// Two FATAL errors back-to-back in the same write buffer
4463+
backend.Send(&pgproto3.ErrorResponse{Severity: "FATAL", Code: "57P01", Message: "terminating connection due to administrator command"})
4464+
backend.Send(&pgproto3.ErrorResponse{Severity: "FATAL", Code: "57P01", Message: "terminating connection due to administrator command"})
4465+
err = backend.Flush()
4466+
if err != nil {
4467+
serverErrChan <- err
4468+
return
4469+
}
4470+
}()
4471+
4472+
parts := strings.Split(ln.Addr().String(), ":")
4473+
host := parts[0]
4474+
port := parts[1]
4475+
connStr := fmt.Sprintf("sslmode=disable host=%s port=%s", host, port)
4476+
4477+
ctx, cancel = context.WithTimeout(ctx, 59*time.Second)
4478+
defer cancel()
4479+
conn, err := pgconn.Connect(ctx, connStr)
4480+
require.NoError(t, err)
4481+
4482+
pipeline := conn.StartPipeline(ctx)
4483+
pipeline.SendPrepare("s1", "select 1", nil)
4484+
pipeline.SendPrepare("s2", "select 2", nil)
4485+
err = pipeline.Sync()
4486+
require.NoError(t, err)
4487+
4488+
// Do NOT call GetResults. Call Close() directly so it drains results via getResults().
4489+
// The first FATAL closes the connection via OnPgError, including close(cleanupDone).
4490+
// The second FATAL is still buffered in chunkReader. Without the fix, processing it
4491+
// would attempt to close cleanupDone again, causing a panic.
4492+
err = pipeline.Close()
4493+
require.Error(t, err)
4494+
}
4495+
43924496
func mustEncode(buf []byte, err error) []byte {
43934497
if err != nil {
43944498
panic(err)

0 commit comments

Comments
 (0)