fix(db): patch postgres.js queued-write-after-close crash (BLO-19583) - #886
Conversation
postgres@3.4.9 buffers sub-1024-byte writes and flushes them from a
setImmediate(nextWrite) callback, while closed() nulls `socket`
synchronously on peer disconnect. A write queued after that point
dereferenced a null socket inside the immediate -- outside every
try/catch on the stack -- so it escaped as an uncaughtException and
terminated paperclip-0:
TypeError: Cannot read properties of null (reading 'write')
at Immediate.nextWrite (postgres/src/connection.js:255:22)
The pool routes around dead connections, so ordinary queries never hit
this. It needs a caller that dispatches straight to Connection.execute()
-- sql.reserve() or sql.begin() -- and execute() only short-circuits on
`terminated`, which an abrupt close never sets. We use transactions
heavily via drizzle.
Upstream PR porsager/postgres#1168 guards the dereference but silently
drops the buffered write, leaving the query pending forever. This patch
also routes the failure through the driver's own connection-closed path
so the query rejects with CONNECTION_CLOSED and the pool reconnects.
Patches all three builds (src/, cjs/, cf/), which carry the identical
function. pnpm-lock.yaml is intentionally not committed: CI owns lockfile
updates and refresh-lockfile.yml lands them after merge.
Co-Authored-By: Claude <noreply@anthropic.com>
1 similar comment
|
@ally please review at head Specific things I want challenged:
|
|
Hey @allyblockcast[bot]! Before this PR can be reviewed, a few things need attention: Missing or incomplete:
Once updated, push a new commit and these checks will re-run automatically. — commitperclip |
allyblockcast
left a comment
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: c36c096
Looks good. Zero Critical, zero Important. This is a tight, well-scoped vendored patch with real regression coverage, and I verified the core claims empirically rather than taking them on trust.
Verification performed
git apply --checkofpatches/postgres@3.4.9.patchagainst a pristinepostgres@3.4.9tree: applies cleanly to all three builds (cf/src,cjs/src,src). Hunk offsets (@@ -254,@@ -252,@@ -252) match the shipped files exactly.errorandErrorsare both in theConnection()closure in all three builds (import { Errors }/require('./errors.js')at the top of each), so the guard cannot raise aReferenceErrorin the CJS or Cloudflare bundle — a common failure mode for multi-build patches.Errors.connection(x, options, socket)destructuressocket || options, so passing the provably-nullsocketis safe and mirrors what upstreamclosed()already does.- Ran the new test file against unpatched
postgres@3.4.9: test 1 fails with the exact production error,Cannot read properties of null (reading 'write'). Re-ran with the guard applied: 2 passed. The regression test genuinely regresses.
Suggestions (3)
- [tests]
packages/db/src/postgres-connection-close-race.test.ts:223— the second test ("cancels a write already queued when the socket closes underneath it") passes against unpatched postgres.js — I confirmed this in the unpatched run above.closed()callsclearImmediate(nextWriteTimer)before the pending immediate can fire, so this case never reaches the new guard; theCONNECTION_CLOSEDrejection it asserts comes fromclosed()'s ownerror(...)call. It is still a legitimate behavioral test (it would catch a future upstream change that stopped clearing the immediate), and the file's doc comment already scopes the regression claim to "the first test" — so nothing here is inaccurate. Consider a one-line comment on the test itself so a later reader doesn't assume both tests protect the guard. - [error-handling]
patches/postgres@3.4.9.patch:17— routing througherror()inherits its early return:if (connection.queue === queues.connecting && options.host[retries + 1]) return. In that branch nothing is rejected, but the guard has already dropped the buffered bytes (chunk = nextWriteTimer = null), so the query would neither be written nor settled — the exact hang the PR sets out to avoid. Not reachable here:options.hostis a comma-split array and paperclip builds every client from a single-hostDATABASE_URL(packages/db/src/client.ts), sooptions.host[retries + 1]is alwaysundefined. Worth a comment, or callingerrored(err)plus asentdrain directly, so the guard's contract holds independent of host config. - [maintenance]
package.json:134— the patch key pinspostgres@3.4.9whilepackages/db/package.jsondeclares^3.4.9. A future minor bump makes the key stop matching. pnpm fails loudly (ERR_PNPM_PATCH_NOT_APPLIED) rather than silently dropping the guard, and this matches the existingembedded-postgresconvention, so it is a tripwire rather than a risk — just be aware the patch must be re-cut on upgrade, and drop it onceporsager/postgres#1168lands upstream.
Strengths
- Rejecting through the driver's own
CONNECTION_CLOSEDpath is a better call than upstream's proposed drop-the-write, which would leave the affected query pending forever. The PR body says so explicitly and the code matches. - Patching all three builds (ESM, CJS, Cloudflare) rather than just
src/— easy to miss, and the CJS build is what actually loads under Node. - The test injects the transport via postgres.js's real
socketoption, so it needs no database and no timing luck. Deterministic coverage of a race is genuinely hard to write. - The
void-not-awaitchoice on the racing query is deliberate and documented: without the guard the query never settles, so awaiting it would surface the bug as an opaque timeout instead of a clear assertion. max_lifetime: null/idle_timeout: nullto keep the driver from holding the vitest worker's event loop open — the kind of detail that otherwise turns into a flaky-CI ticket later.- Leaving
querynon-null after the guard fires is consistent with upstream's ownclosed()behavior and self-heals on the nextReadyForQuery(which nulls it), so the guard does not introduce connection-state corruption on pool reuse. I checked this specifically. - Patch comment cites both the internal ticket and the upstream issue, so the next person knows when it can be deleted.
Recommended Action
- No blocking changes. Safe to merge.
- Consider the three suggestions opportunistically — the multi-host one is the only behavioral gap, and it cannot bite in the current single-host deployment.
Thinking Path
Linked Issues or Issue Description
Fixes the crash tracked in BLO-19578; implementation ticket BLO-19583.
Upstream references: porsager/postgres#1154 and porsager/postgres#1168.
What happened
postgres@3.4.9can queue a sub-1024-byte write after the peer has already closed the socket. The deferrednextWritecallback then dereferencessocket.writewhensocketis null, raising an uncaughtTypeErrorand terminating the Paperclip process.Expected behavior
The affected operation should reject through the driver's normal
CONNECTION_CLOSEDpath, and subsequent pooled work should reconnect instead of losing the process or hanging forever.Steps to reproduce
Use postgres.js's
socketoption with a fake backend that opens a connection, closes it, and then lets a small query queue a write afterclosed()has nulled the socket. Without this patch the immediate throws the productionTypeError.Paperclip version or commit
This PR targets the current
masterdependency set withpostgres@3.4.9pinned by the workspace lockfile.Deployment mode
Server process using postgres.js through the Paperclip database package, including transaction-heavy drizzle paths.
What Changed
postgres@3.4.9in the ESM, CJS, and Cloudflare builds sonextWritehandlessocket === nullwithout throwing from an immediate.packages/db/src/postgres-connection-close-race.test.tsusing the driver'ssocketoption and a fake backend.Verification
Author-reported checks:
The regression test fails with the production
TypeErrorwhen the guard is manually stripped. The fullpackages/dbrun had one pre-existing environmental failure inbackup-lib.test.tsbecause the local agent container lackedpsqlandpg_dumponPATH; the change does not touch the backup/subprocess path.Risks
Moderate dependency-patch risk: the patch is pinned to
postgres@3.4.9and must be removed once upstream releases equivalent behavior. The chosen behavior rejects the affected operation throughCONNECTION_CLOSED; returning onlyfalsewould avoid the crash but could strand the query forever.Model Used
Claude Code assisted with the implementation; this PR-description quality-gate update was prepared with OpenAI Codex GPT-5.
Checklist
Fixes: #/Closes #/Refs #OR (b) described the issue in-PR following the relevant issue templatepsql/pg_dumpcaveat noted above