pgserve 2.3.0 → 2.5.0

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.
Files changed (44) hide show
  1. package/bin/pgserve-wrapper.cjs +9 -4
  2. package/bin/postgres-server.js +170 -631
  3. package/config/logrotate.d/pgserve +47 -0
  4. package/config/pgaudit.conf +31 -0
  5. package/package.json +3 -2
  6. package/scripts/audit-redaction-lint.js +349 -0
  7. package/scripts/test-npx.sh +32 -10
  8. package/src/audit/audit.js +134 -0
  9. package/src/cli-install.cjs +340 -100
  10. package/src/commands/uninstall.js +241 -0
  11. package/src/commands/verify.js +360 -0
  12. package/src/cosign/cache-token.js +328 -0
  13. package/src/cosign/schema.js +97 -0
  14. package/src/cosign/trust-list.js +81 -0
  15. package/src/cosign/verify-binary.js +277 -0
  16. package/src/index.js +11 -44
  17. package/src/lib/admin-json.js +202 -0
  18. package/src/lib/pm2-args.js +119 -0
  19. package/src/lib/runtime-json.js +181 -0
  20. package/src/lib/socket-dir.js +69 -0
  21. package/src/postgres.js +64 -5
  22. package/src/upgrade/index.js +5 -0
  23. package/src/upgrade/steps/cosign-meta-migration.js +123 -0
  24. package/src/admin-client.js +0 -223
  25. package/src/audit.js +0 -168
  26. package/src/cluster.js +0 -654
  27. package/src/control-db.js +0 -330
  28. package/src/daemon-control.js +0 -468
  29. package/src/daemon-shared.js +0 -18
  30. package/src/daemon-tcp.js +0 -297
  31. package/src/daemon.js +0 -709
  32. package/src/dashboard.js +0 -217
  33. package/src/fingerprint.js +0 -479
  34. package/src/gc.js +0 -351
  35. package/src/pg-wire.js +0 -869
  36. package/src/protocol.js +0 -389
  37. package/src/restore.js +0 -574
  38. package/src/router.js +0 -546
  39. package/src/sdk.js +0 -137
  40. package/src/stats-collector.js +0 -453
  41. package/src/stats-dashboard.js +0 -401
  42. package/src/sync.js +0 -335
  43. package/src/tenancy.js +0 -75
  44. package/src/tokens.js +0 -102
package/src/daemon.js DELETED
@@ -1,709 +0,0 @@
1
- /**
2
- * pgserve daemon — singleton control-socket server (orchestrator).
3
- *
4
- * One process per host. Listens on a well-known Unix socket
5
- * (`$XDG_RUNTIME_DIR/pgserve/control.sock`, fallback `/tmp/pgserve/control.sock`),
6
- * supervises a single PostgresManager instance, and proxies every accepted
7
- * client through to the underlying PG Unix socket.
8
- *
9
- * Singleton enforcement uses a PID lock file (`pgserve.pid`) co-located with
10
- * the control socket. A second daemon invocation refuses with the live PID;
11
- * a stale lock (process gone) is cleaned up automatically on next boot.
12
- *
13
- * Module layout (split for AGENTS.md §8 1000-line discipline):
14
- * - daemon.js (this file) — class shell, lifecycle, lock, signal handlers,
15
- * listener wiring, public exports.
16
- * - daemon-control.js — Unix accept hooks: handleSocketOpen/Data/Close/
17
- * Error, processStartupMessage, resolveTenantDatabase (Group 2 + Group 4).
18
- * - daemon-tcp.js — Optional TCP accept hooks + token verify
19
- * (Group 6).
20
- * - daemon-shared.js — flushPending helper shared by both paths.
21
- *
22
- * PR #24 invariants preserved:
23
- * - `PostgresManager.start()` re-entry guard untouched.
24
- * - `PostgresManager.stop()` nulls socketDir/databaseDir.
25
- * - On abnormal daemon exit, the next boot's stale-pid cleanup unlinks
26
- * the orphaned control socket *and* PID lock so we never leak either.
27
- */
28
-
29
- /* global Bun */
30
- import fs from 'fs';
31
- import path from 'path';
32
- import { EventEmitter } from 'events';
33
- import { PostgresManager } from './postgres.js';
34
- import { createLogger } from './logger.js';
35
- import { initFingerprintFfi } from './fingerprint.js';
36
- import { configureAudit } from './audit.js';
37
- import { ensureMetaSchema } from './control-db.js';
38
- import { createAdminClient, writeAdminDiscovery, removeAdminDiscovery } from './admin-client.js';
39
- import {
40
- isFingerprintEnforcementDisabled,
41
- KILL_SWITCH_ENV,
42
- } from './tenancy.js';
43
- import { flushPending } from './daemon-shared.js';
44
- import { attachControlHandlers } from './daemon-control.js';
45
- import { attachTcpHandlers } from './daemon-tcp.js';
46
- import { installSweepTriggers } from './gc.js';
47
-
48
- /**
49
- * Resolve the directory that holds the daemon's control socket and pid lock.
50
- * `$XDG_RUNTIME_DIR/pgserve` when XDG is set (the systemd / freedesktop
51
- * convention), otherwise `/tmp/pgserve` as the documented fallback.
52
- */
53
- export function resolveControlSocketDir() {
54
- const xdg = process.env.XDG_RUNTIME_DIR;
55
- const base = xdg && xdg.length > 0 ? xdg : '/tmp';
56
- return path.join(base, 'pgserve');
57
- }
58
-
59
- export function resolveControlSocketPath(dir = resolveControlSocketDir()) {
60
- return path.join(dir, 'control.sock');
61
- }
62
-
63
- export function resolvePidLockPath(dir = resolveControlSocketDir()) {
64
- return path.join(dir, 'pgserve.pid');
65
- }
66
-
67
- /**
68
- * libpq compat path. When users say `psql -h $XDG_RUNTIME_DIR/pgserve`,
69
- * libpq looks for `<host>/.s.PGSQL.<port>` with port defaulting to 5432.
70
- * The daemon binds `control.sock` (per wish §Group 2) and ALSO publishes
71
- * a `.s.PGSQL.<port>` symlink to it so off-the-shelf clients connect.
72
- */
73
- export function resolveLibpqCompatPath(dir = resolveControlSocketDir(), port = 5432) {
74
- return path.join(dir, `.s.PGSQL.${port}`);
75
- }
76
-
77
- /**
78
- * Return true if a process with the given pid is alive (signal 0 trick).
79
- */
80
- export function isProcessAlive(pid) {
81
- if (!Number.isInteger(pid) || pid <= 0) return false;
82
- try {
83
- process.kill(pid, 0);
84
- return true;
85
- } catch (err) {
86
- // EPERM means the process exists but we don't own it — still alive.
87
- return err.code === 'EPERM';
88
- }
89
- }
90
-
91
- /**
92
- * Acquire the singleton PID lock, taking care of stale lock cleanup.
93
- *
94
- * Returns `{ acquired: true }` on success. On an already-running peer,
95
- * returns `{ acquired: false, pid }` so the caller can render a clean
96
- * "already running, pid N" error and exit non-zero.
97
- *
98
- * Cleanup contract on failed acquisition is the caller's responsibility:
99
- * we never unlink the socket of a *live* peer.
100
- */
101
- export function acquirePidLock({ pidLockPath, socketPath, libpqCompatPath, logger }) {
102
- ensureDir(path.dirname(pidLockPath));
103
-
104
- const orphanPaths = [socketPath, libpqCompatPath].filter(Boolean);
105
-
106
- for (let attempt = 0; attempt < 2; attempt++) {
107
- try {
108
- const fd = fs.openSync(pidLockPath, 'wx', 0o600);
109
- try {
110
- fs.writeSync(fd, String(process.pid));
111
- } finally {
112
- fs.closeSync(fd);
113
- }
114
- return { acquired: true };
115
- } catch (err) {
116
- if (err.code !== 'EEXIST') throw err;
117
-
118
- // PID file exists. Read it and decide whether the owner is alive.
119
- let stalePid = null;
120
- try {
121
- const raw = fs.readFileSync(pidLockPath, 'utf8').trim();
122
- stalePid = parseInt(raw, 10);
123
- } catch {
124
- // Unreadable file is treated as stale.
125
- }
126
-
127
- if (Number.isInteger(stalePid) && isProcessAlive(stalePid)) {
128
- return { acquired: false, pid: stalePid };
129
- }
130
-
131
- // Stale lock — clean it up alongside any orphaned socket / symlink,
132
- // then retry. The next attempt either succeeds or surfaces a real
133
- // error.
134
- logger?.warn?.(
135
- { pidLockPath, stalePid },
136
- 'Found stale daemon PID lock, cleaning up before retry',
137
- );
138
- try {
139
- fs.unlinkSync(pidLockPath);
140
- } catch (e) {
141
- if (e.code !== 'ENOENT') throw e;
142
- }
143
- for (const p of orphanPaths) {
144
- try {
145
- fs.unlinkSync(p);
146
- } catch (e) {
147
- if (e.code !== 'ENOENT') throw e;
148
- }
149
- }
150
- // Loop and retry the open-exclusive.
151
- }
152
- }
153
- // If we got here both attempts failed without throwing — should not happen.
154
- throw new Error('acquirePidLock: failed after stale-lock cleanup');
155
- }
156
-
157
- function ensureDir(dir) {
158
- if (!fs.existsSync(dir)) {
159
- fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
160
- }
161
- }
162
-
163
- /**
164
- * Send a SIGTERM to the daemon owning the lock. Returns the previous pid
165
- * if a daemon was found, or `null` if no live daemon exists.
166
- *
167
- * Used by `pgserve daemon stop`.
168
- */
169
- export function stopDaemon({ controlSocketDir = resolveControlSocketDir(), timeoutMs = 5000 } = {}) {
170
- const pidLockPath = resolvePidLockPath(controlSocketDir);
171
- let pid = null;
172
- try {
173
- const raw = fs.readFileSync(pidLockPath, 'utf8').trim();
174
- pid = parseInt(raw, 10);
175
- } catch {
176
- return { stopped: false, reason: 'no-pid-file' };
177
- }
178
-
179
- if (!Number.isInteger(pid) || pid <= 0) {
180
- try { fs.unlinkSync(pidLockPath); } catch { /* swallow */ }
181
- return { stopped: false, reason: 'invalid-pid-file' };
182
- }
183
-
184
- if (!isProcessAlive(pid)) {
185
- try { fs.unlinkSync(pidLockPath); } catch { /* swallow */ }
186
- try { fs.unlinkSync(resolveControlSocketPath(controlSocketDir)); } catch { /* swallow */ }
187
- try { fs.unlinkSync(resolveLibpqCompatPath(controlSocketDir)); } catch { /* swallow */ }
188
- return { stopped: false, reason: 'stale-pid', pid };
189
- }
190
-
191
- try {
192
- process.kill(pid, 'SIGTERM');
193
- } catch (err) {
194
- return { stopped: false, reason: 'signal-failed', pid, error: err.message };
195
- }
196
-
197
- // Wait for the daemon to remove its pid file.
198
- const deadline = Date.now() + timeoutMs;
199
- while (Date.now() < deadline) {
200
- if (!fs.existsSync(pidLockPath)) {
201
- return { stopped: true, pid };
202
- }
203
- Bun.sleepSync ? Bun.sleepSync(50) : sleepBlocking(50);
204
- }
205
- return { stopped: false, reason: 'timeout', pid };
206
- }
207
-
208
- function sleepBlocking(ms) {
209
- // Tiny blocking sleep used only by the CLI stop path. We avoid pulling in
210
- // an async dep here; ten 50ms ticks across the 5s timeout is fine.
211
- const end = Date.now() + ms;
212
- while (Date.now() < end) { /* spin */ }
213
- }
214
-
215
- /**
216
- * The daemon. Owns one PostgresManager and one Bun.listen({unix}) server.
217
- * Accept-path methods (handleSocketOpen, handleTcpOpen, …) live in the
218
- * daemon-control.js / daemon-tcp.js modules and are mixed into the
219
- * prototype below.
220
- */
221
- export class PgserveDaemon extends EventEmitter {
222
- constructor(options = {}) {
223
- super();
224
- this.controlSocketDir = options.controlSocketDir || resolveControlSocketDir();
225
- this.controlSocketPath = options.controlSocketPath || resolveControlSocketPath(this.controlSocketDir);
226
- this.pidLockPath = options.pidLockPath || resolvePidLockPath(this.controlSocketDir);
227
- this.libpqPort = options.libpqPort || 5432;
228
- this.libpqCompatPath = options.libpqCompatPath || resolveLibpqCompatPath(this.controlSocketDir, this.libpqPort);
229
- this.maxConnections = options.maxConnections || 1000;
230
- this.autoProvision = options.autoProvision !== false;
231
- this.baseDir = options.baseDir || null;
232
- this.useRam = options.useRam || false;
233
- this.auditLogFile = options.auditLogFile || null;
234
- this.auditTarget = options.auditTarget || null;
235
- // Group 6: opt-in TCP binds. Each entry is `{host, port}`. Empty array
236
- // (the default) means "Unix socket only" — no TCP port is bound.
237
- this.tcpListens = normalizeTcpListens(options.tcpListens);
238
- // Group 4: fingerprint enforcement is on by default; the kill-switch env
239
- // var (`PGSERVE_DISABLE_FINGERPRINT_ENFORCEMENT=1`) flips it off and is
240
- // surfaced as a deprecation warning at start(). Tests pass an explicit
241
- // boolean override.
242
- this.enforcementDisabled = options.enforcementDisabled !== undefined
243
- ? !!options.enforcementDisabled
244
- : isFingerprintEnforcementDisabled();
245
- // Group 4 test seam: per-accept overrides for fingerprint derivation.
246
- // Production omits this and the daemon walks `/proc/$pid/cwd` for real.
247
- this._fingerprintAcceptOpts = typeof options._fingerprintAcceptOpts === 'function'
248
- ? options._fingerprintAcceptOpts
249
- : null;
250
- this.logger = options.logger || createLogger({ level: options.logLevel || 'info' });
251
-
252
- this.pgManager = options.pgManager || new PostgresManager({
253
- dataDir: this.baseDir,
254
- port: options.pgPort ?? 0,
255
- logger: this.logger.child ? this.logger.child({ component: 'postgres' }) : this.logger,
256
- useRam: this.useRam,
257
- enablePgvector: options.enablePgvector || false,
258
- });
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
-
272
- this.server = null;
273
- this.tcpServers = [];
274
- this.connections = new Set();
275
- this.socketState = new WeakMap();
276
- this._lockAcquired = false;
277
- this._signalHandlersInstalled = false;
278
- this._stopping = false;
279
- // Lazy-initialised admin DB client (Group 6 token validation).
280
- this._adminClient = null;
281
- this.adminIdleTimeout = options.adminIdleTimeout ?? 300;
282
- this.adminQueryTimeoutMs = options.adminQueryTimeoutMs ?? 0;
283
- this.adminLookupTimeoutMs = options.adminLookupTimeoutMs ?? 5000;
284
- // Group 5: GC sweep handle ({stop, sweep}). Installed once the admin
285
- // client is up and torn down on stop().
286
- this._gcHandle = null;
287
- // Group 5 test seam — opt out of the boot sweep / hourly timer when
288
- // tests want to drive sweeps manually. Default: enabled.
289
- this.gcEnabled = options.gcEnabled !== false;
290
- this.gcOptions = options.gcOptions || {};
291
-
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;
339
- }
340
-
341
- /**
342
- * Start the daemon: acquire singleton lock, boot PG, bind control socket.
343
- *
344
- * Throws `DaemonAlreadyRunningError` (a tagged Error) when another live
345
- * pgserve daemon already owns the lock, so the CLI can render the
346
- * "already running, pid N" message and `exit(1)` cleanly.
347
- */
348
- async start() {
349
- if (this.server) {
350
- this.logger.warn?.({ pid: process.pid }, 'PgserveDaemon.start called while already running');
351
- return this;
352
- }
353
-
354
- ensureDir(this.controlSocketDir);
355
-
356
- // Group 4: surface the kill switch loudly at boot. The audit log records
357
- // every bypassed connection later, but operators should see this in
358
- // the daemon's own stderr the moment the process starts.
359
- if (this.enforcementDisabled) {
360
- const msg =
361
- `[pgserve] WARNING: ${KILL_SWITCH_ENV}=1 is set — fingerprint ` +
362
- `enforcement is DISABLED. Cross-tenant connections will be ` +
363
- `permitted. This kill switch is deprecated and will be removed ` +
364
- `in pgserve v3.`;
365
- try { process.stderr.write(`${msg}\n`); } catch { /* swallow */ }
366
- this.logger.warn?.({ env: KILL_SWITCH_ENV }, 'Fingerprint enforcement disabled — deprecated kill switch in use');
367
- }
368
-
369
- const lock = acquirePidLock({
370
- pidLockPath: this.pidLockPath,
371
- socketPath: this.controlSocketPath,
372
- libpqCompatPath: this.libpqCompatPath,
373
- logger: this.logger,
374
- });
375
- if (!lock.acquired) {
376
- const err = new Error(`pgserve daemon already running, pid ${lock.pid}`);
377
- err.code = 'EALREADYRUNNING';
378
- err.pid = lock.pid;
379
- throw err;
380
- }
381
- this._lockAcquired = true;
382
-
383
- // Best-effort: tighten directory perms in case the dir pre-existed
384
- // from a previous user (e.g. /tmp/pgserve world-writable parent).
385
- try { fs.chmodSync(this.controlSocketDir, 0o700); } catch { /* swallow */ }
386
-
387
- // Wire up audit-log destination + fingerprint FFI before any accept
388
- // can fire, so handleSocketOpen always sees a primed environment.
389
- if (this.auditLogFile || this.auditTarget) {
390
- configureAudit({
391
- ...(this.auditLogFile ? { logFile: this.auditLogFile } : {}),
392
- ...(this.auditTarget ? { target: this.auditTarget } : {}),
393
- });
394
- }
395
- try {
396
- await initFingerprintFfi();
397
- } catch (err) {
398
- this.releaseLock();
399
- throw err;
400
- }
401
-
402
- this.installSignalHandlers();
403
-
404
- try {
405
- await this.pgManager.start();
406
- } catch (err) {
407
- // Release the lock before propagating — otherwise the operator has to
408
- // manually unlink a pid file that points at a dead process.
409
- this.releaseLock();
410
- throw err;
411
- }
412
-
413
- // Bind the control socket. Bun's listener writes to the path; we already
414
- // unlinked any stale socket in acquirePidLock (or no socket existed).
415
- const daemon = this;
416
- try {
417
- this.server = Bun.listen({
418
- unix: this.controlSocketPath,
419
- socket: {
420
- data(socket, data) {
421
- daemon.handleSocketData(socket, data);
422
- },
423
- open(socket) {
424
- daemon.handleSocketOpen(socket);
425
- },
426
- close(socket) {
427
- daemon.handleSocketClose(socket);
428
- },
429
- error(socket, error) {
430
- daemon.handleSocketError(socket, error);
431
- },
432
- drain(socket) {
433
- const state = daemon.socketState.get(socket);
434
- if (!state) return;
435
- if (state.pendingToClient) {
436
- state.pendingToClient = flushPending(socket, state.pendingToClient);
437
- }
438
- if (!state.pendingToClient && state.pgSocket) {
439
- state.pgSocket.resume();
440
- }
441
- },
442
- },
443
- });
444
- } catch (err) {
445
- try { await this.pgManager.stop(); } catch { /* swallow */ }
446
- this.releaseLock();
447
- throw err;
448
- }
449
-
450
- // Restrict the socket to the owning user (some kernels honour mode
451
- // bits on AF_UNIX sockets, which makes our daemon refuse to even
452
- // accept from other UIDs without further auth).
453
- try { fs.chmodSync(this.controlSocketPath, 0o600); } catch { /* swallow */ }
454
-
455
- // Publish a libpq-compatible symlink so off-the-shelf clients can use
456
- // `psql -h <dir>` without knowing the `control.sock` name. Replace any
457
- // stale symlink left by a previous abnormal exit.
458
- try { fs.unlinkSync(this.libpqCompatPath); } catch (e) {
459
- if (e.code !== 'ENOENT') {
460
- this.logger.warn?.({ err: e.message }, 'Failed to unlink stale libpq compat symlink');
461
- }
462
- }
463
- try {
464
- fs.symlinkSync(path.basename(this.controlSocketPath), this.libpqCompatPath);
465
- } catch (e) {
466
- this.logger.warn?.({ err: e.message }, 'Failed to publish libpq compat symlink');
467
- }
468
-
469
- // Group 6: open the admin DB client + provision the meta schema before
470
- // we accept any connection that might rely on it (TCP token verify).
471
- try {
472
- this._adminClient = await createAdminClient({
473
- socketDir: this.pgManager.socketDir,
474
- port: this.pgManager.port,
475
- idleTimeout: this.adminIdleTimeout,
476
- queryTimeoutMs: this.adminQueryTimeoutMs,
477
- });
478
- await ensureMetaSchema(this._adminClient);
479
- writeAdminDiscovery({
480
- controlSocketDir: this.controlSocketDir,
481
- socketDir: this.pgManager.socketDir,
482
- port: this.pgManager.port,
483
- });
484
- } catch (err) {
485
- this.logger.warn?.(
486
- { err: err?.message || String(err) },
487
- 'admin DB init failed — TCP listen will refuse connections',
488
- );
489
- }
490
-
491
- // Group 6: bind any opt-in TCP listeners. Errors here are fatal — if the
492
- // operator asked for TCP they want to know it failed (port collision,
493
- // EACCES) rather than silently fall back to Unix-only.
494
- for (const listen of this.tcpListens) {
495
- const tcp = await this.bindTcpListener(listen);
496
- this.tcpServers.push(tcp);
497
- }
498
-
499
- // Group 5: install GC sweep triggers (boot + hourly + on-connect sample)
500
- // once the admin client is provisioned. Disabled when gcEnabled=false
501
- // (tests that drive sweeps manually) or when no admin client exists.
502
- if (this.gcEnabled && this._adminClient) {
503
- try {
504
- this._gcHandle = installSweepTriggers(this, {
505
- adminClient: this._adminClient,
506
- ...this.gcOptions,
507
- });
508
- } catch (err) {
509
- this.logger.warn?.(
510
- { err: err?.message || String(err) },
511
- 'GC sweep install failed — orphan reaping disabled',
512
- );
513
- }
514
- }
515
-
516
- this.logger.info?.({
517
- pid: process.pid,
518
- controlSocketPath: this.controlSocketPath,
519
- pidLockPath: this.pidLockPath,
520
- pgPort: this.pgManager.port,
521
- tcpListens: this.tcpListens,
522
- handshakeDeadlineMs: this.handshakeDeadlineMs,
523
- }, 'pgserve daemon listening');
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
-
536
- this.emit('listening');
537
- return this;
538
- }
539
-
540
- /**
541
- * Graceful shutdown: drain connections, stop PG, release lock + socket.
542
- */
543
- async stop() {
544
- if (this._stopping) return;
545
- this._stopping = true;
546
-
547
- this.logger.info?.('Stopping pgserve daemon');
548
-
549
- if (this._handshakeWatchdogTimer) {
550
- clearInterval(this._handshakeWatchdogTimer);
551
- this._handshakeWatchdogTimer = null;
552
- }
553
-
554
- for (const socket of this.connections) {
555
- try { socket.end(); } catch { /* swallow */ }
556
- }
557
- this.connections.clear();
558
-
559
- if (this.server) {
560
- try { this.server.stop(); } catch { /* swallow */ }
561
- this.server = null;
562
- }
563
-
564
- // Group 6: tear down opt-in TCP listeners.
565
- for (const tcp of this.tcpServers) {
566
- try { tcp.stop(); } catch { /* swallow */ }
567
- }
568
- this.tcpServers = [];
569
-
570
- // Group 5: detach GC triggers before the admin client closes so an
571
- // in-flight sweep doesn't try to query a closed connection.
572
- if (this._gcHandle) {
573
- try { await this._gcHandle.stop(); } catch { /* swallow */ }
574
- this._gcHandle = null;
575
- }
576
-
577
- if (this._adminClient) {
578
- try { await this._adminClient.end(); } catch { /* swallow */ }
579
- this._adminClient = null;
580
- }
581
- try {
582
- removeAdminDiscovery(this.controlSocketDir);
583
- } catch (e) {
584
- if (e.code !== 'ENOENT') {
585
- this.logger.warn?.({ err: e.message }, 'Failed to remove admin discovery file');
586
- }
587
- }
588
-
589
- try {
590
- await this.pgManager.stop();
591
- } catch (err) {
592
- this.logger.warn?.({ err: err.message }, 'PostgresManager.stop failed during daemon shutdown');
593
- }
594
-
595
- try { fs.unlinkSync(this.libpqCompatPath); } catch (e) {
596
- if (e.code !== 'ENOENT') {
597
- this.logger.warn?.({ err: e.message }, 'Failed to unlink libpq compat symlink');
598
- }
599
- }
600
-
601
- try { fs.unlinkSync(this.controlSocketPath); } catch (e) {
602
- if (e.code !== 'ENOENT') {
603
- this.logger.warn?.({ err: e.message }, 'Failed to unlink control socket');
604
- }
605
- }
606
-
607
- this.releaseLock();
608
- this._stopping = false;
609
- this.emit('stopped');
610
- }
611
-
612
- releaseLock() {
613
- if (!this._lockAcquired) return;
614
- try {
615
- // Only remove the lock if it still belongs to us. Defends against
616
- // a fast restart loop where another daemon raced in.
617
- const raw = fs.readFileSync(this.pidLockPath, 'utf8').trim();
618
- const owner = parseInt(raw, 10);
619
- if (Number.isInteger(owner) && owner === process.pid) {
620
- fs.unlinkSync(this.pidLockPath);
621
- }
622
- } catch (e) {
623
- if (e.code !== 'ENOENT') {
624
- this.logger.warn?.({ err: e.message }, 'Failed to release daemon pid lock');
625
- }
626
- }
627
- this._lockAcquired = false;
628
- }
629
-
630
- installSignalHandlers() {
631
- if (this._signalHandlersInstalled) return;
632
- this._signalHandlersInstalled = true;
633
- const onSignal = async (sig) => {
634
- this.logger.info?.({ sig }, 'Received signal, draining daemon');
635
- try { await this.stop(); } catch { /* swallow */ }
636
- // Re-raise so the OS reports the right exit status. Use the default
637
- // disposition rather than process.exit(0): operators expect a
638
- // SIGTERM-killed daemon to exit with the corresponding code.
639
- process.exit(0);
640
- };
641
- process.on('SIGTERM', onSignal);
642
- process.on('SIGINT', onSignal);
643
- process.on('SIGHUP', onSignal);
644
- }
645
-
646
- getStats() {
647
- return {
648
- controlSocketPath: this.controlSocketPath,
649
- pidLockPath: this.pidLockPath,
650
- activeConnections: this.connections.size,
651
- pgPort: this.pgManager.port,
652
- postgres: this.pgManager.getStats(),
653
- };
654
- }
655
- }
656
-
657
- // Mix the accept-path handlers (Unix + TCP) into the prototype. Done at
658
- // module load so `new PgserveDaemon()` always has them — same observable
659
- // surface as the pre-split file.
660
- attachControlHandlers(PgserveDaemon);
661
- attachTcpHandlers(PgserveDaemon);
662
-
663
- /**
664
- * Normalise the `--listen` form. Accepts:
665
- * - omitted / null / [] → no TCP listeners
666
- * - "5432" → bind 0.0.0.0:5432
667
- * - ":5432" → bind 0.0.0.0:5432
668
- * - "127.0.0.1:5432" → bind localhost only
669
- * - array of any of the above
670
- *
671
- * Returns an array of `{host, port}` objects. Throws on garbage input.
672
- */
673
- export function normalizeTcpListens(listens) {
674
- if (listens === undefined || listens === null) return [];
675
- const arr = Array.isArray(listens) ? listens : [listens];
676
- return arr.filter(Boolean).map(parseSingleListen);
677
- }
678
-
679
- function parseSingleListen(spec) {
680
- if (typeof spec === 'object' && typeof spec.port === 'number') {
681
- return { host: spec.host || '0.0.0.0', port: spec.port };
682
- }
683
- if (typeof spec !== 'string') {
684
- throw new Error(`pgserve daemon --listen: bad spec ${JSON.stringify(spec)}`);
685
- }
686
- let s = spec.trim();
687
- if (s.startsWith(':')) s = s.slice(1);
688
- let host = '0.0.0.0';
689
- let portText = s;
690
- const lastColon = s.lastIndexOf(':');
691
- if (lastColon !== -1) {
692
- host = s.slice(0, lastColon);
693
- portText = s.slice(lastColon + 1);
694
- }
695
- const port = parseInt(portText, 10);
696
- if (!Number.isInteger(port) || port < 1 || port > 65535) {
697
- throw new Error(`pgserve daemon --listen: invalid port "${spec}"`);
698
- }
699
- return { host: host || '0.0.0.0', port };
700
- }
701
-
702
- /**
703
- * Convenience entry — used by the CLI subcommand.
704
- */
705
- export async function startDaemon(options = {}) {
706
- const daemon = new PgserveDaemon(options);
707
- await daemon.start();
708
- return daemon;
709
- }