Skip to content

_ending is set by every sync() and never reset, so connection errors are swallowed for the life of the client (and with pipeline: true, a query's promise can never settle) #3769

Description

@NIMPS22
  • pg: 8.23.0
  • Runtime: Cloudflare Workers (workerd) via pg-cloudflare, nodejs_compat
  • PostgreSQL: Neon, reached through Cloudflare Hyperdrive (transaction-mode pooler)

Summary

Connection.prototype.sync() sets this._ending = true, and nothing ever sets it back. Since sync() runs after every query in the extended protocol, _ending is permanently true from the first query onward, for a client that is not ending at all.

reportStreamError then silently drops ECONNRESET and EPIPE for the rest of that connection's life:

// lib/connection.js:54-59
const reportStreamError = function (error) {
  // errors about disconnections should be ignored during disconnect
  if (self._ending && (error.code === 'ECONNRESET' || error.code === 'EPIPE')) {
    return
  }
  self.emit('error', error)
}
// lib/connection.js:199-202
sync() {
  this._ending = true
  this._send(syncBuffer)
}

The comment says during disconnect, and the flag no longer means that. Recovery from a mid-request teardown therefore rests entirely on the socket's close -> end -> _errorAllQueries path.

Where we hit it

With pipeline: true (8.22+) that recovery path races the pipelined bookkeeping, and an in-flight query's promise is sometimes never settled. Not rejected, not resolved: the awaiting code waits for ever.

In production this presented as a sign-in that hung indefinitely. The HTTP request never completed, so nothing was ever logged, while pg_stat_activity on the server showed the connection idle in Client/ClientRead with its last query answered, for fourteen hours. The server had replied; the reply never reached the promise.

Reproduction

A Worker opening a fresh Client per round against a real Hyperdrive origin, running a sign-in-shaped chain of several sequential queries with a transaction interleaved on the same client (begin / set_config / ... / commit), 400 rounds per variant:

variant hung for ever
pipeline: true + interleaved transaction 2 / 400
pipeline: false + interleaved transaction 0 / 400
pipeline: true, no transaction 0 / 400

Three things are needed together: pipelining on, more than one statement genuinely in flight (which the interleaved transaction guarantees), and a real origin teardown. A local proxy issuing RST/FIN rejected cleanly every time; only the real pooler, with its own connection recycling, produced the race at scale.

In both hangs the client-side state was:

events:         ["client.error:Connection terminated unexpectedly", "client.end"]
readyForQuery:  false
_activeQuery:   still set
_ended:         false

So the end handler had run while _activeQuery was still set: the queue never reached _errorAllQueries for that query.

Why we think the two are the same story

_ending being permanently true means the ordinary error road is closed before the socket-close road is even reached, so everything depends on a path that, on this runtime, is delivered asynchronously by cloudflare:sockets' .closed and races _pulsePipelinedQueryQueue. We did not pin the lost rejection to a single line inside that race, and we would rather say so than guess. The _ending behaviour above, however, is plain in the source and looks wrong on its own terms regardless of runtime.

Possibly the same family as #3707, where a missing Sync leaves ReadyForQuery unarriving and wedges the connection.

What we did

Turned pipeline off. The same sign-in step that never returned now answers in 88-115 ms, and the hang has not recurred.

Happy to run further instrumented variants of the probe if that would help; the environment is reproducible on our side.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions