You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
pg-cloudflare 1.4.1: client.end() emits "This socket has been closed." error #3805
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'importpgfrom'pg'it('end() after a query emits no client error',async()=>{constclient=newpg.Client({connectionString: env.HYPERDRIVE.connectionString})constseen=[]client.on('error',(e)=>seen.push(`error: ${e.message}`))client.on('end',()=>seen.push('end'))awaitclient.connect()awaitclient.query('select 1')awaitclient.end()seen.push('end() resolved')awaitnewPromise((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.
_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).
While ending, reportStreamError ignores only ECONNRESET and EPIPE (packages/pg/lib/connection.js:54-61). This error has no code.
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:
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.
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)
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.
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.
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.
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.
Activity
SlambertDK commentedon Oct 15, 2026
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. Noclient.end()in app code: pg-pool releases idle clients that Hyperdrive has already dropped,Client.end()→CloudflareSocket.end()→close()rejects the pendingread()withThis socket has been closed., and by then the client has no'error'listener left, soemit('error')throws inside the.catchhandler →unhandledRejection. Sentry: ~15 events/hour on a lightly used deployment, mechanismonunhandledrejection, 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 (
_endingflag, which we also set inend()), the patch additionally:socket.closedhas resolved and_addClosedHandleralready set_cfSocket = nulland emitted'close'; the rejected read that races it is not new information..catch((e) => this.emit('error', e))sites (connect,end,startTls,_addClosedHandler) go through an_emitErrorthat checkslistenerCount('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)closedresolving, then the read rejecting → no'error'; (c) a read failing with no'error'listener → nounhandledRejection. All three fail on stock 1.4.1.robingenz commentedon Oct 17, 2026
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 isThis socket has been closed.@cloudflare/vitest-pool-workers0.22.0, workerd 1.20260815.1): the error isStream 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 throughclient.end(), so the error reaches the pool's'error'event, and with no listener it is an unhandled rejection thatwrangler devdoes not print.Repro (
wrangler dev,compatibility_flags: ["nodejs_compat"],compatibility_date: "2026-08-22"):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/poolExpected:
Actual:
The error arrives after
end()has resolved.Cause. I agree with the analysis above. On master (0980cef):
Connection.end()writes Terminate, and its callback callsstream.end()(packages/pg/lib/connection.js:217-227). Since fix: support callback as second argument toCloudflareSocket.write#3747 this callback runs.CloudflareSocket.end()then callssocket.close()(packages/pg-cloudflare/src/index.ts:115)._listen()is still waiting onthis._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:148afterstartTls()).reportStreamErroronly ignoresECONNRESETandEPIPE(packages/pg/lib/connection.js:54-60). This error has nocode, 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 devand the Vitest pool both show no error now, for the client,pool.end()and the idle timeout. A real failure is still reported: withpg_terminate_backend()duringSELECT pg_sleep(5), the query fails withterminating connection due to administrator commandand the client emitsConnection 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 beforeend(). For a pool, ignore errors in the pool's'error'listener whilepool.endingis true, and setidleTimeoutMillis: 0. When the idle timeout closes a client,pool.endingis false, so the listener can't tell that error from a real one.