Skip to content

pg-cloudflare 1.4.1: client.end() emits "This socket has been closed." error #3805

Open

Description

@WolfieLeader

Since pg-cloudflare 1.4.1, a clean await client.end() in Cloudflare Workers is followed by an 'error' event on the Client. With no 'error' listener, it becomes an unhandled rejection.

Versions: pg 8.23.1, pg-cloudflare 1.4.1, @cloudflare/vitest-plugin 1.3.6, vitest 4.1.11, workerd 1.20261001.1, compatibility_date 2026-09-23 with nodejs_compat, PostgreSQL 18 behind a local Hyperdrive binding.

Repro (Workers Vitest pool):

import { env } from 'cloudflare:workers'
import { expect, it } from 'vitest'
import pg from 'pg'

it('end() after a query emits no client error', async () => {
  const client = new pg.Client({ connectionString: env.HYPERDRIVE.connectionString })
  const seen = []
  client.on('error', (e) => seen.push(`error: ${e.message}`))
  client.on('end', () => seen.push('end'))
  await client.connect()
  await client.query('select 1')
  await client.end()
  seen.push('end() resolved')
  await new Promise((resolve) => setTimeout(resolve, 200))
  expect(seen).toEqual(['end', 'end() resolved'])
})

Every run adds "error: This socket has been closed.". Without the listener, vitest reports Unhandled Rejection TypeError: This socket has been closed. at pg-cloudflare/dist/index.js:64.

Cause (master 0980cef):

  1. Since fix: support callback as second argument to CloudflareSocket.write #3747, the stream.write(endBuffer, cb) callback in Connection.end() runs, so CloudflareSocket.end() calls socket.close(). Before, the callback never ran, and the server closed the socket.
  2. _listen() still waits on reader.read() (packages/pg-cloudflare/src/index.ts:68). workerd rejects that read, and connect() re-emits it as 'error' (src/index.ts:50).
  3. While ending, reportStreamError ignores only ECONNRESET and EPIPE (packages/pg/lib/connection.js:54-61). This error has no code.

Bisect: #3735 (cd5ec59) is clean. #3747 (c9e5761) and 1.4.1 show the error.

Open PRs #3718 and #3784 do not fix it. Workaround: attach client.on('error', () => {}) before end().

Activity

  1. SlambertDK commented on Oct 15, 2026

    @SlambertDK

    Confirming this in production, and adding two cases #3806 does not cover.

    Where we see it: a Next.js app on Cloudflare Workers (OpenNext, nodejs_compat), pg 8.20.0 + pg-pool, pg-cloudflare 1.4.1, Supabase Postgres behind Hyperdrive. No client.end() in app code: pg-pool releases idle clients that Hyperdrive has already dropped, Client.end() → CloudflareSocket.end() → close() rejects the pending read() with This socket has been closed., and by then the client has no 'error' listener left, so emit('error') throws inside the .catch handler → unhandledRejection. Sentry: ~15 events/hour on a lightly used deployment, mechanism onunhandledrejection, single frame _listen. No request fails — it is pure noise, but it is tagged with the deployment, so it skews any per-deployment error comparison (our deploy watchdog's rollback rule).

    We ship 1.4.1 with a pnpm patch; after deploying it the event count on the new version is 0 (72 min, vs 13 in the hour before on the previous version). Compared with #3806 (_ending flag, which we also set in end()), the patch additionally:

    1. Ignores a read that fails after the peer closed the socket — socket.closed has resolved and _addClosedHandler already set _cfSocket = null and emitted 'close'; the rejected read that races it is not new information.
    2. Never throws from emitting an error nobody listens for — all .catch((e) => this.emit('error', e)) sites (connect, end, startTls, _addClosedHandler) go through an _emitError that checks listenerCount('error') > 0. With pg the listener is gone once the client has been ended/removed from the pool, which is exactly when these late rejections arrive.

    Other read failures on a live socket still emit 'error' as before.

    Patch against the 1.4.1 dist (what we run; happy to turn it into a PR on top of #3806 if wanted)
    @@ constructor
             this._cfReader = null;
    +        this._closing = false;
    @@ connect()
    -                this._listenOnce().catch((e) => this.emit('error', e));
    +                this._listenOnce().catch((e) => this._readFailed(e));
    -                this._listen().catch((e) => this.emit('error', e));
    +                this._listen().catch((e) => this._readFailed(e));
             catch (e) {
    -            this.emit('error', e);
    +            this._emitError(e);
             }
         }
    +    _readFailed(e) {
    +        if (this._closing || this.destroyed || this._cfSocket === null) {
    +            return; // end()/destroy() ran, or the peer closed and 'close' was already emitted
    +        }
    +        this._emitError(e);
    +    }
    +    _emitError(e) {
    +        if (this.listenerCount('error') > 0) this.emit('error', e);
    +    }
    @@ end()
             log('ending CF socket');
    +        this._closing = true;
             this.write(data, encoding, (err) => {
    ...
    -                .catch((e) => this.emit('error', e));
    +                .catch((e) => this._emitError(e));
    @@ startTls()
    -        this._listen().catch((e) => this.emit('error', e));
    +        this._listen().catch((e) => this._readFailed(e));
    @@ _addClosedHandler()
    -            .catch((e) => this.emit('error', e));
    +            .catch((e) => this._emitError(e));

    Tests we run against the dist with a fake cloudflare:sockets: (a) end() then the pending read rejecting → no 'error', exactly one 'close'; (b) closed resolving, then the read rejecting → no 'error'; (c) a read failing with no 'error' listener → no unhandledRejection. All three fail on stock 1.4.1.

  2. robingenz commented on Oct 17, 2026

    @robingenz

    I see the same bug. The comment above already covers the pool's idle path and the missing listener, so here is only what is still missing: a repro without Hyperdrive, the older error text, and a test of #3806.

    Versions: pg 8.23.1, pg-cloudflare 1.4.1, PostgreSQL 17 (Docker, plain TCP, no Hyperdrive).

    • wrangler dev (wrangler 4.142.0, workerd 1.20260926.1): the error is This socket has been closed.
    • Workers Vitest pool (@cloudflare/vitest-pool-workers 0.22.0, workerd 1.20260815.1): the error is Stream was cancelled.

    So the text depends on the workerd version, not on the tool. I'm adding it here so people who search for Stream was cancelled. find this issue.

    Pool. Same as described above: pool.end() and the idle timeout both go through client.end(), so the error reaches the pool's 'error' event, and with no listener it is an unhandled rejection that wrangler dev does not print.

    Repro (wrangler dev, compatibility_flags: ["nodejs_compat"], compatibility_date: "2026-08-22"):

    import pg from 'pg'
    
    const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
    
    export default {
      async fetch(request, env) {
        const events = []
        if (new URL(request.url).pathname === '/client') {
          const client = new pg.Client({ connectionString: env.DATABASE_URL })
          client.on('error', (error) => events.push(`client error: ${error.message}`))
          client.on('end', () => events.push('end'))
          await client.connect()
          await client.query('SELECT 1')
          await client.end()
        } else {
          const pool = new pg.Pool({ connectionString: env.DATABASE_URL })
          pool.on('error', (error) => events.push(`pool error: ${error.message}`))
          await pool.query('SELECT 1')
          await pool.end()
        }
        events.push('end() resolved')
        await wait(200)
        return Response.json(events)
      },
    }
    docker run -d --rm -e POSTGRES_PASSWORD=postgres -p 55432:5432 postgres:17-alpine
    # DATABASE_URL=postgresql://postgres:postgres@localhost:55432/postgres in wrangler.jsonc "vars"
    npx wrangler dev
    curl localhost:8787/client
    curl localhost:8787/pool

    Expected:

    ["end","end() resolved"]
    ["end() resolved"]
    

    Actual:

    ["end","end() resolved","client error: This socket has been closed."]
    ["end() resolved","pool error: This socket has been closed."]
    

    The error arrives after end() has resolved.

    Cause. I agree with the analysis above. On master (0980cef):

    • Connection.end() writes Terminate, and its callback calls stream.end() (packages/pg/lib/connection.js:217-227). Since fix: support callback as second argument to CloudflareSocket.write #3747 this callback runs.
    • CloudflareSocket.end() then calls socket.close() (packages/pg-cloudflare/src/index.ts:115).
    • _listen() is still waiting on this._cfReader!.read() (src/index.ts:68). Closing the socket rejects that read.
    • connect() turns the rejection into an 'error' event (src/index.ts:50, and :148 after startTls()).
    • While ending, reportStreamError only ignores ECONNRESET and EPIPE (packages/pg/lib/connection.js:54-60). This error has no code, so it reaches the client, and from there the pool.

    #3806 fixes the end() path. I built #3806's change and ran the repro above with it. wrangler dev and the Vitest pool both show no error now, for the client, pool.end() and the idle timeout. A real failure is still reported: with pg_terminate_backend() during SELECT pg_sleep(5), the query fails with terminating connection due to administrator command and the client emits Connection terminated unexpectedly. The two cases in the comment above, a read that fails after the peer closed and an emit with no listener, are not covered by #3806 and look worth folding into it.

    Workaround until a release: for a client, add an 'error' listener before end(). For a pool, ignore errors in the pool's 'error' listener while pool.ending is true, and set idleTimeoutMillis: 0. When the idle timeout closes a client, pool.ending is false, so the listener can't tell that error from a real one.

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