Skip to content

Commit 9683053

Browse files
authored
fix(pg): do not treat Sync as connection ending (#3772)
* fix(pg): do not treat Sync as connection ending Connection.sync() was setting _ending=true on every extended-query Sync. That flag is meant for disconnect (Terminate / end()), so after the first parameterized query ECONNRESET and EPIPE were swallowed for the life of the connection. Keep _ending only on end() and connect-timeout teardown. Fixes #3769 * test(pg): add integration tests for Sync teardown handling Adds a real-backend integration test under test/integration/gh-issues/ covering the bug from #3769: Connection.prototype.sync() used to set _ending = true, so every healthy extended-protocol connection was left looking like it was ending. reportStreamError drops ECONNRESET/EPIPE while _ending is set, so a genuine mid-query teardown was silently swallowed and only the generic close-path error surfaced. The tests use a real PostgreSQL backend reached through a local TCP proxy, so the connection reset can be triggered deterministically: 1. a real extended-protocol query does not mark the connection as ending (fails before the fix) 2. a mid-query connection reset is reported, not swallowed by Sync (fails before the fix: no ECONNRESET reaches the client) Both tests fail on the pre-patch code and pass with the fix. * test(pg): skip 3772 integration tests under native bindings The integration suite runs twice: once with the JS implementation and once with a `native` argument that swaps in the libpq bindings. The native client has no `connection` (and therefore no `_ending`), so the state-machine assertions threw a TypeError and aborted the run via the helper's uncaughtException handler. Guard on helper.args.native, matching the existing idiom in test/integration/client/pipeline-portal-tests.js.
1 parent 5971813 commit 9683053

3 files changed

Lines changed: 185 additions & 1 deletion

File tree

packages/pg/lib/connection.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,8 @@ class Connection extends EventEmitter {
201201
}
202202

203203
sync() {
204-
this._ending = true
204+
// Sync is the extended-query protocol barrier, not a disconnect.
205+
// Only end()/Terminate (and connect-timeout teardown) should set _ending.
205206
this._send(syncBuffer)
206207
}
207208

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
'use strict'
2+
const helper = require('../test-helper')
3+
const assert = require('assert')
4+
const net = require('net')
5+
const suite = new helper.Suite()
6+
7+
// These tests target the pure-JS connection state machine: `_ending` lives on
8+
// `Connection` and is only consulted by the JS `reportStreamError`. The native
9+
// (libpq) client has no such property, so there is nothing to assert here.
10+
if (helper.args.native) {
11+
return
12+
}
13+
14+
// https://github.com/brianc/node-postgres/issues/3769
15+
//
16+
// `Connection.prototype.sync()` used to set `_ending = true`. Sync is the
17+
// extended-query protocol barrier sent after every parse/bind/execute, so the
18+
// flag was left true for the entire life of a healthy connection. Because
19+
// `reportStreamError` drops ECONNRESET/EPIPE while `_ending` is set, a genuine
20+
// mid-connection teardown was silently ignored from the first parameterized
21+
// query onwards: the socket error never reached the client, and recovery was
22+
// left to the asynchronous close -> end path.
23+
//
24+
// These tests use a real PostgreSQL backend, reached through a local TCP proxy
25+
// so the connection teardown can be triggered deterministically.
26+
27+
const PG_PORT = Number(helper.config.port) || 5432
28+
29+
// A TCP proxy in front of the real backend. Reports the port it listens on and
30+
// exposes the sockets so a test can reset the connection like a pooler would.
31+
function createProxy() {
32+
const server = net.createServer((clientSocket) => {
33+
const upstream = net.connect(PG_PORT, helper.config.host)
34+
clientSocket.pipe(upstream)
35+
upstream.pipe(clientSocket)
36+
server.lastClientSocket = clientSocket
37+
server.sockets = server.sockets || []
38+
server.sockets.push(clientSocket, upstream)
39+
clientSocket.on('error', () => {})
40+
upstream.on('error', () => {})
41+
})
42+
return new Promise((resolve) => {
43+
server.listen(0, '127.0.0.1', () => resolve({ server, port: server.address().port }))
44+
})
45+
}
46+
47+
function closeProxy(server) {
48+
// Tear down every socket so the process is free to exit.
49+
;(server.sockets || []).forEach((sock) => {
50+
try {
51+
sock.destroy()
52+
} catch (_) {
53+
// the socket may already be torn down; nothing to clean up
54+
}
55+
})
56+
return new Promise((resolve) => server.close(() => resolve()))
57+
}
58+
59+
function connectThroughProxy(port, options) {
60+
const client = new helper.pg.Client({
61+
...helper.config,
62+
host: '127.0.0.1',
63+
port,
64+
...options,
65+
})
66+
// A teardown is expected in these tests; collect client errors instead of
67+
// letting an unhandled 'error' event fail the process.
68+
client.clientErrors = []
69+
client.on('error', (err) => client.clientErrors.push(err))
70+
// The proxy connection is reset on purpose, so don't let an in-flight socket
71+
// keep the process alive after the test finishes.
72+
return client
73+
}
74+
75+
// Wait for `promise` to settle, reporting how it settled.
76+
function settle(promise, timeoutMillis = 10000) {
77+
return new Promise((resolve) => {
78+
const timer = setTimeout(() => resolve({ settled: false }), timeoutMillis)
79+
promise.then(
80+
() => {
81+
clearTimeout(timer)
82+
resolve({ settled: true, rejected: false })
83+
},
84+
(err) => {
85+
clearTimeout(timer)
86+
resolve({ settled: true, rejected: true, error: err })
87+
}
88+
)
89+
})
90+
}
91+
92+
const codesFrom = (errors) => errors.map((e) => e && e.code).filter(Boolean)
93+
94+
suite.test('a real extended-protocol query does not mark the connection as ending', async () => {
95+
const client = new helper.pg.Client(helper.config)
96+
client.on('error', () => {})
97+
await client.connect()
98+
99+
// Prime with a simple query, then run a parameterized query. The parameterized
100+
// query goes through parse/bind/execute and really does write a Sync to the
101+
// socket, which is what used to set _ending.
102+
await client.query('SELECT 1 AS one')
103+
assert.equal(client.connection._ending, false, 'a simple query should not set _ending')
104+
105+
await client.query('SELECT $1::int AS n', [1])
106+
assert.equal(
107+
client.connection._ending,
108+
false,
109+
'Sync is the extended-query barrier, not a disconnect: _ending must stay false'
110+
)
111+
112+
// Still usable afterwards, and still not "ending".
113+
const { rows } = await client.query('SELECT $1::text AS t', ['still here'])
114+
assert.equal(rows[0].t, 'still here')
115+
assert.equal(client.connection._ending, false)
116+
117+
await client.end()
118+
})
119+
120+
suite.test('a mid-query connection reset is reported, not swallowed by Sync', async () => {
121+
const { server, port } = await createProxy()
122+
const client = connectThroughProxy(port)
123+
124+
try {
125+
await client.connect()
126+
127+
// Extended-protocol query, so Sync has already run on this connection.
128+
await client.query('SELECT $1::int AS n', [1])
129+
130+
// Start a query that will still be in flight when the connection is reset.
131+
const inFlight = client.query({ text: 'SELECT pg_sleep($1)', values: [2] })
132+
const settled = settle(inFlight)
133+
134+
// Reset the TCP connection the way an origin/pooler teardown would.
135+
await new Promise((resolve) => setTimeout(resolve, 100))
136+
assert.ok(server.lastClientSocket, 'expected the proxy to have a client socket to reset')
137+
server.lastClientSocket.resetAndDestroy()
138+
139+
const result = await settled
140+
assert.ok(result.settled, 'the in-flight query must settle rather than hang forever')
141+
142+
// The socket error must reach the client. Before the fix, Sync left _ending
143+
// set, so reportStreamError dropped the socket error and only the generic
144+
// "Connection terminated unexpectedly" error from the close path was seen.
145+
const seen = codesFrom(client.clientErrors).concat(result.rejected ? codesFrom([result.error]) : [])
146+
assert.ok(
147+
seen.includes('ECONNRESET') || seen.includes('EPIPE'),
148+
'the underlying socket error should be reported to the client; saw: ' +
149+
JSON.stringify(seen) +
150+
' client errors: ' +
151+
JSON.stringify(client.clientErrors.map((e) => e.message))
152+
)
153+
} finally {
154+
try {
155+
await client.end()
156+
} catch (_) {
157+
// the connection was reset on purpose; end() rejecting is expected
158+
}
159+
await closeProxy(server)
160+
}
161+
})

packages/pg/test/unit/connection/error-tests.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,28 @@ suite.test('connection emits ECONNRESET errors during normal operation', functio
2929
con.stream.emit('error', e)
3030
})
3131

32+
suite.test('connection emits ECONNRESET errors after Sync (Sync is not disconnect)', function (done) {
33+
const con = new Connection({ stream: new MemoryStream() })
34+
con.connect()
35+
// Extended-query Sync used to incorrectly set _ending and swallow resets (#3769)
36+
con.sync()
37+
assert.equal(con._ending, false)
38+
assert.emits(con, 'error', function (err) {
39+
assert.equal(err.code, 'ECONNRESET')
40+
done()
41+
})
42+
const e = new Error('Connection Reset')
43+
e.code = 'ECONNRESET'
44+
con.stream.emit('error', e)
45+
})
46+
47+
suite.test('connection does not set _ending when calling sync()', function () {
48+
const con = new Connection({ stream: new MemoryStream() })
49+
con.connect()
50+
con.sync()
51+
assert.equal(con._ending, false)
52+
})
53+
3254
suite.test('connection does not emit ECONNRESET errors during disconnect', function (done) {
3355
const con = new Connection({ stream: new MemoryStream() })
3456
con.connect()

0 commit comments

Comments
 (0)