sql-pg: scoped connection shutdown retains a socket when the peer withholds FIN
I maintainer di solito rispondono entro 1 giorno
Valutazione
Questa issue non è ancora stata valutata.
Descrizione
What version of Effect is running?
Published, unpatched [email protected] and @effect/[email protected], on Linux with Node.js 24.21.0. Also reproduced on unpatched 4.1.0.
What steps can reproduce the bug?
A scoped PostgreSQL connection completes its query and scope release, but its Node process still owns the client socket when the peer withholds its closing FIN. This reproduction uses the default TCP connector, without an ORM, custom stream, or socket monkeypatching.
Prerequisites: Linux (the ownership check uses /proc), Node.js 24, pnpm, and a disposable local PostgreSQL database accepting plain TCP. The query is SELECT 1; no tables are created or modified. Do not use production credentials.
In a fresh directory outside any workspace that patches these packages, save these two files.
package.json:
{
"private": true,
"type": "module",
"dependencies": {
"effect": "4.1.2",
"@effect/sql-pg": "4.1.2"
},
"scripts": { "repro": "node repro.mts" }
}
repro.mts:
import assert from 'node:assert/strict';
import { createConnection, createServer } from 'node:net';
import type { Socket } from 'node:net';
import { readdir, readFile, readlink } from 'node:fs/promises';
import { setTimeout as delay } from 'node:timers/promises';
import * as Effect from 'effect/Effect';
import * as Redacted from 'effect/Redacted';
import * as PgConnection from '@effect/sql-pg/PgConnection';
assert.equal(process.platform, 'linux', 'This diagnostic uses Linux /proc');
const databaseUrl = process.env.PG_URL ?? process.env.TEST_DATABASE_URL;
assert(databaseUrl, 'Set PG_URL to a disposable local PostgreSQL database');
const upstream = new URL(databaseUrl);
const relaySockets: Socket[] = [];
const clientPorts = new Set<number>();
const relay = createServer({ allowHalfOpen: true }, (frontend) => {
assert(frontend.remotePort);
clientPorts.add(frontend.remotePort);
const backend = createConnection({ host: upstream.hostname, port: Number(upstream.port || 5432) });
relaySockets.push(frontend, backend);
frontend.on('error', () => undefined);
backend.on('error', () => undefined);
frontend.pipe(backend);
backend.pipe(frontend, { end: false });
});
async function ownedDriverSockets(relayPort: number) {
const links = await Promise.all((await readdir('/proc/self/fd')).map((fd) => readlink(`/proc/self/fd/${fd}`).catch(() => '')));
const ownedInodes = new Set(links.flatMap((link) => {
const match = /^socket:\[(\d+)\]$/.exec(link);
return match ? [match[1]] : [];
}));
const rows = (await readFile('/proc/self/net/tcp', 'utf8')).trim().split('\n').slice(1);
return rows.map((row) => row.trim().split(/\s+/)).filter((fields) =>
clientPorts.has(parseInt(fields[1].split(':')[1], 16)) &&
parseInt(fields[2].split(':')[1], 16) === relayPort &&
ownedInodes.has(fields[9])
);
}
await new Promise<void>((accept) => relay.listen(0, '127.0.0.1', accept));
const address = relay.address();
assert(address && typeof address !== 'string');
const url = new URL(upstream);
url.hostname = '127.0.0.1';
url.port = String(address.port);
try {
await Effect.runPromise(Effect.scoped(Effect.gen(function* () {
const connection = yield* PgConnection.make({ url: Redacted.make(url.href), ssl: false });
const result = yield* connection.query('SELECT 1 AS alive');
assert.equal(result.rows[0].alive, 1);
assert.equal((yield* Effect.promise(() => ownedDriverSockets(address.port))).length, 1);
})));
await delay(1250);
const retained = await ownedDriverSockets(address.port);
console.log(JSON.stringify({ retainedOwnedSockets: retained.length, tcpStates: retained.map((row) => row[3]) }));
process.exitCode = retained.length === 0 ? 0 : 1;
} finally {
for (const socket of relaySockets) socket.destroy();
await new Promise<void>((accept) => relay.close(() => accept()));
}
Install and run, replacing the URL with your disposable database's credentials:
pnpm install
PG_URL='postgres://USER:[email protected]:5432/DISPOSABLE_DB' pnpm repro
The relay forwards PostgreSQL traffic, including Terminate, but deliberately does not forward the backend's closing FIN. To run a healthy-peer control, replace backend.pipe(frontend, { end: false }); with backend.pipe(frontend); and rerun.
What is the expected behavior?
I would expect scoped release to retire the driver-owned transport within a bounded time, including when the peer does not finish closing it: allow graceful termination, then destroy and join the socket if necessary.
However, PgConnection.make documents sending Terminate and ending the socket, not explicitly waiting for physical closure. Could maintainers clarify whether bounded physical transport retirement should be part of this scoped resource's contract? I am not assuming the current behavior violates an existing documented guarantee.
What do you see instead?
The query succeeds and Effect.scoped returns. Measured 1.25 seconds later:
{"retainedOwnedSockets":1,"tcpStates":["05"]}
05 is Linux FIN_WAIT2. The diagnostic exits with status 1 to indicate a retained socket; pnpm's resulting lifecycle failure is expected. An earlier connection/query error is not this observation.
The healthy-peer control succeeds with zero retained owned sockets and exit status 0:
{"retainedOwnedSockets":0,"tcpStates":[]}
The ownership check matches client-side TCP tuple/inode entries against /proc/self/fd, excluding the relay's endpoints and orphaned kernel TCP records.
Additional information
The finalizer calls closeUnsafe() synchronously, which calls socket.end(Terminate) without joining closure. The Effect runtime therefore has no asynchronous transport-close finalizer to await; this is not a claim that the runtime fails to await a finalizer it was given.
A local 4.1.0 patch allows one second for graceful termination, then destroys the transport and joins close/closed. In a same-version A/B control, it leaves zero owned sockets in this scenario. One second is a local policy choice, not a proposed upstream default. The held-FIN scope completes in approximately 11–15 ms unpatched versus 1,013 ms patched; that is a shutdown-policy tradeoff, not a query benchmark.
An additional default-connector control, separate from the minimal script above, repeats reserve/invalidate three times with maxConnections: 1. Unpatched 4.1.0 and 4.1.2 leave three owned FIN_WAIT2 sockets after pool disposal; patched 4.1.0 leaves none. This is not a claim that three PostgreSQL backend sessions remain alive or that the pool promises a strict physical-socket ceiling during retirement.
Related work checked:
- #8854 detaches listeners and retirement hooks while preserving graceful shutdown.
- #9895 / #9896 bound stalled UNLISTEN cleanup; this reproduction has no listener.
- #10000 addresses pin/UNLISTEN ordering, not physical closure.
This evidence establishes the local fault scenario, not production frequency, indefinite retention, eventual OS reclamation time, or TLS behavior. I would like to agree on the intended shutdown semantics before proposing a driver regression and a fix.
- Lingua principale
- TypeScript
- Stelle
- 16.7k
- Fork
- 808
- Merge medio
- 12h 17m
- PR unite (30g)
- 805
Preparare l'ambiente
Questo progetto non fornisce container di sviluppo, Dockerfile né guida per i contributori, quindi l'ambiente è a tuo carico: parti dal suo README e consulta la nostra guida al primo contributo per i passaggi generali.
Come iniziare
- Leggi tutta la issue e poi la guida ai contributi del progetto.
- Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
- Fai un fork del repository e lavora su un branch.
- Apri una pull request che faccia riferimento al numero della issue.
Altre issue di Effect-TS/effect
-
From Discord: Return Stream.empty for HTTP requests with an empty bodyForse già presa @pujitha24 l’ha presa oggi. Aperta
Difficoltà 2/5 1-3 ore Idoneità per principianti 30/100
I maintainer di solito rispondono entro 1 giorno
-
Schema: a public way to derive a canonical codec, as `toCodecJson` and `toCodecStringTree` doForse già presa @effect-bot l’ha presa oggi. ApertaNeeds Proposal schema
Effect-TS/effect#10457 · 1 assegnatario ·
I maintainer di solito rispondono entro 1 giorno
-
McpServer.layerHttp: optional legacy GET SSE for idle list-change notificationsForse già presa @effect-bot l’ha presa 1 giorno fa. Aperta
Effect-TS/effect#10436 · 1 assegnatario ·
I maintainer di solito rispondono entro 1 giorno
-
SqlClient: a fiber forked inside a transaction keeps using its connection after the transaction endsForse già presa @effect-bot l’ha presa 1 giorno fa. Apertabug
Effect-TS/effect#10431 · 1 assegnatario ·
I maintainer di solito rispondono entro 1 giorno
-
CLI completions stop offering repeatable flags after their first useForse già presa @effect-bot l’ha presa 1 giorno fa. Aperta
Effect-TS/effect#10427 · 1 assegnatario ·
I maintainer di solito rispondono entro 1 giorno
Tutte le issue di Effect-TS/effect
Issue simili
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 78/100
microsoft/vscode-containers#655 ·
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 77/100
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 72/100
dsh-tauri-desk/deepseek-harness-desktop#1477 · 1 commento ·
-
[docs] RELEASE.md: wrong branch name, no check that the prepare PR merged with the right subject, manual publish stepForse già presa @albertteoh l’ha presa oggi. Apertadocumentation
Difficoltà 2/5 1-3 ore Idoneità per principianti 85/100
jaegertracing/jaeger-ui#4739 ·
I maintainer di solito rispondono entro 1 giorno
-
Add: Shop On TV SDApertacheck:passed streams:add
Difficoltà 2/5 1-3 ore Idoneità per principianti 64/100
I maintainer di solito rispondono entro 1 giorno