pgserve 2.0.3 → 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,34 @@ 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
+
21
+ ## 2.0.4
22
+
23
+ ### Fixed
24
+
25
+ - `_startPostgres()` now removes a stale `postmaster.pid` from the data
26
+ directory before spawning postgres. Previously, an unclean shutdown
27
+ (SIGKILL, machine reboot, OOM) left a `postmaster.pid` whose recorded
28
+ PID was no longer alive, and postgres refused to start with
29
+ `FATAL: lock file "postmaster.pid" already exists` on the next boot.
30
+ Operators had to `rm postmaster.pid` manually to recover. A live PID
31
+ is never touched, so a real concurrent postmaster still surfaces the
32
+ normal lock conflict. ([#46](https://github.com/namastexlabs/pgserve/pull/46),
33
+ fixes [#45](https://github.com/namastexlabs/pgserve/issues/45))
34
+
7
35
  ## 2.0.0 — Unreleased
8
36
 
9
37
  > The release date will replace "Unreleased" when the v2.0.0 release workflow
@@ -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.3",
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';
@@ -709,11 +711,57 @@ export class PostgresManager {
709
711
  }
710
712
  }
711
713
 
714
+ /**
715
+ * Detect and remove a stale postmaster.pid that postgres would otherwise
716
+ * refuse to start against. Stale = the PID written into the file is not
717
+ * alive on this host. Called at the top of _startPostgres so that crash
718
+ * / SIGKILL / unclean reboot recovery is automatic.
719
+ *
720
+ * Real running backends are NEVER touched — if the PID is alive we leave
721
+ * the file alone and let postgres surface its normal "lock file already
722
+ * exists" error so the operator sees the conflict.
723
+ */
724
+ async _ensureNoStalePostmasterLock() {
725
+ const pidFile = path.join(this.databaseDir, 'postmaster.pid');
726
+ let raw;
727
+ try {
728
+ raw = await fs.promises.readFile(pidFile, 'utf-8');
729
+ } catch (err) {
730
+ if (err.code === 'ENOENT') return;
731
+ throw err;
732
+ }
733
+ const firstLine = (raw.split('\n')[0] ?? '').trim();
734
+ const pid = Number.parseInt(firstLine, 10);
735
+ if (!Number.isInteger(pid) || pid <= 0) {
736
+ this.logger.warn(
737
+ { pidFile, firstLine },
738
+ 'postmaster.pid is unparseable; removing as stale'
739
+ );
740
+ await fs.promises.unlink(pidFile).catch(() => {});
741
+ return;
742
+ }
743
+ let alive = false;
744
+ try {
745
+ process.kill(pid, 0);
746
+ alive = true;
747
+ } catch (err) {
748
+ // EPERM = process exists but we can't signal it — still alive.
749
+ alive = err.code === 'EPERM';
750
+ }
751
+ if (alive) return;
752
+ this.logger.info(
753
+ { pidFile, stalePid: pid },
754
+ 'Removing stale postmaster.pid (PID not running) before postgres start'
755
+ );
756
+ await fs.promises.unlink(pidFile).catch(() => {});
757
+ }
758
+
712
759
  /**
713
760
  * Start the PostgreSQL server process
714
761
  * Uses Bun.spawn() for ~40% faster process startup
715
762
  */
716
763
  async _startPostgres() {
764
+ await this._ensureNoStalePostmasterLock();
717
765
  return new Promise((resolve, reject) => {
718
766
  // Build PostgreSQL arguments
719
767
  const pgArgs = [
@@ -817,6 +865,7 @@ export class PostgresManager {
817
865
  // the exit is unexpected (external kill, crash, OOM).
818
866
  this.process.exited.then((code) => {
819
867
  processExited = true;
868
+ const expected = !!this._stopping;
820
869
  if (!started) {
821
870
  reject(new Error(`PostgreSQL exited with code ${code} before starting: ${startupOutput}`));
822
871
  }
@@ -824,7 +873,7 @@ export class PostgresManager {
824
873
  // On unexpected exit (not via stop()), reset cached paths so that
825
874
  // getSocketPath() returns null and callers can fall back to TCP
826
875
  // or force a fresh start().
827
- if (!this._stopping) {
876
+ if (!expected) {
828
877
  this.socketDir = null;
829
878
  this.databaseDir = null;
830
879
  this.logger?.warn(
@@ -832,6 +881,10 @@ export class PostgresManager {
832
881
  'PostgreSQL subprocess exited unexpectedly — socketDir/databaseDir reset'
833
882
  );
834
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 });
835
888
  });
836
889
 
837
890
  // Method 1: TCP connection polling (preferred, works on Linux/macOS)
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Stale postmaster.pid cleanup
3
+ *
4
+ * Verifies that PostgresManager._ensureNoStalePostmasterLock removes
5
+ * a postmaster.pid file whose recorded PID is no longer alive, and
6
+ * leaves alone a postmaster.pid whose recorded PID is alive.
7
+ *
8
+ * Regression coverage: postgres refuses to start when postmaster.pid
9
+ * exists, even if the writer crashed. After unclean shutdowns this
10
+ * required manual `rm` to recover.
11
+ */
12
+
13
+ import { PostgresManager } from '../src/postgres.js';
14
+ import { test, expect } from 'bun:test';
15
+ import fs from 'fs';
16
+ import path from 'path';
17
+ import os from 'os';
18
+
19
+ function makeMgr(dataDir) {
20
+ const mgr = new PostgresManager({ dataDir });
21
+ mgr.databaseDir = dataDir;
22
+ mgr.logger = {
23
+ info: () => {},
24
+ warn: () => {},
25
+ error: () => {},
26
+ debug: () => {},
27
+ };
28
+ return mgr;
29
+ }
30
+
31
+ function makePidFile(dir, contents) {
32
+ fs.mkdirSync(dir, { recursive: true });
33
+ const pidFile = path.join(dir, 'postmaster.pid');
34
+ fs.writeFileSync(pidFile, contents, 'utf-8');
35
+ return pidFile;
36
+ }
37
+
38
+ test('removes postmaster.pid when recorded PID is dead', async () => {
39
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pgserve-stale-'));
40
+ try {
41
+ // PID 999999999 will not exist on any sane system
42
+ const pidFile = makePidFile(dir, '999999999\n/some/data\n123\n');
43
+ const mgr = makeMgr(dir);
44
+ await mgr._ensureNoStalePostmasterLock();
45
+ expect(fs.existsSync(pidFile)).toBe(false);
46
+ } finally {
47
+ fs.rmSync(dir, { recursive: true, force: true });
48
+ }
49
+ });
50
+
51
+ test('keeps postmaster.pid when recorded PID is the current (alive) process', async () => {
52
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pgserve-alive-'));
53
+ try {
54
+ const pidFile = makePidFile(dir, `${process.pid}\n/some/data\n123\n`);
55
+ const mgr = makeMgr(dir);
56
+ await mgr._ensureNoStalePostmasterLock();
57
+ expect(fs.existsSync(pidFile)).toBe(true);
58
+ } finally {
59
+ fs.rmSync(dir, { recursive: true, force: true });
60
+ }
61
+ });
62
+
63
+ test('removes postmaster.pid when first line is unparseable', async () => {
64
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pgserve-garbage-'));
65
+ try {
66
+ const pidFile = makePidFile(dir, 'garbage\nnot-a-pid\n');
67
+ const mgr = makeMgr(dir);
68
+ await mgr._ensureNoStalePostmasterLock();
69
+ expect(fs.existsSync(pidFile)).toBe(false);
70
+ } finally {
71
+ fs.rmSync(dir, { recursive: true, force: true });
72
+ }
73
+ });
74
+
75
+ test('no-ops when postmaster.pid does not exist', async () => {
76
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pgserve-missing-'));
77
+ try {
78
+ const mgr = makeMgr(dir);
79
+ // Should resolve without throwing
80
+ await mgr._ensureNoStalePostmasterLock();
81
+ expect(fs.existsSync(path.join(dir, 'postmaster.pid'))).toBe(false);
82
+ } finally {
83
+ fs.rmSync(dir, { recursive: true, force: true });
84
+ }
85
+ });
@@ -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
+ });