pgserve 1.1.10 → 2.0.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 (43) hide show
  1. package/.genie/brainstorms/pgserve-v2/DESIGN.md +174 -0
  2. package/.genie/wishes/pgserve-v2/BRIEF-from-genie-pgserve.md +99 -0
  3. package/.genie/wishes/pgserve-v2/WISH.md +442 -0
  4. package/.genie/wishes/release-system-genie-pattern/WISH.md +268 -0
  5. package/.genie/wishes/release-system-genie-pattern/validation.md +205 -0
  6. package/.github/workflows/ci.yml +8 -4
  7. package/.github/workflows/release.yml +233 -111
  8. package/.github/workflows/{build-all-platforms.yml → version.yml} +32 -8
  9. package/AGENTS.md +10 -8
  10. package/CHANGELOG.md +150 -0
  11. package/Makefile +18 -41
  12. package/README.md +186 -1
  13. package/SECURITY.md +109 -0
  14. package/bin/pglite-server.js +253 -1
  15. package/eslint.config.js +2 -0
  16. package/package.json +1 -1
  17. package/src/admin-client.js +171 -0
  18. package/src/audit.js +168 -0
  19. package/src/control-db.js +313 -0
  20. package/src/daemon-control.js +408 -0
  21. package/src/daemon-shared.js +18 -0
  22. package/src/daemon-tcp.js +296 -0
  23. package/src/daemon.js +629 -0
  24. package/src/fingerprint.js +453 -0
  25. package/src/gc.js +351 -0
  26. package/src/index.js +11 -0
  27. package/src/postgres.js +54 -0
  28. package/src/protocol.js +131 -0
  29. package/src/router.js +78 -5
  30. package/src/tenancy.js +75 -0
  31. package/src/tokens.js +102 -0
  32. package/tests/audit.test.js +189 -0
  33. package/tests/control-db.test.js +285 -0
  34. package/tests/daemon-fingerprint-integration.test.js +109 -0
  35. package/tests/daemon-pr24-regression.test.js +201 -0
  36. package/tests/fingerprint.test.js +249 -0
  37. package/tests/fixtures/240-orphan-seed.sql +30 -0
  38. package/tests/multi-tenant.test.js +164 -0
  39. package/tests/orphan-cleanup.test.js +390 -0
  40. package/tests/tcp-listen.test.js +368 -0
  41. package/tests/tenancy.test.js +403 -0
  42. package/.github/release.yml +0 -30
  43. package/scripts/release.cjs +0 -198
package/src/daemon.js ADDED
@@ -0,0 +1,629 @@
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 || 5433,
255
+ logger: this.logger.child ? this.logger.child({ component: 'postgres' }) : this.logger,
256
+ useRam: this.useRam,
257
+ enablePgvector: options.enablePgvector || false,
258
+ });
259
+
260
+ this.server = null;
261
+ this.tcpServers = [];
262
+ this.connections = new Set();
263
+ this.socketState = new WeakMap();
264
+ this._lockAcquired = false;
265
+ this._signalHandlersInstalled = false;
266
+ this._stopping = false;
267
+ // Lazy-initialised admin DB client (Group 6 token validation).
268
+ this._adminClient = null;
269
+ // Group 5: GC sweep handle ({stop, sweep}). Installed once the admin
270
+ // client is up and torn down on stop().
271
+ this._gcHandle = null;
272
+ // Group 5 test seam — opt out of the boot sweep / hourly timer when
273
+ // tests want to drive sweeps manually. Default: enabled.
274
+ this.gcEnabled = options.gcEnabled !== false;
275
+ this.gcOptions = options.gcOptions || {};
276
+
277
+ this.setMaxListeners(this.maxConnections + 10);
278
+ }
279
+
280
+ /**
281
+ * Start the daemon: acquire singleton lock, boot PG, bind control socket.
282
+ *
283
+ * Throws `DaemonAlreadyRunningError` (a tagged Error) when another live
284
+ * pgserve daemon already owns the lock, so the CLI can render the
285
+ * "already running, pid N" message and `exit(1)` cleanly.
286
+ */
287
+ async start() {
288
+ if (this.server) {
289
+ this.logger.warn?.({ pid: process.pid }, 'PgserveDaemon.start called while already running');
290
+ return this;
291
+ }
292
+
293
+ ensureDir(this.controlSocketDir);
294
+
295
+ // Group 4: surface the kill switch loudly at boot. The audit log records
296
+ // every bypassed connection later, but operators should see this in
297
+ // the daemon's own stderr the moment the process starts.
298
+ if (this.enforcementDisabled) {
299
+ const msg =
300
+ `[pgserve] WARNING: ${KILL_SWITCH_ENV}=1 is set — fingerprint ` +
301
+ `enforcement is DISABLED. Cross-tenant connections will be ` +
302
+ `permitted. This kill switch is deprecated and will be removed ` +
303
+ `in pgserve v3.`;
304
+ try { process.stderr.write(`${msg}\n`); } catch { /* swallow */ }
305
+ this.logger.warn?.({ env: KILL_SWITCH_ENV }, 'Fingerprint enforcement disabled — deprecated kill switch in use');
306
+ }
307
+
308
+ const lock = acquirePidLock({
309
+ pidLockPath: this.pidLockPath,
310
+ socketPath: this.controlSocketPath,
311
+ libpqCompatPath: this.libpqCompatPath,
312
+ logger: this.logger,
313
+ });
314
+ if (!lock.acquired) {
315
+ const err = new Error(`pgserve daemon already running, pid ${lock.pid}`);
316
+ err.code = 'EALREADYRUNNING';
317
+ err.pid = lock.pid;
318
+ throw err;
319
+ }
320
+ this._lockAcquired = true;
321
+
322
+ // Best-effort: tighten directory perms in case the dir pre-existed
323
+ // from a previous user (e.g. /tmp/pgserve world-writable parent).
324
+ try { fs.chmodSync(this.controlSocketDir, 0o700); } catch { /* swallow */ }
325
+
326
+ // Wire up audit-log destination + fingerprint FFI before any accept
327
+ // can fire, so handleSocketOpen always sees a primed environment.
328
+ if (this.auditLogFile || this.auditTarget) {
329
+ configureAudit({
330
+ ...(this.auditLogFile ? { logFile: this.auditLogFile } : {}),
331
+ ...(this.auditTarget ? { target: this.auditTarget } : {}),
332
+ });
333
+ }
334
+ try {
335
+ await initFingerprintFfi();
336
+ } catch (err) {
337
+ this.releaseLock();
338
+ throw err;
339
+ }
340
+
341
+ this.installSignalHandlers();
342
+
343
+ try {
344
+ await this.pgManager.start();
345
+ } catch (err) {
346
+ // Release the lock before propagating — otherwise the operator has to
347
+ // manually unlink a pid file that points at a dead process.
348
+ this.releaseLock();
349
+ throw err;
350
+ }
351
+
352
+ // Bind the control socket. Bun's listener writes to the path; we already
353
+ // unlinked any stale socket in acquirePidLock (or no socket existed).
354
+ const daemon = this;
355
+ try {
356
+ this.server = Bun.listen({
357
+ unix: this.controlSocketPath,
358
+ socket: {
359
+ data(socket, data) {
360
+ daemon.handleSocketData(socket, data);
361
+ },
362
+ open(socket) {
363
+ daemon.handleSocketOpen(socket);
364
+ },
365
+ close(socket) {
366
+ daemon.handleSocketClose(socket);
367
+ },
368
+ error(socket, error) {
369
+ daemon.handleSocketError(socket, error);
370
+ },
371
+ drain(socket) {
372
+ const state = daemon.socketState.get(socket);
373
+ if (!state) return;
374
+ if (state.pendingToClient) {
375
+ state.pendingToClient = flushPending(socket, state.pendingToClient);
376
+ }
377
+ if (!state.pendingToClient && state.pgSocket) {
378
+ state.pgSocket.resume();
379
+ }
380
+ },
381
+ },
382
+ });
383
+ } catch (err) {
384
+ try { await this.pgManager.stop(); } catch { /* swallow */ }
385
+ this.releaseLock();
386
+ throw err;
387
+ }
388
+
389
+ // Restrict the socket to the owning user (some kernels honour mode
390
+ // bits on AF_UNIX sockets, which makes our daemon refuse to even
391
+ // accept from other UIDs without further auth).
392
+ try { fs.chmodSync(this.controlSocketPath, 0o600); } catch { /* swallow */ }
393
+
394
+ // Publish a libpq-compatible symlink so off-the-shelf clients can use
395
+ // `psql -h <dir>` without knowing the `control.sock` name. Replace any
396
+ // stale symlink left by a previous abnormal exit.
397
+ try { fs.unlinkSync(this.libpqCompatPath); } catch (e) {
398
+ if (e.code !== 'ENOENT') {
399
+ this.logger.warn?.({ err: e.message }, 'Failed to unlink stale libpq compat symlink');
400
+ }
401
+ }
402
+ try {
403
+ fs.symlinkSync(path.basename(this.controlSocketPath), this.libpqCompatPath);
404
+ } catch (e) {
405
+ this.logger.warn?.({ err: e.message }, 'Failed to publish libpq compat symlink');
406
+ }
407
+
408
+ // Group 6: open the admin DB client + provision the meta schema before
409
+ // we accept any connection that might rely on it (TCP token verify).
410
+ try {
411
+ this._adminClient = await createAdminClient({
412
+ socketDir: this.pgManager.socketDir,
413
+ port: this.pgManager.port,
414
+ });
415
+ await ensureMetaSchema(this._adminClient);
416
+ writeAdminDiscovery({
417
+ controlSocketDir: this.controlSocketDir,
418
+ socketDir: this.pgManager.socketDir,
419
+ port: this.pgManager.port,
420
+ });
421
+ } catch (err) {
422
+ this.logger.warn?.(
423
+ { err: err?.message || String(err) },
424
+ 'admin DB init failed — TCP listen will refuse connections',
425
+ );
426
+ }
427
+
428
+ // Group 6: bind any opt-in TCP listeners. Errors here are fatal — if the
429
+ // operator asked for TCP they want to know it failed (port collision,
430
+ // EACCES) rather than silently fall back to Unix-only.
431
+ for (const listen of this.tcpListens) {
432
+ const tcp = await this.bindTcpListener(listen);
433
+ this.tcpServers.push(tcp);
434
+ }
435
+
436
+ // Group 5: install GC sweep triggers (boot + hourly + on-connect sample)
437
+ // once the admin client is provisioned. Disabled when gcEnabled=false
438
+ // (tests that drive sweeps manually) or when no admin client exists.
439
+ if (this.gcEnabled && this._adminClient) {
440
+ try {
441
+ this._gcHandle = installSweepTriggers(this, {
442
+ adminClient: this._adminClient,
443
+ ...this.gcOptions,
444
+ });
445
+ } catch (err) {
446
+ this.logger.warn?.(
447
+ { err: err?.message || String(err) },
448
+ 'GC sweep install failed — orphan reaping disabled',
449
+ );
450
+ }
451
+ }
452
+
453
+ this.logger.info?.({
454
+ pid: process.pid,
455
+ controlSocketPath: this.controlSocketPath,
456
+ pidLockPath: this.pidLockPath,
457
+ pgPort: this.pgManager.port,
458
+ tcpListens: this.tcpListens,
459
+ }, 'pgserve daemon listening');
460
+
461
+ this.emit('listening');
462
+ return this;
463
+ }
464
+
465
+ /**
466
+ * Graceful shutdown: drain connections, stop PG, release lock + socket.
467
+ */
468
+ async stop() {
469
+ if (this._stopping) return;
470
+ this._stopping = true;
471
+
472
+ this.logger.info?.('Stopping pgserve daemon');
473
+
474
+ for (const socket of this.connections) {
475
+ try { socket.end(); } catch { /* swallow */ }
476
+ }
477
+ this.connections.clear();
478
+
479
+ if (this.server) {
480
+ try { this.server.stop(); } catch { /* swallow */ }
481
+ this.server = null;
482
+ }
483
+
484
+ // Group 6: tear down opt-in TCP listeners.
485
+ for (const tcp of this.tcpServers) {
486
+ try { tcp.stop(); } catch { /* swallow */ }
487
+ }
488
+ this.tcpServers = [];
489
+
490
+ // Group 5: detach GC triggers before the admin client closes so an
491
+ // in-flight sweep doesn't try to query a closed connection.
492
+ if (this._gcHandle) {
493
+ try { await this._gcHandle.stop(); } catch { /* swallow */ }
494
+ this._gcHandle = null;
495
+ }
496
+
497
+ if (this._adminClient) {
498
+ try { await this._adminClient.end(); } catch { /* swallow */ }
499
+ this._adminClient = null;
500
+ }
501
+ try {
502
+ removeAdminDiscovery(this.controlSocketDir);
503
+ } catch (e) {
504
+ if (e.code !== 'ENOENT') {
505
+ this.logger.warn?.({ err: e.message }, 'Failed to remove admin discovery file');
506
+ }
507
+ }
508
+
509
+ try {
510
+ await this.pgManager.stop();
511
+ } catch (err) {
512
+ this.logger.warn?.({ err: err.message }, 'PostgresManager.stop failed during daemon shutdown');
513
+ }
514
+
515
+ try { fs.unlinkSync(this.libpqCompatPath); } catch (e) {
516
+ if (e.code !== 'ENOENT') {
517
+ this.logger.warn?.({ err: e.message }, 'Failed to unlink libpq compat symlink');
518
+ }
519
+ }
520
+
521
+ try { fs.unlinkSync(this.controlSocketPath); } catch (e) {
522
+ if (e.code !== 'ENOENT') {
523
+ this.logger.warn?.({ err: e.message }, 'Failed to unlink control socket');
524
+ }
525
+ }
526
+
527
+ this.releaseLock();
528
+ this._stopping = false;
529
+ this.emit('stopped');
530
+ }
531
+
532
+ releaseLock() {
533
+ if (!this._lockAcquired) return;
534
+ try {
535
+ // Only remove the lock if it still belongs to us. Defends against
536
+ // a fast restart loop where another daemon raced in.
537
+ const raw = fs.readFileSync(this.pidLockPath, 'utf8').trim();
538
+ const owner = parseInt(raw, 10);
539
+ if (Number.isInteger(owner) && owner === process.pid) {
540
+ fs.unlinkSync(this.pidLockPath);
541
+ }
542
+ } catch (e) {
543
+ if (e.code !== 'ENOENT') {
544
+ this.logger.warn?.({ err: e.message }, 'Failed to release daemon pid lock');
545
+ }
546
+ }
547
+ this._lockAcquired = false;
548
+ }
549
+
550
+ installSignalHandlers() {
551
+ if (this._signalHandlersInstalled) return;
552
+ this._signalHandlersInstalled = true;
553
+ const onSignal = async (sig) => {
554
+ this.logger.info?.({ sig }, 'Received signal, draining daemon');
555
+ try { await this.stop(); } catch { /* swallow */ }
556
+ // Re-raise so the OS reports the right exit status. Use the default
557
+ // disposition rather than process.exit(0): operators expect a
558
+ // SIGTERM-killed daemon to exit with the corresponding code.
559
+ process.exit(0);
560
+ };
561
+ process.on('SIGTERM', onSignal);
562
+ process.on('SIGINT', onSignal);
563
+ process.on('SIGHUP', onSignal);
564
+ }
565
+
566
+ getStats() {
567
+ return {
568
+ controlSocketPath: this.controlSocketPath,
569
+ pidLockPath: this.pidLockPath,
570
+ activeConnections: this.connections.size,
571
+ pgPort: this.pgManager.port,
572
+ postgres: this.pgManager.getStats(),
573
+ };
574
+ }
575
+ }
576
+
577
+ // Mix the accept-path handlers (Unix + TCP) into the prototype. Done at
578
+ // module load so `new PgserveDaemon()` always has them — same observable
579
+ // surface as the pre-split file.
580
+ attachControlHandlers(PgserveDaemon);
581
+ attachTcpHandlers(PgserveDaemon);
582
+
583
+ /**
584
+ * Normalise the `--listen` form. Accepts:
585
+ * - omitted / null / [] → no TCP listeners
586
+ * - "5432" → bind 0.0.0.0:5432
587
+ * - ":5432" → bind 0.0.0.0:5432
588
+ * - "127.0.0.1:5432" → bind localhost only
589
+ * - array of any of the above
590
+ *
591
+ * Returns an array of `{host, port}` objects. Throws on garbage input.
592
+ */
593
+ export function normalizeTcpListens(listens) {
594
+ if (listens === undefined || listens === null) return [];
595
+ const arr = Array.isArray(listens) ? listens : [listens];
596
+ return arr.filter(Boolean).map(parseSingleListen);
597
+ }
598
+
599
+ function parseSingleListen(spec) {
600
+ if (typeof spec === 'object' && typeof spec.port === 'number') {
601
+ return { host: spec.host || '0.0.0.0', port: spec.port };
602
+ }
603
+ if (typeof spec !== 'string') {
604
+ throw new Error(`pgserve daemon --listen: bad spec ${JSON.stringify(spec)}`);
605
+ }
606
+ let s = spec.trim();
607
+ if (s.startsWith(':')) s = s.slice(1);
608
+ let host = '0.0.0.0';
609
+ let portText = s;
610
+ const lastColon = s.lastIndexOf(':');
611
+ if (lastColon !== -1) {
612
+ host = s.slice(0, lastColon);
613
+ portText = s.slice(lastColon + 1);
614
+ }
615
+ const port = parseInt(portText, 10);
616
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
617
+ throw new Error(`pgserve daemon --listen: invalid port "${spec}"`);
618
+ }
619
+ return { host: host || '0.0.0.0', port };
620
+ }
621
+
622
+ /**
623
+ * Convenience entry — used by the CLI subcommand.
624
+ */
625
+ export async function startDaemon(options = {}) {
626
+ const daemon = new PgserveDaemon(options);
627
+ await daemon.start();
628
+ return daemon;
629
+ }