Repository navigation
Conversation
|
I've also recently gotten bitten by this one |
|
Thanks for this PR. We tested it together with #1209: the two fix every crash case we could reproduce. We found one case that is still broken, and a one-line fix for it. What we tested
On 3.4.9 without the PRs
A plain null check in With #1209 + #1215All three are fixed. ✅ Still broken: a connection reset inside
|
|
We independently found and patched the same two issues in production (3.4.9): the stale One path this PR doesn't reach: |
|
A reproduction for the stale If a query is written in the same event-loop turn as the socket closes, its bytes stay in Run against import postgres from 'postgres'
const o = { host: '127.0.0.1', user: 'postgres', database: 'postgres', ssl: false, max: 1, connect_timeout: 2 }
let closed = false
const sql = postgres({ ...o, onclose: () => closed = true }), admin = postgres(o)
const [{ pid }] = await sql`select pg_backend_pid() as pid`
const tick = () => { // one query per loop turn, from a setImmediate queued behind the client's flush
if (closed) return
sql.unsafe(`select 1 /*${'x'.repeat(900)}*/`).catch(() => {})
queueMicrotask(() => setImmediate(tick))
}
setImmediate(tick)
await admin`select pg_terminate_backend(${pid})`
while (!closed) await new Promise(r => setTimeout(r, 10))
for (let i = 1; i <= 3; i++)
await sql`select ${i}::int as i`.then(() => console.log(i, 'ok'), e => console.log(i, e.code, e.message))
await Promise.all([sql.end(), admin.end()])On master 411429e (Node 24, PostgreSQL 17): With this PR's One thing it leaves: with this PR applied, in 3 of 6 runs queries 2 and 3 never settle (no reply within 4 s each). On master the same hang appears with a different ordering of the loop (3 of 6 runs), so it looks pre-existing rather than caused by this PR. It seems related to queries left in |
…f, support chat inputs, Postgres connection drops, served-route pricing (#688) * fix(web): show Environment, Connections, Context and MCP for cloud computer agents A pod host's daemon spawns every exec with the same identity, connection and env-text base env a sandbox runner turn carries, and context docs and MCP config reach any host through its daemon (ADR-0035/0037). The settings rail still gated all four sections to sandboxes and self-owned computers, so a cloud computer agent could not edit what the platform delivers. - The section gate is now every runtime but external. OpenClaw on a self-owned computer stays the one Environment gap: its env lives in the user's own gateway process. - Restart and the env pane's pending-restart reminder follow the restart endpoint's own precondition, sandbox or cloud computer, through one helper. - The runtime precondition notes, now shown only for external agents, name cloud computers too, in all 11 catalogs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(channels): count WeChat poll boundaries as liveness, retry edge connect timeouts A WeChat channel that hit one error never returned to connected while its long polls kept ending at the edge timeout. A boundary re-polled without reporting a status, so the channel stayed in error and the channel manager restarted it every 600s (stop, notifystop, start) indefinitely. A boundary means the gateway held this session's long poll open until the edge cut it, so once the channel has a cursor it now reports connected, which clears the error and the backoff. Without a cursor a boundary is still not an initial sync. EdgeOne reports its own failures in 52x/55x pairs. 554 is the same origin read timeout as 524 and now takes the same boundary path. 522/552 mean the edge could not reach the gateway: the poll loop retries them with the same cursor, and only three in a row put the channel in error. Before, each one took the error path and left a 10-minute gap. Other HTTP, network and -14 failures are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(channels): end the WeChat -14 pause for a re-scanned or re-registered token An expired WeChat session (-14) pauses the channel's polling for an hour, held in provider memory by channel. Two defects in that pause: - A re-scan did not take effect for up to an hour. Saving the new token re-registers the channel, and the restart that follows came back to start(), which idled out the old token's pause. That restart often comes from the lease holder's config fingerprint check, so register() may not run on the instance holding the pause at all. The pause now belongs to the token iLink rejected, and a new token polls at once on any instance. A successful register() also ends the pause for its own token and wakes a loop parked in it, because a Register that keeps the token restarts nothing. - Every manager bounce during the pause still sent notifystop with the dead token, about every 600s per expired channel. stop() now skips it while the token is paused. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(channels): keep a WeChat channel's gateway when its token is replaced The settings form sends a pasted token with baseUrl: null, and the CLI can send just a botToken. The provider read either as the default gateway, so an international channel updated that way moved to the domestic host, and its register probe and polling went there. A token belongs to the gateway its QR login returned, so a region must not be rewritten. Updates now hand the stored credentials to validateCredentials: a replacement token keeps the stored gateway unless the caller names one, and create still defaults to the standard gateway. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(api): back off a skill repository's scans after a failure A rate-limited catalog scan stored nothing, so every skills/discover read started the same doomed scan again: on 2026-10-02 18 scans in 3.5 minutes spent 2,063 GitHub requests, and 51 rate-limited scans since 09-25 cost 2,890 requests across production and staging. skill_repo_scans now records a failed scan's retry time, consecutive failure count and classification (migration 0033). The claim refuses a repository inside its backoff on every instance, the discover read path no longer schedules it, and a successful scan clears it. The wait is at least a minute, doubles per consecutive failure up to an hour, honours GitHub's Retry-After and exhausted x-ratelimit-reset, and is jittered upward. A foreground install or refresh inside the backoff answers 503 with Retry-After instead of busy or a request; a caller's own cancellation records nothing. Library skill imports that fail at GitHub or while saving now log their stage and classification, which the 10-02 import 503s lacked. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(docs): send support chat page context as Chatflow inputs and show a limit state The support Chatflow now declares page_url, page_title and page_locale on its Start node and answers in the page's language. The panel sends the page as inputs on every message, clamped to the declared lengths, and the question goes out as the reader wrote it. The first-question comment carrier and the history strip that hid it are gone. A 429 from the request limits in front of the service now shows its own message and waits for the reader's Retry instead of reading as a generic error. Retry restarts a panel that never got a passport, and a history poll that fails during navigation recovery ends the wait instead of leaving the answer on "thinking". The composer caps a question at 4,000 characters, under the 32 KB the edge accepts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(db): settle a transaction's work when its Postgres connection closes postgres.js 3.4.9 leaves a transaction's connection in a broken state when the backend goes away. If a query is running when the connection closes, the callback fails and the scope writes its automatic ROLLBACK to the closed socket. That write throws an uncaught TypeError ("Cannot read properties of null (reading 'write')") from a setImmediate, and the API exits on it. This is the exception from the 2026-08-16 production crash. Queries still queued inside that transaction were never settled. A callback that kept running could also query, commit or roll back whichever transaction picked up the reconnected connection next. Each of these reproduces against a real server with the production client config (createDb, drizzle). Upstream porsager/postgres#1215 fixes this and is unmerged; no newer postgres.js release exists. Ship it as a pnpm patch of 3.4.9 (the PR's src diff at 18cffe01, applied to src, cjs/src and cf/src): - the transaction keeps the close error and rejects its queued and later queries with it, including its own commit/rollback; - the connection drops the pending write and the old query/result state before reuse. It cannot be done from outside the driver: the scope issues commit and rollback through its own handler. The patch file has to reach every frozen install. Each image copies patches/ before it installs, and prepare-runtime-workspace copies the patch files the root manifest names into the pruned runtime workspace (cloud names them under oss/). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(api): keep a dropped database connection from ending the process All five unhandled-rejection exits since 2026-09-10 carried postgres.js's CONNECTION_CLOSED from connection.js:453: - staging 09-10 20:54 and 09-11 00:58; - prod 09-11 07:32; - staging and prod 09-22 22:12. Three of those four windows were drops seen by both environments within seconds, i.e. the shared PgBouncer, not one connection. The driver rejected the queries that were on the connection, which is correct, but some of them belonged to work that nothing awaits, and the API exits on any unhandled rejection. A type-aware no-floating-promises pass over apps/api/src found 123 sites. Resolving each voided call's body left these that can reject on a database error: - the sandbox power-sync tick: every 1.5 s on the leader, with observedProviderIds, tickPolled and the quota candidates unguarded; - the automation scheduler tick: every 30 s on every instance; - the export and deletion sweeps; - a forced daemon disconnect's lease clear; - the sign-in reconcile after a terminal closes; - the ready-service touch; - the pod-host cleanup's re-read. Each now logs and returns. The awaited entry points (sweep(), executeNow) still reject to their callers. Backstop: an unhandled rejection whose reason is a postgres.js connection error (Errors.connection sets code === errno: CONNECTION_CLOSED, _ENDED, _DESTROYED, CONNECT_TIMEOUT) no longer ends the process. The pool reconnects on its next query, and exiting only kills every live turn on the replica. It is reported as a process.unhandled_rejection event (outcome recovered, with code, class and stack) plus a log line; Sentry's default integration still captures it, so the leak stays visible. Every other unhandled rejection is still fatal, and uncaught exceptions are untouched. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(api): report every lost database connection and name the broker's connections The crash investigation could not tell a pooler-wide drop from a single connection closing: postgres.js reconnects silently, and only the queries in flight left a trace. createDb now takes an onClose hook (postgres.js onclose). The app pool, the bus client and the daemon RPC broker emit one db.connection.closed event per closed connection, labelled with the pool. The event runs in a root context, because a close is not part of whatever request opened the connection. Lifetime recycling is an occasional single event; a pooler drop is a burst across pools and machines in the same second. The broker's client was the one connection in pg_stat_activity still named postgres.js; it is now mf-api-broker, next to mf-api and mf-api-bus. Correct createDb's comment while here: the pooler question is settled. The deployments go through the cluster's PgBouncer endpoint in session mode, inferred from LISTEN working through it (#712). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(api): leave no rejection behind a cancelled ref write or a failed sprite read Two more paths of the #843 class, where work nobody awaits rejects and the API exits on the unhandled rejection. - spriteReadFile returned a done promise that rejects with the same error the stream throws to its reader. A caller that only iterated the stream left done unhandled. Seen on prod 2026-10-01 01:03 (api 7.9.0): an MCP config read hit "readChunk 0 short read 1487/1717". The request got the error from the stream and answered it, and done's copy of the error ended the process. #604 (09-29) since moved that read onto the host daemon's readFileStream, which already sinks its done, so no product code calls spriteReadFile now. The export keeps the same contract, sunk at the source. - persistUpstreamRef voided the ref write's promise when the turn was already cancelled. That promise rethrows TurnFenceLostError, so a fence lost after the cancel rejected with no handler. The staging exits during the 09-27 and 09-30 deploys ("column account_id / primary_agent_id does not exist") came from the old machine's host power-sync tick. Migrations 0027 and 0031 dropped those runtime_hosts columns, and tickPolled's full-column host select then failed with nothing catching it. On 09-30 its guarded stages logged the same error 95 ms before the exit. 584685ac's tick catch already covers this; a test now pins it. The other unawaited calls from the 116-site lint pass cannot reject. Their bodies are wrapped in try/catch or end in .catch(); awake-hold release is synchronous; lease and price-source promises are built from never-rejecting acquire()/load().catch(). The baileys end() calls emit to listeners that return on the stopped flag set just before. Fastify reply calls are not promises. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(usage): price resumed Claude Code runs on their verified served route Claude Code never stamped a served pricing scope at dispatch, so a resumed run on a ledger-restoring CLI (priced from its own tokens) used the Agent's current provider row with no managed brand, and a re-attach after an API restart read no scope at all. A brand switch or a run on the CLI's own sign-in was priced and attributed as if the saved binding had served it. Claude Code now verifies the credential snapshot it injects (key, endpoint and protocol against the bound provider row), stamps that scope before the exec starts, and prices the live run, the re-attach and adoption from the stamp. A run without platform credentials stamps the unknown scope. Fresh runs keep the CLI's own total_cost_usd. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: price OpenClaw and Hermes turns by the route the daemon attests An OpenClaw gateway and a Hermes ACP child pick their provider from the runtime's own config, so the provider the Agent is bound to says nothing about which route served a turn, and pricing by it is a guess the route contract rules out. Each turn now carries a fresh nonce (turn.route-attestation.v1). The daemon resolves the route that served it the way the runtime does and answers with HMAC-SHA256 keyed by that provider's API key over the nonce, the wire protocol and the normalized base URL (one helper in @manyfold/shared builds the message for both sides), so the key never leaves the machine: - OpenClaw: the single provider the transcript names, looked up in the gateway's openclaw.json at turn start and again at the end, with env references resolved only from an environment the daemon itself gave the gateway; anything else is unresolved. - Hermes: the config.yaml model section and the provider key in the env the ACP child is spawned with, after the dotenv hermes loads over it. Profiles, a managed scope, secret-manager sources, credential pools and key commands are unresolved, and a resumed session is attested only on the route this daemon saw it created on. The API stamps a receipt at dispatch (what the bound row's key would answer on each of its endpoints, no key) and settles the turn's scope from the daemon's answer at the final, live or replayed after a restart. A match prices with that row and its managed brand; a mismatch, a missing answer or an old daemon prices with no provider scope and is logged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
|
Independent verification at Windows x64, Node 24.21.0 / Bun 1.4.2, ESM / regenerated CommonJS, PostgreSQL 17.11 in a disposable container. Five focused cases failed on upstream and passed on this PR in all four runtime/entry combinations (20 before/after pairs):
I also exercised actual TCP FIN/RST through a local relay during an observed
So these results support the transaction-local close latch and queue rejection, while also confirming that this PR alone is not a complete fix for recovery after TCP reset. I did not run the full upstream suite, Deno, or Cloudflare. Standalone reproduction (save as repro-1215.mjs at the repository root)Set import assert from 'node:assert/strict'
import net from 'node:net'
import { createRequire } from 'node:module'
const [scenario, entry = 'esm'] = process.argv.slice(2)
const imported = entry === 'cjs'
? createRequire(import.meta.url)('./cjs/src/index.js')
: (await import('./src/index.js')).default
const postgres = imported.default ?? imported
const url = process.env.REVIEW_DATABASE_URL
assert.ok(url, 'Set REVIEW_DATABASE_URL to a disposable PostgreSQL server')
const delay = ms => new Promise(resolve => setTimeout(resolve, ms))
const deferred = () => {
let resolve, reject
const promise = new Promise((a, b) => { resolve = a; reject = b })
return { promise, resolve, reject }
}
const timer = setTimeout(() => {
console.error('FAIL: operation did not settle within 6 seconds')
process.exit(2)
}, 6000)
const options = { max: 1, fetch_types: false, prepare: false, connect_timeout: 2, onnotice: () => {} }
const clients = []
const client = extra => {
const sql = postgres(url, { ...options, ...extra })
clients.push(sql)
return sql
}
const observer = client()
async function disconnected() {
const sql = client()
const ready = deferred(), resume = deferred(), attempted = deferred()
let oldTx
const failed = sql.begin(async tx => {
oldTx = tx
ready.resolve((await tx`select pg_backend_pid() as pid`)[0].pid)
await resume.promise
if (scenario === 'late-query') {
const error = await tx`select 42 as value`.then(() => null, error => error)
attempted.resolve(error)
}
if (scenario === 'late-rollback') throw new Error('original callback failed')
}).then(() => null, error => error)
try {
const pid = await ready.promise
await observer`select pg_terminate_backend(${pid}::int)`
const closed = await failed
assert.equal(closed?.code, 'CONNECTION_CLOSED')
if (scenario === 'late-query') {
resume.resolve()
assert.equal((await attempted.promise)?.code, 'CONNECTION_CLOSED')
await delay(30)
} else {
await sql.begin(async tx => {
const before = (await tx`select txid_current()::text as id`)[0].id
if (scenario === 'reused-query') {
await tx`select set_config('review.owner', 'replacement', true)`
const error = await oldTx`select current_setting('review.owner')`.then(() => null, error => error)
assert.equal(error?.code, 'CONNECTION_CLOSED', 'Old handle must not read the replacement transaction')
}
resume.resolve()
await delay(50)
const after = (await tx`select txid_current()::text as id`)[0].id
assert.equal(after, before, 'Old callback must not finish the replacement transaction')
})
}
assert.equal((await sql`select 43 as value`)[0].value, 43)
} finally {
resume.resolve()
}
}
async function queued() {
const sql = client({ max_pipeline: 1 })
let outcomes
const closed = await sql.begin(tx => {
outcomes = Promise.allSettled([
tx`select pg_terminate_backend(pg_backend_pid())`.execute(),
tx`select 1`.execute(),
tx`select 2`.execute()
])
return outcomes
}).then(() => null, error => error)
assert.equal(closed?.code, 'CONNECTION_CLOSED')
const results = await outcomes
assert.deepEqual(results.map(result => result.status), ['rejected', 'rejected', 'rejected'])
assert.equal((await sql`select 43 as value`)[0].value, 43)
}
async function transportLoss() {
const target = new URL(url)
const sockets = new Set()
let pair
const relay = net.createServer(downstream => {
const upstream = net.connect(Number(target.port), target.hostname)
pair = { downstream, upstream }
for (const socket of [downstream, upstream]) {
sockets.add(socket)
socket.on('error', () => {})
socket.on('close', () => sockets.delete(socket))
}
downstream.pipe(upstream).pipe(downstream)
})
await new Promise(resolve => relay.listen(0, '127.0.0.1', resolve))
const via = new URL(url)
via.port = String(relay.address().port)
const sql = postgres(via.toString(), options)
clients.push(sql)
try {
const [{ pid }] = await sql`select pg_backend_pid() as pid`
const failed = sql.begin(async tx => {
await tx`select pg_sleep(5)`
}).then(() => null, error => error)
for (;;) {
const [state] = await observer`select wait_event from pg_stat_activity where pid = ${pid}`
if (state?.wait_event === 'PgSleep') break
await delay(5)
}
pair.upstream.unpipe(pair.downstream)
pair.downstream.unpipe(pair.upstream)
if (scenario === 'rst') pair.downstream.resetAndDestroy()
else pair.downstream.end()
pair.upstream.destroy()
const error = await failed
assert.ok(error, 'The interrupted transaction must reject')
console.log(`Transaction rejected: ${error.code}`)
for (const value of [41, 42, 43]) {
assert.equal((await sql`select ${value}::int as value`)[0].value, value)
console.log(`Reconnect query ${value}: passed`)
}
} finally {
await sql.end({ timeout: 0 })
for (const socket of sockets) socket.destroy()
await new Promise(resolve => relay.close(resolve))
}
}
try {
if (scenario === 'queued') await queued()
else if (scenario === 'fin' || scenario === 'rst') await transportLoss()
else await disconnected()
console.log(`PASS ${scenario} ${entry}`)
} catch (error) {
console.error(`FAIL ${scenario} ${entry}:`, error)
process.exitCode = 1
} finally {
await Promise.allSettled(clients.map(sql => sql.end({ timeout: 0 })))
clearTimeout(timer)
}Prepared with Codex assistance; the results above come from executed checks. |
|
A reproduction of the transaction case and of the state a closed connection keeps, with a patch combining this PR's change with #1240's and #1246's, is in #1240 (comment). |
Fixes #1216.
If a backend disconnects while a
sql.begin()callback is awaiting other work,begin()rejects but the callback keeps running. Once the pool reconnects, that callback can issue queries on the replacement connection. Returning or throwing from it can also commit or roll back a different transaction. Queries still queued inside the original transaction can remain pending indefinitely.Keep the close error on the transaction, reject its queued queries, and reject subsequent queries through that transaction's handler, including automatic commit/rollback. Clear the connection's pending write and query/result/error state before reuse, too. Without that reset, the first query after reconnect can receive the old backend's fatal error.
The four regression tests cover queued queries, a query through the disconnected transaction, and late commit/rollback. The latter three share a fixture that pauses the original callback until a replacement transaction is open. No new dependencies or public API changes.
Related to #1204. This reproduction specifically covers a backend disconnect followed by connection reuse; I haven't verified that it explains the original report.
Reproduce
Save the following as
repro.mjsin the repository root. It needs Node 22 and a PostgreSQL login that can terminate its own other sessions.For a disposable local server:
docker run --rm -d --name postgres-disconnect-repro \ -p 127.0.0.1:55432:5432 \ -e POSTGRES_HOST_AUTH_METHOD=trust postgres:17 docker exec postgres-disconnect-repro pg_isready -U postgres PGHOST=127.0.0.1 PGPORT=55432 PGUSER=postgres PGDATABASE=postgres node repro.mjs docker stop postgres-disconnect-reproWait for
pg_isreadyto report that the server is accepting connections before running the script. On upstream411429e, the assertion fails because the transaction IDs differ: the old callback has committed the replacement transaction. On this branch they stay equal and the script exits successfully. I verified both outcomes.Tests
The first commit,
0282301, adds only the regression tests; the second applies the fix. With the PostgreSQL setup from the existing test workflow in place:The first run times out in
Disconnect rejects queued transaction queries and allows reconnect. The fixed branch passes. The full suite needs both PostgreSQL servers from the workflow (ports 5432 and 5433), SSL, logical replication, prepared transactions, the PostgreSQL CLI tools, and Deno 1.x. Use disposable databases: the existing bootstrap changes server settings and recreates its test database and roles.Validated against PostgreSQL 17.11:
tests/test.js:20assumes a Node stack format; the same failure occurs on unmodified upstream.