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 +14 -0
- package/bin/postgres-server.js +14 -0
- package/package.json +1 -1
- package/src/daemon.js +12 -0
- package/src/postgres.js +9 -2
- package/tests/wrapper-supervision.test.js +107 -0
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
|
package/bin/postgres-server.js
CHANGED
|
@@ -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
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 (!
|
|
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
|
+
});
|