pgserve 2.0.5 → 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 +13 -0
- package/package.json +1 -1
- package/src/daemon-control.js +7 -0
- package/src/daemon.js +63 -0
- package/tests/router-handshake-watchdog.test.js +110 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,19 @@ 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
|
+
|
|
7
20
|
## 2.0.5
|
|
8
21
|
|
|
9
22
|
### Fixed
|
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
|
@@ -290,6 +290,52 @@ export class PgserveDaemon extends EventEmitter {
|
|
|
290
290
|
this.gcOptions = options.gcOptions || {};
|
|
291
291
|
|
|
292
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;
|
|
293
339
|
}
|
|
294
340
|
|
|
295
341
|
/**
|
|
@@ -473,8 +519,20 @@ export class PgserveDaemon extends EventEmitter {
|
|
|
473
519
|
pidLockPath: this.pidLockPath,
|
|
474
520
|
pgPort: this.pgManager.port,
|
|
475
521
|
tcpListens: this.tcpListens,
|
|
522
|
+
handshakeDeadlineMs: this.handshakeDeadlineMs,
|
|
476
523
|
}, 'pgserve daemon listening');
|
|
477
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
|
+
|
|
478
536
|
this.emit('listening');
|
|
479
537
|
return this;
|
|
480
538
|
}
|
|
@@ -488,6 +546,11 @@ export class PgserveDaemon extends EventEmitter {
|
|
|
488
546
|
|
|
489
547
|
this.logger.info?.('Stopping pgserve daemon');
|
|
490
548
|
|
|
549
|
+
if (this._handshakeWatchdogTimer) {
|
|
550
|
+
clearInterval(this._handshakeWatchdogTimer);
|
|
551
|
+
this._handshakeWatchdogTimer = null;
|
|
552
|
+
}
|
|
553
|
+
|
|
491
554
|
for (const socket of this.connections) {
|
|
492
555
|
try { socket.end(); } catch { /* swallow */ }
|
|
493
556
|
}
|
|
@@ -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
|
+
});
|