pgserve 2.0.4 → 2.0.5

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,20 @@ 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.5
8
+
9
+ ### Fixed
10
+
11
+ - `PostgresManager` now extends `EventEmitter` and emits `backendExited`
12
+ with `{ code, expected }` when the postgres child exits. `expected=true`
13
+ is reserved for shutdowns initiated by `stop()`; everything else is
14
+ treated as a fault. `PgserveDaemon` re-emits unexpected exits as
15
+ `backendDiedUnexpectedly`, and the daemon CLI wrapper subscribes and
16
+ exits non-zero so a process supervisor (`genie serve`, pm2, systemd)
17
+ can restart the daemon cleanly. Previously, an external SIGKILL of
18
+ the postgres backend left the wrapper alive in `epoll_wait` while the
19
+ control socket accepted connections forever — pgserve#45.
20
+
7
21
  ## 2.0.4
8
22
 
9
23
  ### 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.5",
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",
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();
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,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
+ });