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