pgserve 2.0.5 → 2.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,19 @@ All notable changes to `pgserve` are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres
5
5
  to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## 2.0.6
8
+
9
+ ### Fixed
10
+
11
+ - `PgserveDaemon` now runs a watchdog that forcibly closes peers stuck in
12
+ pre-handshake state past `PGSERVE_HANDSHAKE_DEADLINE_MS` (default
13
+ 30000ms). Without this, a peer that connected to `control.sock` and
14
+ never sent the postgres StartupMessage occupied a connection slot
15
+ indefinitely — pgserve#45 documented the file-descriptor leak under
16
+ load. The watchdog runs every `handshakeSweepIntervalMs` (default
17
+ 5000ms, bounded at 1s minimum). Stalls are logged with `acceptedAt`,
18
+ `ageMs`, and the peer's fingerprint.
19
+
7
20
  ## 2.0.5
8
21
 
9
22
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pgserve",
3
- "version": "2.0.5",
3
+ "version": "2.0.6",
4
4
  "description": "Embedded PostgreSQL server with true concurrent connections - zero config, auto-provision databases",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -72,6 +72,13 @@ function handleSocketOpen(socket) {
72
72
  pendingToPg: null,
73
73
  pendingToClient: null,
74
74
  fingerprint,
75
+ // Wall-clock timestamp when this socket was accepted. The watchdog
76
+ // installed by PgserveDaemon.start() forcibly closes any socket that
77
+ // hasn't completed its postgres handshake within
78
+ // PGSERVE_HANDSHAKE_DEADLINE_MS. Without this, a peer that connects
79
+ // and never sends the StartupMessage occupies the connection slot
80
+ // forever — the file-descriptor leak documented in pgserve#45.
81
+ acceptedAt: Date.now(),
75
82
  });
76
83
  this.connections.add(socket);
77
84
  if (fingerprint) {
package/src/daemon.js CHANGED
@@ -290,6 +290,52 @@ export class PgserveDaemon extends EventEmitter {
290
290
  this.gcOptions = options.gcOptions || {};
291
291
 
292
292
  this.setMaxListeners(this.maxConnections + 10);
293
+
294
+ // Watchdog: forcibly close any control-socket peer that has been accepted
295
+ // but hasn't completed the postgres handshake within this deadline. The
296
+ // env override is for tests (or for operators who want a tighter bound).
297
+ // See pgserve#45: peers that connected and never sent a StartupMessage
298
+ // would pile up indefinitely in `state.handshakeComplete=false`,
299
+ // exhausting connection slots.
300
+ const envDeadline = Number.parseInt(process.env.PGSERVE_HANDSHAKE_DEADLINE_MS ?? '', 10);
301
+ this.handshakeDeadlineMs =
302
+ Number.isFinite(envDeadline) && envDeadline > 0
303
+ ? envDeadline
304
+ : (options.handshakeDeadlineMs ?? 30_000);
305
+ // Sweep cadence: small enough to bound the worst-case slop on top of the
306
+ // deadline (5s default → 30s deadline becomes "killed within 30-35s").
307
+ this.handshakeSweepIntervalMs = Math.max(
308
+ 1000,
309
+ Math.min(this.handshakeDeadlineMs, options.handshakeSweepIntervalMs ?? 5_000),
310
+ );
311
+ this._handshakeWatchdogTimer = null;
312
+ }
313
+
314
+ /**
315
+ * Iterate accepted sockets and force-close any that have been waiting on
316
+ * the postgres handshake for longer than `handshakeDeadlineMs`. Exposed on
317
+ * the prototype so tests can drive it deterministically without waiting for
318
+ * the timer.
319
+ */
320
+ _sweepStuckHandshakes() {
321
+ const now = Date.now();
322
+ let closed = 0;
323
+ for (const socket of this.connections) {
324
+ const state = this.socketState.get(socket);
325
+ if (!state) continue;
326
+ if (state.handshakeComplete) continue;
327
+ const acceptedAt = state.acceptedAt ?? now;
328
+ if (now - acceptedAt < this.handshakeDeadlineMs) continue;
329
+ this.logger.warn?.(
330
+ { acceptedAt, ageMs: now - acceptedAt, deadlineMs: this.handshakeDeadlineMs, fingerprint: state.fingerprint },
331
+ 'Closing peer stuck in pre-handshake state past deadline',
332
+ );
333
+ try { socket.end(); } catch { /* swallow */ }
334
+ this.connections.delete(socket);
335
+ this.socketState.delete(socket);
336
+ closed++;
337
+ }
338
+ return closed;
293
339
  }
294
340
 
295
341
  /**
@@ -473,8 +519,20 @@ export class PgserveDaemon extends EventEmitter {
473
519
  pidLockPath: this.pidLockPath,
474
520
  pgPort: this.pgManager.port,
475
521
  tcpListens: this.tcpListens,
522
+ handshakeDeadlineMs: this.handshakeDeadlineMs,
476
523
  }, 'pgserve daemon listening');
477
524
 
525
+ // Arm the handshake watchdog. unref() so the timer doesn't keep the
526
+ // process alive on its own — the daemon already awaits the wrapper's
527
+ // forever-promise.
528
+ this._handshakeWatchdogTimer = setInterval(
529
+ () => this._sweepStuckHandshakes(),
530
+ this.handshakeSweepIntervalMs,
531
+ );
532
+ if (typeof this._handshakeWatchdogTimer.unref === 'function') {
533
+ this._handshakeWatchdogTimer.unref();
534
+ }
535
+
478
536
  this.emit('listening');
479
537
  return this;
480
538
  }
@@ -488,6 +546,11 @@ export class PgserveDaemon extends EventEmitter {
488
546
 
489
547
  this.logger.info?.('Stopping pgserve daemon');
490
548
 
549
+ if (this._handshakeWatchdogTimer) {
550
+ clearInterval(this._handshakeWatchdogTimer);
551
+ this._handshakeWatchdogTimer = null;
552
+ }
553
+
491
554
  for (const socket of this.connections) {
492
555
  try { socket.end(); } catch { /* swallow */ }
493
556
  }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Handshake watchdog: peers that connect and never complete the postgres
3
+ * StartupMessage are forcibly closed past `PGSERVE_HANDSHAKE_DEADLINE_MS`.
4
+ *
5
+ * Regression coverage: pgserve#45 documented file-descriptor leak where
6
+ * peers piled up indefinitely in `state.handshakeComplete=false`.
7
+ *
8
+ * The tests drive `_sweepStuckHandshakes()` directly via a synthetic
9
+ * connection record. This avoids spawning a real postgres backend, which
10
+ * is unnecessary when we only want to assert the sweep policy and timer
11
+ * lifecycle.
12
+ */
13
+
14
+ import { PgserveDaemon } from '../src/daemon.js';
15
+ import { test, expect } from 'bun:test';
16
+ import fs from 'fs';
17
+ import path from 'path';
18
+ import os from 'os';
19
+
20
+ function quietLogger() {
21
+ return {
22
+ info: () => {}, warn: () => {}, error: () => {}, debug: () => {},
23
+ child: () => quietLogger(),
24
+ };
25
+ }
26
+
27
+ function makeDaemon(opts = {}) {
28
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pgserve-watchdog-'));
29
+ const daemon = new PgserveDaemon({
30
+ baseDir: dir,
31
+ logger: quietLogger(),
32
+ enforcementDisabled: true,
33
+ ...opts,
34
+ });
35
+ return { daemon, dir };
36
+ }
37
+
38
+ function fakeSocket() {
39
+ const calls = [];
40
+ return {
41
+ end: () => { calls.push('end'); },
42
+ pause: () => {}, resume: () => {}, write: () => 0,
43
+ _calls: calls,
44
+ };
45
+ }
46
+
47
+ test('handshakeDeadlineMs falls back to 30000 when env unset', () => {
48
+ delete process.env.PGSERVE_HANDSHAKE_DEADLINE_MS;
49
+ const { daemon, dir } = makeDaemon();
50
+ expect(daemon.handshakeDeadlineMs).toBe(30000);
51
+ fs.rmSync(dir, { recursive: true, force: true });
52
+ });
53
+
54
+ test('handshakeDeadlineMs honours PGSERVE_HANDSHAKE_DEADLINE_MS env', () => {
55
+ process.env.PGSERVE_HANDSHAKE_DEADLINE_MS = '2000';
56
+ try {
57
+ const { daemon, dir } = makeDaemon();
58
+ expect(daemon.handshakeDeadlineMs).toBe(2000);
59
+ fs.rmSync(dir, { recursive: true, force: true });
60
+ } finally {
61
+ delete process.env.PGSERVE_HANDSHAKE_DEADLINE_MS;
62
+ }
63
+ });
64
+
65
+ test('_sweepStuckHandshakes closes pre-handshake sockets past deadline', () => {
66
+ const { daemon, dir } = makeDaemon({ handshakeDeadlineMs: 100 });
67
+ const sock = fakeSocket();
68
+ const stuckAt = Date.now() - 500; // older than 100ms deadline
69
+ daemon.connections.add(sock);
70
+ daemon.socketState.set(sock, { handshakeComplete: false, acceptedAt: stuckAt });
71
+ const closed = daemon._sweepStuckHandshakes();
72
+ expect(closed).toBe(1);
73
+ expect(sock._calls).toContain('end');
74
+ expect(daemon.connections.has(sock)).toBe(false);
75
+ expect(daemon.socketState.has(sock)).toBe(false);
76
+ fs.rmSync(dir, { recursive: true, force: true });
77
+ });
78
+
79
+ test('_sweepStuckHandshakes leaves fresh pre-handshake sockets alone', () => {
80
+ const { daemon, dir } = makeDaemon({ handshakeDeadlineMs: 30000 });
81
+ const sock = fakeSocket();
82
+ daemon.connections.add(sock);
83
+ daemon.socketState.set(sock, { handshakeComplete: false, acceptedAt: Date.now() });
84
+ const closed = daemon._sweepStuckHandshakes();
85
+ expect(closed).toBe(0);
86
+ expect(sock._calls).not.toContain('end');
87
+ expect(daemon.connections.has(sock)).toBe(true);
88
+ fs.rmSync(dir, { recursive: true, force: true });
89
+ });
90
+
91
+ test('_sweepStuckHandshakes leaves completed-handshake sockets alone even past deadline', () => {
92
+ const { daemon, dir } = makeDaemon({ handshakeDeadlineMs: 100 });
93
+ const sock = fakeSocket();
94
+ daemon.connections.add(sock);
95
+ daemon.socketState.set(sock, { handshakeComplete: true, acceptedAt: Date.now() - 5000 });
96
+ const closed = daemon._sweepStuckHandshakes();
97
+ expect(closed).toBe(0);
98
+ expect(sock._calls).not.toContain('end');
99
+ fs.rmSync(dir, { recursive: true, force: true });
100
+ });
101
+
102
+ test('handshakeSweepIntervalMs is bounded sensibly relative to deadline', () => {
103
+ const { daemon, dir } = makeDaemon({
104
+ handshakeDeadlineMs: 200,
105
+ handshakeSweepIntervalMs: 50,
106
+ });
107
+ // Sweep interval cannot drop below 1s safety floor.
108
+ expect(daemon.handshakeSweepIntervalMs).toBe(1000);
109
+ fs.rmSync(dir, { recursive: true, force: true });
110
+ });