pgserve 2.0.4 → 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,33 @@ 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
+
20
+ ## 2.0.5
21
+
22
+ ### Fixed
23
+
24
+ - `PostgresManager` now extends `EventEmitter` and emits `backendExited`
25
+ with `{ code, expected }` when the postgres child exits. `expected=true`
26
+ is reserved for shutdowns initiated by `stop()`; everything else is
27
+ treated as a fault. `PgserveDaemon` re-emits unexpected exits as
28
+ `backendDiedUnexpectedly`, and the daemon CLI wrapper subscribes and
29
+ exits non-zero so a process supervisor (`genie serve`, pm2, systemd)
30
+ can restart the daemon cleanly. Previously, an external SIGKILL of
31
+ the postgres backend left the wrapper alive in `epoll_wait` while the
32
+ control socket accepted connections forever — pgserve#45.
33
+
7
34
  ## 2.0.4
8
35
 
9
36
  ### Fixed
@@ -83,6 +83,20 @@ async function runDaemonSubcommand(daemonArgs) {
83
83
  // `pgserve daemon` (long-running)
84
84
  const opts = parseDaemonArgs(daemonArgs);
85
85
  const daemon = new PgserveDaemon(opts);
86
+
87
+ // When the postgres backend dies on us (SIGKILL, OOM, segfault, anything
88
+ // other than a clean stop()), exit non-zero so a process supervisor can
89
+ // restart the daemon cleanly. Without this, the wrapper sat alive in
90
+ // epoll_wait while postgres was dead, and clients got "control.sock
91
+ // accepts but never replies" — pgserve#45.
92
+ daemon.on('backendDiedUnexpectedly', ({ code }) => {
93
+ console.error(
94
+ `pgserve daemon: postgres backend exited unexpectedly (code=${code}); ` +
95
+ `the wrapper is exiting so a process supervisor can restart it.`
96
+ );
97
+ process.exit(1);
98
+ });
99
+
86
100
  try {
87
101
  await daemon.start();
88
102
  } catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pgserve",
3
- "version": "2.0.4",
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
@@ -257,6 +257,18 @@ export class PgserveDaemon extends EventEmitter {
257
257
  enablePgvector: options.enablePgvector || false,
258
258
  });
259
259
 
260
+ // Forward unexpected backend deaths to wrapper-level supervisors. A clean
261
+ // stop() sets PostgresManager._stopping=true so the event arrives with
262
+ // expected=true and we leave the daemon alone; an external SIGKILL / OOM
263
+ // / segfault arrives with expected=false and we re-emit so the wrapper
264
+ // can exit non-zero and let a process supervisor (genie serve, pm2,
265
+ // systemd) restart us cleanly. See pgserve#45.
266
+ this.pgManager.on('backendExited', (info) => {
267
+ if (!info.expected) {
268
+ this.emit('backendDiedUnexpectedly', info);
269
+ }
270
+ });
271
+
260
272
  this.server = null;
261
273
  this.tcpServers = [];
262
274
  this.connections = new Set();
@@ -278,6 +290,52 @@ export class PgserveDaemon extends EventEmitter {
278
290
  this.gcOptions = options.gcOptions || {};
279
291
 
280
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;
281
339
  }
282
340
 
283
341
  /**
@@ -461,8 +519,20 @@ export class PgserveDaemon extends EventEmitter {
461
519
  pidLockPath: this.pidLockPath,
462
520
  pgPort: this.pgManager.port,
463
521
  tcpListens: this.tcpListens,
522
+ handshakeDeadlineMs: this.handshakeDeadlineMs,
464
523
  }, 'pgserve daemon listening');
465
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
+
466
536
  this.emit('listening');
467
537
  return this;
468
538
  }
@@ -476,6 +546,11 @@ export class PgserveDaemon extends EventEmitter {
476
546
 
477
547
  this.logger.info?.('Stopping pgserve daemon');
478
548
 
549
+ if (this._handshakeWatchdogTimer) {
550
+ clearInterval(this._handshakeWatchdogTimer);
551
+ this._handshakeWatchdogTimer = null;
552
+ }
553
+
479
554
  for (const socket of this.connections) {
480
555
  try { socket.end(); } catch { /* swallow */ }
481
556
  }
package/src/postgres.js CHANGED
@@ -14,6 +14,7 @@
14
14
  */
15
15
 
16
16
  /* global fetch, Bun */
17
+ import { EventEmitter } from 'events';
17
18
  import os from 'os';
18
19
  import path from 'path';
19
20
  import fs from 'fs';
@@ -419,8 +420,9 @@ function findAvailableTcpPort() {
419
420
  return port;
420
421
  }
421
422
 
422
- export class PostgresManager {
423
+ export class PostgresManager extends EventEmitter {
423
424
  constructor(options = {}) {
425
+ super();
424
426
  this.dataDir = options.dataDir || null; // null = memory mode (temp dir)
425
427
  this.port = options.port ?? 5433; // Internal PG port (router listens on different port)
426
428
  this.user = options.user || 'postgres';
@@ -863,6 +865,7 @@ export class PostgresManager {
863
865
  // the exit is unexpected (external kill, crash, OOM).
864
866
  this.process.exited.then((code) => {
865
867
  processExited = true;
868
+ const expected = !!this._stopping;
866
869
  if (!started) {
867
870
  reject(new Error(`PostgreSQL exited with code ${code} before starting: ${startupOutput}`));
868
871
  }
@@ -870,7 +873,7 @@ export class PostgresManager {
870
873
  // On unexpected exit (not via stop()), reset cached paths so that
871
874
  // getSocketPath() returns null and callers can fall back to TCP
872
875
  // or force a fresh start().
873
- if (!this._stopping) {
876
+ if (!expected) {
874
877
  this.socketDir = null;
875
878
  this.databaseDir = null;
876
879
  this.logger?.warn(
@@ -878,6 +881,10 @@ export class PostgresManager {
878
881
  'PostgreSQL subprocess exited unexpectedly — socketDir/databaseDir reset'
879
882
  );
880
883
  }
884
+ // Notify supervisors. `expected=true` means stop() initiated the exit
885
+ // (clean shutdown); `expected=false` means the backend died on its
886
+ // own — supervisors should treat the latter as a fault signal.
887
+ this.emit('backendExited', { code, expected });
881
888
  });
882
889
 
883
890
  // Method 1: TCP connection polling (preferred, works on Linux/macOS)
@@ -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
+ });
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Wrapper supervision: postgres backend death surfaces to the wrapper
3
+ *
4
+ * Verifies that:
5
+ * 1. PostgresManager extends EventEmitter and emits `backendExited` when
6
+ * the postgres child exits.
7
+ * 2. `expected: true` is reported when the exit was initiated by stop().
8
+ * 3. `expected: false` is reported when the child was killed externally
9
+ * (the case the wrapper needs to react to per pgserve#45).
10
+ * 4. PgserveDaemon re-emits `backendDiedUnexpectedly` only for unexpected
11
+ * exits, not for clean stop().
12
+ *
13
+ * Tests use the real Bun.spawn'd postgres binary via PostgresManager because
14
+ * the supervision contract is end-to-end — a unit test with a mocked process
15
+ * would prove only that the JS plumbing fires.
16
+ */
17
+
18
+ import { PostgresManager } from '../src/postgres.js';
19
+ import { PgserveDaemon } from '../src/daemon.js';
20
+ import { EventEmitter } from 'events';
21
+ import { test, expect } from 'bun:test';
22
+ import fs from 'fs';
23
+ import path from 'path';
24
+ import os from 'os';
25
+
26
+ function quietLogger() {
27
+ return {
28
+ info: () => {}, warn: () => {}, error: () => {}, debug: () => {},
29
+ child: () => quietLogger(),
30
+ };
31
+ }
32
+
33
+ test('PostgresManager extends EventEmitter', () => {
34
+ const mgr = new PostgresManager({});
35
+ expect(mgr).toBeInstanceOf(EventEmitter);
36
+ });
37
+
38
+ test('PostgresManager emits backendExited with expected=true after stop()', async () => {
39
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pgserve-supv-stop-'));
40
+ const mgr = new PostgresManager({ dataDir: dir, logger: quietLogger() });
41
+ let event = null;
42
+ mgr.on('backendExited', (info) => { event = info; });
43
+ await mgr.start();
44
+ await mgr.stop();
45
+ // Give event loop a tick to flush exited.then handler if not already drained
46
+ await new Promise((r) => setTimeout(r, 50));
47
+ expect(event).not.toBeNull();
48
+ expect(event.expected).toBe(true);
49
+ fs.rmSync(dir, { recursive: true, force: true });
50
+ }, 60000);
51
+
52
+ // External-SIGKILL integration coverage runs on Linux only. On macOS,
53
+ // Bun.spawn'd postgres reliably refuses to surface its `exited` promise
54
+ // within the test deadline when killed by SIGKILL — Bun's posix_spawn
55
+ // path on darwin holds parent reaping until grandchildren reap, which
56
+ // postgres never does fast enough for a deterministic test. The
57
+ // `expected=false` branch is still covered cross-platform by the
58
+ // daemon-level re-emit test below, which feeds a synthetic
59
+ // `backendExited` payload through a fake EventEmitter and bypasses the
60
+ // OS signal-handling variability entirely.
61
+ const linuxOnly = process.platform === 'linux' ? test : test.skip;
62
+ linuxOnly('PostgresManager emits backendExited with expected=false on external SIGKILL (linux)', async () => {
63
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pgserve-supv-kill-'));
64
+ const mgr = new PostgresManager({ dataDir: dir, logger: quietLogger() });
65
+ let event = null;
66
+ mgr.on('backendExited', (info) => { event = info; });
67
+ await mgr.start();
68
+ const childPid = mgr.process?.pid;
69
+ expect(childPid).toBeGreaterThan(0);
70
+ // External kill — _stopping stays false, so the handler must mark unexpected
71
+ process.kill(childPid, 'SIGKILL');
72
+ // Wait for the exit handler to fire (max 3s)
73
+ for (let i = 0; i < 60 && event === null; i++) {
74
+ await new Promise((r) => setTimeout(r, 50));
75
+ }
76
+ expect(event).not.toBeNull();
77
+ expect(event.expected).toBe(false);
78
+ // Cleanup: paths were nulled by the unexpected-exit branch, so stop() is a no-op
79
+ await mgr.stop().catch(() => {});
80
+ fs.rmSync(dir, { recursive: true, force: true });
81
+ }, 60000);
82
+
83
+ test('PgserveDaemon re-emits backendDiedUnexpectedly only on unexpected exit', () => {
84
+ // Pure plumbing test — synthesize PgserveDaemon and a fake pgManager that
85
+ // is just an EventEmitter; verify the wiring.
86
+ const fakePgManager = new EventEmitter();
87
+ // PgserveDaemon constructor needs a baseDir; passing a tmp dir avoids
88
+ // touching real config.
89
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pgserve-supv-daemon-'));
90
+ const daemon = new PgserveDaemon({
91
+ baseDir: dir,
92
+ logger: quietLogger(),
93
+ pgManager: fakePgManager,
94
+ enforcementDisabled: true,
95
+ });
96
+ const events = [];
97
+ daemon.on('backendDiedUnexpectedly', (info) => events.push(info));
98
+
99
+ fakePgManager.emit('backendExited', { code: 0, expected: true });
100
+ expect(events).toHaveLength(0); // clean stop — no re-emit
101
+
102
+ fakePgManager.emit('backendExited', { code: 137, expected: false });
103
+ expect(events).toHaveLength(1);
104
+ expect(events[0]).toEqual({ code: 137, expected: false });
105
+
106
+ fs.rmSync(dir, { recursive: true, force: true });
107
+ });