sql-pg: scoped connection shutdown retains a socket when the peer withholds FIN

Đang mở
#10,458 0 bình luận 0 reaction 1 người được giao Xem trên GitHub

Maintainer thường phản hồi trong vòng 1 ngày

@effect-bot đang làm issue này rồi.

Từ ngày 7/11/2026.

  • #10459 của @PanAchy — đang mở

Đá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

  1. Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
  2. 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.
  3. Fork repository và làm thay đổi trên một nhánh.
  4. Mở pull request có tham chiếu số hiệu của issue.

Issue khác của Effect-TS/effect

Tất cả issue của Effect-TS/effect

Issue tương tự

Thêm issue về TypeScript

Nhận issue mới trong hộp thư của bạn

Bản tóm tắt ngắn những issue GitHub phù hợp với người mới.