sql-pg: scoped connection shutdown retains a socket when the peer withholds FIN
Maintainer thường phản hồi trong vòng 1 ngày
Đánh giá
Issue này chưa được đánh giá.
Mô tả
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.
- Ngôn ngữ chính
- TypeScript
- Star
- 16.7k
- Fork
- 808
- Merge trung bình
- 12 giờ 17 phút
- Pull request đã merge (30 ngày)
- 805
Chuẩn bị môi trường
Dự án này không cung cấp dev container, Dockerfile hay hướng dẫn đóng góp, nên bạn cần tự thiết lập môi trường: hãy bắt đầu từ README và xem hướng dẫn đóng góp lần đầu của chúng tôi để biết các bước chung.
Bắt đầu từ đâu
- Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
- Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
- Fork repository và làm thay đổi trên một nhánh.
- Mở pull request có tham chiếu số hiệu của issue.
Issue khác của Effect-TS/effect
-
From Discord: Return Stream.empty for HTTP requests with an empty bodyCó thể đã có người làm @pujitha24 đã nhận hôm nay. Đang mở
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 30/100
Maintainer thường phản hồi trong vòng 1 ngày
-
Schema: a public way to derive a canonical codec, as `toCodecJson` and `toCodecStringTree` doCó thể đã có người làm @effect-bot đã nhận hôm nay. Đang mởNeeds Proposal schema
Effect-TS/effect#10457 · 1 người được giao ·
Maintainer thường phản hồi trong vòng 1 ngày
-
McpServer.layerHttp: optional legacy GET SSE for idle list-change notificationsCó thể đã có người làm @effect-bot đã nhận 1 ngày trước. Đang mở
Effect-TS/effect#10436 · 1 người được giao ·
Maintainer thường phản hồi trong vòng 1 ngày
-
SqlClient: a fiber forked inside a transaction keeps using its connection after the transaction endsCó thể đã có người làm @effect-bot đã nhận 1 ngày trước. Đang mởbug
Effect-TS/effect#10431 · 1 người được giao ·
Maintainer thường phản hồi trong vòng 1 ngày
-
CLI completions stop offering repeatable flags after their first useCó thể đã có người làm @effect-bot đã nhận 1 ngày trước. Đang mở
Effect-TS/effect#10427 · 1 người được giao ·
Maintainer thường phản hồi trong vòng 1 ngày
Tất cả issue của Effect-TS/effect
Issue tương tự
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 78/100
microsoft/vscode-containers#655 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 77/100
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 72/100
dsh-tauri-desk/deepseek-harness-desktop#1477 · 1 bình luận ·
-
[docs] RELEASE.md: wrong branch name, no check that the prepare PR merged with the right subject, manual publish stepCó thể đã có người làm @albertteoh đã nhận hôm nay. Đang mởdocumentation
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 85/100
jaegertracing/jaeger-ui#4739 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Add: Shop On TV SDĐang mởcheck:passed streams:add
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 64/100
Maintainer thường phản hồi trong vòng 1 ngày