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

Aperta
#10,458 0 commenti 0 reazioni 1 assegnatario Vedi su GitHub

I maintainer di solito rispondono entro 1 giorno

@effect-bot ci sta già lavorando.

Dal 7/11/2026.

  • #10459 di @PanAchy — aperta

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

  1. Leggi tutta la issue e poi la guida ai contributi del progetto.
  2. Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
  3. Fai un fork del repository e lavora su un branch.
  4. Apri una pull request che faccia riferimento al numero della issue.

Altre issue di Effect-TS/effect

Tutte le issue di Effect-TS/effect

Issue simili

Altre issue su TypeScript

Ricevi le nuove issue nella tua casella

Un breve riepilogo di issue GitHub adatte ai principianti.