pgserve 2.0.1 → 2.0.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pgserve",
3
- "version": "2.0.1",
3
+ "version": "2.0.3",
4
4
  "description": "Embedded PostgreSQL server with true concurrent connections - zero config, auto-provision databases",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -36,7 +36,9 @@ import path from 'path';
36
36
  * @param {string} [args.user='postgres']
37
37
  * @param {string} [args.password='postgres']
38
38
  * @param {number} [args.max=2]
39
- * @returns {Promise<{query: (text: string, params?: any[]) => Promise<{rows: any[], rowCount: number}>, end: () => Promise<void>, sql: any}>}
39
+ * @param {number} [args.idleTimeout=300]
40
+ * @param {number} [args.queryTimeoutMs=0]
41
+ * @returns {Promise<{supportsQueryOptions: boolean, query: (text: string, params?: any[], opts?: {timeoutMs?: number}) => Promise<{rows: any[], rowCount: number}>, end: () => Promise<void>, sql: any}>}
40
42
  */
41
43
  export async function createAdminClient({
42
44
  socketDir: _socketDir = null,
@@ -46,25 +48,37 @@ export async function createAdminClient({
46
48
  user = 'postgres',
47
49
  password = 'postgres',
48
50
  max = 2,
51
+ idleTimeout = 300,
52
+ queryTimeoutMs = 0,
49
53
  } = {}) {
50
54
  if (typeof port !== 'number') throw new Error('createAdminClient: port required');
51
- const sql = new SQL({
55
+ const options = {
52
56
  hostname: host,
53
57
  port,
54
58
  database,
55
59
  username: user,
56
60
  password,
57
61
  max,
58
- // TODO #38: investigate GC perf for 240-orphan sweep on shared CI runners;
59
- // bumped 10s→30s during Felipe deadline 2026-04-29 to unblock pgserve v2.0 ship.
60
- idleTimeout: 30,
61
- });
62
+ idleTimeout,
63
+ };
64
+ let sql = new SQL(options);
62
65
  // Light probe so a misconfigured daemon fails loudly here rather than at
63
66
  // first query.
64
67
  await sql`SELECT 1`;
68
+
69
+ async function reopen() {
70
+ const closing = sql;
71
+ sql = new SQL(options);
72
+ void closing.close().catch(() => { /* swallow */ });
73
+ await sql`SELECT 1`;
74
+ }
75
+
65
76
  return {
66
- sql,
67
- async query(text, params = []) {
77
+ supportsQueryOptions: true,
78
+ get sql() {
79
+ return sql;
80
+ },
81
+ async query(text, params = [], opts = {}) {
68
82
  // control-db.js is written for the pg npm module's contract, which
69
83
  // requires JSON-stringified payloads bound to JSONB parameters.
70
84
  // Bun.SQL goes the other way: it stringifies JS objects when they
@@ -73,12 +87,14 @@ export async function createAdminClient({
73
87
  // it represents). Bridge the impedance mismatch here so the same
74
88
  // call sites work against either driver.
75
89
  const adapted = params.map(coerceJsonbParam);
76
- const rows = await sql.unsafe(text, adapted);
77
- // Bun returns an Array of plain objects with `count` set on it; turn
78
- // JSONB columns back into JS values so control-db.js's parseTokens
79
- // sees the array-of-objects shape it would receive from pg.
80
- const out = Array.from(rows).map(decodeJsonColumns);
81
- return { rows: out, rowCount: rows.count ?? rows.length ?? 0 };
90
+ const timeoutMs = opts.timeoutMs ?? queryTimeoutMs;
91
+ try {
92
+ return await runQueryWithTimeout(sql, text, adapted, timeoutMs);
93
+ } catch (err) {
94
+ if (!isRetriableAdminQueryError(err)) throw err;
95
+ await reopen();
96
+ return await runQueryWithTimeout(sql, text, adapted, timeoutMs);
97
+ }
82
98
  },
83
99
  async end() {
84
100
  try { await sql.close(); } catch { /* swallow */ }
@@ -86,6 +102,42 @@ export async function createAdminClient({
86
102
  };
87
103
  }
88
104
 
105
+ async function runQueryWithTimeout(sql, text, params, queryTimeoutMs) {
106
+ const query = runQuery(sql, text, params);
107
+ return withTimeout(query, queryTimeoutMs);
108
+ }
109
+
110
+ async function runQuery(sql, text, params) {
111
+ const rows = await sql.unsafe(text, params);
112
+ // Bun returns an Array of plain objects with `count` set on it; turn
113
+ // JSONB columns back into JS values so control-db.js's parseTokens
114
+ // sees the array-of-objects shape it would receive from pg.
115
+ const out = Array.from(rows).map(decodeJsonColumns);
116
+ return { rows: out, rowCount: rows.count ?? rows.length ?? 0 };
117
+ }
118
+
119
+ function withTimeout(promise, timeoutMs) {
120
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return promise;
121
+ let timer;
122
+ const timeout = new Promise((_, reject) => {
123
+ timer = setTimeout(() => {
124
+ const err = new Error(`admin query timed out after ${timeoutMs}ms`);
125
+ err.code = 'EADMINQUERYTIMEOUT';
126
+ reject(err);
127
+ }, timeoutMs);
128
+ timer.unref?.();
129
+ });
130
+ promise.catch(() => { /* handled by the race winner */ });
131
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
132
+ }
133
+
134
+ function isRetriableAdminQueryError(err) {
135
+ const code = err?.code;
136
+ if (['EADMINQUERYTIMEOUT', 'ECONNRESET', 'EPIPE', 'ETIMEDOUT', 'ConnectionClosed'].includes(code)) return true;
137
+ const message = err?.message || String(err);
138
+ return /connection (?:closed|terminated|reset)|socket closed|timeout|CONNECTION_ENDED|CONNECTION_DESTROYED/i.test(message);
139
+ }
140
+
89
141
  /**
90
142
  * Strings shaped like a JSON array or object are unwrapped so Bun.SQL's
91
143
  * automatic JSONB serialiser sees the JS value (not a quoted JSON string).
package/src/cluster.js CHANGED
@@ -256,6 +256,7 @@ class ClusterRouter extends EventEmitter {
256
256
  if (code === SSL_REQUEST_CODE || code === GSSAPI_REQUEST_CODE) {
257
257
  socket.write(Buffer.from('N'));
258
258
  state.buffer = buffer.length > messageLength ? buffer.subarray(messageLength) : null;
259
+ if (state.buffer) await this.processStartupMessage(socket, state);
259
260
  return;
260
261
  }
261
262
 
package/src/control-db.js CHANGED
@@ -35,6 +35,13 @@ const REAPABLE_QUERY = `
35
35
  ORDER BY last_connection_at ASC
36
36
  `;
37
37
 
38
+ function query(client, text, params = [], opts = {}) {
39
+ if (client.supportsQueryOptions && opts && Object.keys(opts).length > 0) {
40
+ return client.query(text, params, opts);
41
+ }
42
+ return client.query(text, params);
43
+ }
44
+
38
45
  /**
39
46
  * Create the `pgserve_meta` table if it does not already exist.
40
47
  * Safe to call repeatedly — used at daemon boot and in tests.
@@ -88,12 +95,13 @@ export async function recordDbCreated(client, {
88
95
  packageRealpath = null,
89
96
  livenessPid = null,
90
97
  persist = false,
91
- }) {
98
+ }, opts = {}) {
92
99
  if (!databaseName) throw new Error('recordDbCreated: databaseName required');
93
100
  if (!fingerprint) throw new Error('recordDbCreated: fingerprint required');
94
101
  if (typeof peerUid !== 'number') throw new Error('recordDbCreated: peerUid must be number');
95
102
 
96
- await client.query(
103
+ await query(
104
+ client,
97
105
  `
98
106
  INSERT INTO pgserve_meta
99
107
  (database_name, fingerprint, peer_uid, package_realpath, liveness_pid, persist)
@@ -107,6 +115,7 @@ export async function recordDbCreated(client, {
107
115
  last_connection_at = now()
108
116
  `,
109
117
  [databaseName, fingerprint, peerUid, packageRealpath, livenessPid, persist],
118
+ opts,
110
119
  );
111
120
  }
112
121
 
@@ -117,9 +126,10 @@ export async function recordDbCreated(client, {
117
126
  * @param {{query: Function}} client
118
127
  * @param {{databaseName: string, livenessPid?: number|null}} args
119
128
  */
120
- export async function touchLastConnection(client, { databaseName, livenessPid = null }) {
129
+ export async function touchLastConnection(client, { databaseName, livenessPid = null }, opts = {}) {
121
130
  if (!databaseName) throw new Error('touchLastConnection: databaseName required');
122
- await client.query(
131
+ await query(
132
+ client,
123
133
  `
124
134
  UPDATE pgserve_meta
125
135
  SET last_connection_at = now(),
@@ -127,6 +137,7 @@ export async function touchLastConnection(client, { databaseName, livenessPid =
127
137
  WHERE database_name = $1
128
138
  `,
129
139
  [databaseName, livenessPid],
140
+ opts,
130
141
  );
131
142
  }
132
143
 
@@ -137,11 +148,13 @@ export async function touchLastConnection(client, { databaseName, livenessPid =
137
148
  * @param {string} databaseName
138
149
  * @param {boolean} value
139
150
  */
140
- export async function markPersist(client, databaseName, value) {
151
+ export async function markPersist(client, databaseName, value, opts = {}) {
141
152
  if (!databaseName) throw new Error('markPersist: databaseName required');
142
- await client.query(
153
+ await query(
154
+ client,
143
155
  `UPDATE pgserve_meta SET persist = $2 WHERE database_name = $1`,
144
156
  [databaseName, !!value],
157
+ opts,
145
158
  );
146
159
  }
147
160
 
@@ -203,12 +216,14 @@ export async function deleteMetaRow(client, databaseName) {
203
216
  * @param {string} fingerprint — 12 hex chars
204
217
  * @returns {Promise<{databaseName: string, fingerprint: string, peerUid: number, allowedTokens: Array<{id: string, hash: string, issued_at: string}>} | null>}
205
218
  */
206
- export async function findRowByFingerprint(client, fingerprint) {
219
+ export async function findRowByFingerprint(client, fingerprint, opts = {}) {
207
220
  if (!fingerprint) throw new Error('findRowByFingerprint: fingerprint required');
208
- const r = await client.query(
221
+ const r = await query(
222
+ client,
209
223
  `SELECT database_name, fingerprint, peer_uid, allowed_tokens
210
224
  FROM pgserve_meta WHERE fingerprint = $1 LIMIT 1`,
211
225
  [fingerprint],
226
+ opts,
212
227
  );
213
228
  if (r.rows.length === 0) return null;
214
229
  const row = r.rows[0];
@@ -239,12 +254,12 @@ function parseTokens(raw) {
239
254
  * @returns {Promise<{databaseName: string}>}
240
255
  * @throws if the fingerprint has no pgserve_meta row
241
256
  */
242
- export async function addAllowedToken(client, { fingerprint, tokenId, tokenHash }) {
257
+ export async function addAllowedToken(client, { fingerprint, tokenId, tokenHash }, opts = {}) {
243
258
  if (!fingerprint) throw new Error('addAllowedToken: fingerprint required');
244
259
  if (!tokenId) throw new Error('addAllowedToken: tokenId required');
245
260
  if (!tokenHash) throw new Error('addAllowedToken: tokenHash required');
246
261
 
247
- const row = await findRowByFingerprint(client, fingerprint);
262
+ const row = await findRowByFingerprint(client, fingerprint, opts);
248
263
  if (!row) {
249
264
  const err = new Error(
250
265
  `addAllowedToken: no pgserve_meta row for fingerprint ${fingerprint}; ` +
@@ -259,11 +274,13 @@ export async function addAllowedToken(client, { fingerprint, tokenId, tokenHash
259
274
  hash: tokenHash,
260
275
  issued_at: new Date().toISOString(),
261
276
  };
262
- await client.query(
277
+ await query(
278
+ client,
263
279
  `UPDATE pgserve_meta
264
280
  SET allowed_tokens = allowed_tokens || $2::jsonb
265
281
  WHERE database_name = $1`,
266
282
  [row.databaseName, JSON.stringify([entry])],
283
+ opts,
267
284
  );
268
285
  return { databaseName: row.databaseName };
269
286
  }
@@ -302,10 +319,10 @@ export async function revokeAllowedToken(client, tokenId) {
302
319
  * @param {{fingerprint: string, tokenHash: string}} args
303
320
  * @returns {Promise<{tokenId: string, databaseName: string} | null>}
304
321
  */
305
- export async function verifyToken(client, { fingerprint, tokenHash }) {
322
+ export async function verifyToken(client, { fingerprint, tokenHash }, opts = {}) {
306
323
  if (!fingerprint) throw new Error('verifyToken: fingerprint required');
307
324
  if (!tokenHash) throw new Error('verifyToken: tokenHash required');
308
- const row = await findRowByFingerprint(client, fingerprint);
325
+ const row = await findRowByFingerprint(client, fingerprint, opts);
309
326
  if (!row) return null;
310
327
  const match = row.allowedTokens.find((t) => timingSafeEqual(t.hash, tokenHash));
311
328
  if (!match) return null;
@@ -130,6 +130,7 @@ async function processStartupMessage(socket, state) {
130
130
  if (code === SSL_REQUEST_CODE || code === GSSAPI_REQUEST_CODE) {
131
131
  socket.write(Buffer.from('N'));
132
132
  state.buffer = buffer.length > messageLength ? buffer.subarray(messageLength) : null;
133
+ if (state.buffer) await processStartupMessage.call(this, socket, state);
133
134
  return;
134
135
  }
135
136
 
@@ -268,10 +269,11 @@ async function resolveTenantDatabase(state, requestedDb) {
268
269
  }
269
270
 
270
271
  const { fingerprint, name, uid, pid, packageRealpath } = fp;
272
+ const lookupOpts = { timeoutMs: this.adminLookupTimeoutMs };
271
273
 
272
274
  let row = null;
273
275
  try {
274
- row = await findRowByFingerprint(this._adminClient, fingerprint);
276
+ row = await findRowByFingerprint(this._adminClient, fingerprint, lookupOpts);
275
277
  } catch (err) {
276
278
  this.logger.warn?.(
277
279
  { err: err?.message || String(err), fingerprint },
@@ -296,7 +298,7 @@ async function resolveTenantDatabase(state, requestedDb) {
296
298
  packageRealpath: packageRealpath || null,
297
299
  livenessPid: typeof pid === 'number' && pid > 0 ? pid : null,
298
300
  persist: persistRequested,
299
- });
301
+ }, lookupOpts);
300
302
  audit(AUDIT_EVENTS.DB_CREATED, {
301
303
  database: newName,
302
304
  fingerprint,
@@ -319,7 +321,7 @@ async function resolveTenantDatabase(state, requestedDb) {
319
321
  await touchLastConnection(this._adminClient, {
320
322
  databaseName: row.databaseName,
321
323
  livenessPid: typeof pid === 'number' && pid > 0 ? pid : null,
322
- });
324
+ }, lookupOpts);
323
325
  } catch (err) {
324
326
  this.logger.warn?.(
325
327
  { err: err?.message || String(err), database: row.databaseName },
@@ -330,7 +332,7 @@ async function resolveTenantDatabase(state, requestedDb) {
330
332
  // flag between connections — the previous run might have started without
331
333
  // persist:true and the operator just added it (or vice versa).
332
334
  try {
333
- await markPersist(this._adminClient, row.databaseName, persistRequested);
335
+ await markPersist(this._adminClient, row.databaseName, persistRequested, lookupOpts);
334
336
  } catch (err) {
335
337
  this.logger.warn?.(
336
338
  { err: err?.message || String(err), database: row.databaseName },
package/src/daemon-tcp.js CHANGED
@@ -126,6 +126,7 @@ async function processTcpStartupMessage(socket, state) {
126
126
  if (code === SSL_REQUEST_CODE || code === GSSAPI_REQUEST_CODE) {
127
127
  socket.write(Buffer.from('N'));
128
128
  state.buffer = buffer.length > messageLength ? buffer.subarray(messageLength) : null;
129
+ if (state.buffer) await processTcpStartupMessage.call(this, socket, state);
129
130
  return;
130
131
  }
131
132
  if (code === CANCEL_REQUEST_CODE) {
@@ -153,7 +154,7 @@ async function processTcpStartupMessage(socket, state) {
153
154
  validated = await verifyToken(this._adminClient, {
154
155
  fingerprint: auth.fingerprint,
155
156
  tokenHash,
156
- });
157
+ }, { timeoutMs: this.adminLookupTimeoutMs });
157
158
  }
158
159
  } catch (err) {
159
160
  this.logger.warn?.({ err: err.message }, 'verifyToken failed');
package/src/daemon.js CHANGED
@@ -251,7 +251,7 @@ export class PgserveDaemon extends EventEmitter {
251
251
 
252
252
  this.pgManager = options.pgManager || new PostgresManager({
253
253
  dataDir: this.baseDir,
254
- port: options.pgPort || 5433,
254
+ port: options.pgPort ?? 0,
255
255
  logger: this.logger.child ? this.logger.child({ component: 'postgres' }) : this.logger,
256
256
  useRam: this.useRam,
257
257
  enablePgvector: options.enablePgvector || false,
@@ -266,6 +266,9 @@ export class PgserveDaemon extends EventEmitter {
266
266
  this._stopping = false;
267
267
  // Lazy-initialised admin DB client (Group 6 token validation).
268
268
  this._adminClient = null;
269
+ this.adminIdleTimeout = options.adminIdleTimeout ?? 300;
270
+ this.adminQueryTimeoutMs = options.adminQueryTimeoutMs ?? 0;
271
+ this.adminLookupTimeoutMs = options.adminLookupTimeoutMs ?? 5000;
269
272
  // Group 5: GC sweep handle ({stop, sweep}). Installed once the admin
270
273
  // client is up and torn down on stop().
271
274
  this._gcHandle = null;
@@ -411,6 +414,8 @@ export class PgserveDaemon extends EventEmitter {
411
414
  this._adminClient = await createAdminClient({
412
415
  socketDir: this.pgManager.socketDir,
413
416
  port: this.pgManager.port,
417
+ idleTimeout: this.adminIdleTimeout,
418
+ queryTimeoutMs: this.adminQueryTimeoutMs,
414
419
  });
415
420
  await ensureMetaSchema(this._adminClient);
416
421
  writeAdminDiscovery({
@@ -6,7 +6,7 @@
6
6
  *
7
7
  * 1. SO_PEERCRED (Linux) / getpeereid + LOCAL_PEERPID (macOS)
8
8
  * → kernel-attested {pid, uid, gid}
9
- * 2. /proc/$pid/cwd peer's current working directory (Linux only)
9
+ * 2. peer cwd lookup → /proc/$pid/cwd on Linux, lsof on macOS
10
10
  * 3. walk upward to the nearest package.json
11
11
  * 4. if found: fingerprint = sha256(realpath \0 name \0 uid)[:12] mode='package'
12
12
  * else: fingerprint = sha256(uid \0 cwd \0 cmdline[1])[:12] mode='script'
@@ -29,6 +29,7 @@
29
29
  */
30
30
 
31
31
  import crypto from 'crypto';
32
+ import { execFileSync } from 'child_process';
32
33
  import fs from 'fs';
33
34
  import path from 'path';
34
35
  import { audit, AUDIT_EVENTS } from './audit.js';
@@ -175,17 +176,21 @@ function makeDarwinReader(symbols, ptr) {
175
176
  }
176
177
 
177
178
  // ---------------------------------------------------------------------------
178
- // /proc reads Linux-only; macOS daemon support is best-effort
179
+ // Peer process metadata reads
179
180
  // ---------------------------------------------------------------------------
180
181
 
181
182
  /**
182
- * Resolve the cwd of a peer process via /proc/$pid/cwd. Linux-only.
183
- * Returns null if the symlink cannot be read (process gone, EACCES, etc).
183
+ * Resolve the cwd of a peer process. Linux uses /proc/$pid/cwd; macOS has no
184
+ * /proc, so it shells out to the platform lsof binary and parses the cwd row.
185
+ * Returns null if the process disappeared, permissions deny the lookup, or the
186
+ * host does not expose a cwd for the peer.
184
187
  *
185
188
  * @param {number} pid
186
189
  * @returns {string | null}
187
190
  */
188
191
  export function readProcCwd(pid) {
192
+ if (!Number.isInteger(pid) || pid <= 0) return null;
193
+ if (process.platform === 'darwin') return readDarwinCwd(pid);
189
194
  if (process.platform !== 'linux') return null;
190
195
  try {
191
196
  return fs.readlinkSync(`/proc/${pid}/cwd`);
@@ -194,6 +199,27 @@ export function readProcCwd(pid) {
194
199
  }
195
200
  }
196
201
 
202
+ function readDarwinCwd(pid) {
203
+ const lsof = process.env.PGSERVE_LSOF_BIN || '/usr/sbin/lsof';
204
+ try {
205
+ const output = execFileSync(lsof, ['-a', '-p', String(pid), '-d', 'cwd', '-Fn'], {
206
+ encoding: 'utf8',
207
+ timeout: 1000,
208
+ stdio: ['ignore', 'pipe', 'ignore'],
209
+ });
210
+ return parseDarwinLsofCwd(output);
211
+ } catch {
212
+ return null;
213
+ }
214
+ }
215
+
216
+ export function parseDarwinLsofCwd(output) {
217
+ for (const line of String(output || '').split(/\r?\n/)) {
218
+ if (line.startsWith('n') && line.length > 1) return line.slice(1);
219
+ }
220
+ return null;
221
+ }
222
+
197
223
  /**
198
224
  * Read the peer's argv via /proc/$pid/cmdline (NUL-separated).
199
225
  * argv[0] is the exe; argv[1] is typically the script.
package/src/postgres.js CHANGED
@@ -406,10 +406,23 @@ export function pgvectorMetaMatches(meta, runtime) {
406
406
  return true;
407
407
  }
408
408
 
409
+ function findAvailableTcpPort() {
410
+ const server = Bun.listen({
411
+ hostname: '127.0.0.1',
412
+ port: 0,
413
+ socket: {
414
+ data() {},
415
+ },
416
+ });
417
+ const port = server.port;
418
+ server.stop(true);
419
+ return port;
420
+ }
421
+
409
422
  export class PostgresManager {
410
423
  constructor(options = {}) {
411
424
  this.dataDir = options.dataDir || null; // null = memory mode (temp dir)
412
- this.port = options.port || 5433; // Internal PG port (router listens on different port)
425
+ this.port = options.port ?? 5433; // Internal PG port (router listens on different port)
413
426
  this.user = options.user || 'postgres';
414
427
  this.password = options.password || 'postgres';
415
428
  this.logger = options.logger;
@@ -465,6 +478,10 @@ export class PostgresManager {
465
478
  await fs.promises.chmod(this.binaries.initdb, '755');
466
479
  await fs.promises.chmod(this.binaries.postgres, '755');
467
480
 
481
+ if (this.port === 0) {
482
+ this.port = findAvailableTcpPort();
483
+ }
484
+
468
485
  // Determine data directory
469
486
  if (this.persistent) {
470
487
  this.databaseDir = this.dataDir;
@@ -568,7 +585,8 @@ export class PostgresManager {
568
585
  const initdbCmd = [
569
586
  this.binaries.initdb,
570
587
  `--pgdata=${this.databaseDir}`,
571
- '--auth=password',
588
+ '--auth-local=trust',
589
+ '--auth-host=password',
572
590
  `--username=${this.user}`,
573
591
  `--pwfile=${passwordFile}`,
574
592
  ];
@@ -744,11 +762,13 @@ export class PostgresManager {
744
762
  // Whichever succeeds first wins
745
763
 
746
764
  const markReady = (method) => {
747
- if (!started) {
748
- started = true;
749
- this.logger.info({ port: this.port, method }, 'PostgreSQL ready');
750
- resolve();
751
- }
765
+ if (started || processExited) return true;
766
+ const socketPath = this.getSocketPath();
767
+ if (socketPath && !fs.existsSync(socketPath)) return false;
768
+ started = true;
769
+ this.logger.info({ port: this.port, method }, 'PostgreSQL ready');
770
+ resolve();
771
+ return true;
752
772
  };
753
773
 
754
774
  // Read stderr - detect port binding in logs (locale-independent: just look for port number)
@@ -873,14 +893,19 @@ export class PostgresManager {
873
893
  if (processExited) return;
874
894
 
875
895
  try {
896
+ const socketPath = this.getSocketPath();
897
+ if (socketPath && fs.existsSync(socketPath)) {
898
+ markReady('unix-socket');
899
+ return;
900
+ }
876
901
  await tryConnect();
877
902
  // On Windows, TCP port opens before PostgreSQL is fully ready for protocol handshakes
878
903
  // Add delay to let PostgreSQL complete its startup sequence
879
904
  if (isWindows) {
880
905
  await Bun.sleep(2000); // 2 second delay for Windows
881
906
  }
882
- markReady('tcp');
883
- return;
907
+ if (processExited) return;
908
+ if (markReady('tcp')) return;
884
909
  } catch {
885
910
  await Bun.sleep(200);
886
911
  }
package/src/router.js CHANGED
@@ -342,6 +342,7 @@ export class MultiTenantRouter extends EventEmitter {
342
342
  socket.write(Buffer.from('N'));
343
343
  // Remove this request from buffer, wait for real startup
344
344
  state.buffer = buffer.length > messageLength ? buffer.subarray(messageLength) : null;
345
+ if (state.buffer) await this.processStartupMessage(socket, state);
345
346
  return;
346
347
  }
347
348
 
@@ -0,0 +1,171 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import fs from 'fs';
3
+ import net from 'net';
4
+ import path from 'path';
5
+
6
+ import {
7
+ PgserveDaemon,
8
+ resolveControlSocketPath,
9
+ resolvePidLockPath,
10
+ } from '../src/daemon.js';
11
+ import { createLogger } from '../src/logger.js';
12
+
13
+ const SSL_REQUEST_CODE = 80877103;
14
+ const PROTOCOL_VERSION_3 = 196608;
15
+
16
+ function silentLogger() {
17
+ return createLogger({ level: process.env.PGSERVE_TEST_LOG || 'warn' });
18
+ }
19
+
20
+ function makeIsolated(tag) {
21
+ const dir = path.join('/tmp', `pgs-${tag}-${process.pid}-${Date.now()}`);
22
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
23
+ return dir;
24
+ }
25
+
26
+ function freeTcpPort() {
27
+ return new Promise((resolve, reject) => {
28
+ const srv = net.createServer();
29
+ srv.unref();
30
+ srv.on('error', reject);
31
+ srv.listen(0, '127.0.0.1', () => {
32
+ const { port } = srv.address();
33
+ srv.close(() => resolve(port));
34
+ });
35
+ });
36
+ }
37
+
38
+ function sslRequest() {
39
+ const buf = Buffer.alloc(8);
40
+ buf.writeUInt32BE(8, 0);
41
+ buf.writeUInt32BE(SSL_REQUEST_CODE, 4);
42
+ return buf;
43
+ }
44
+
45
+ function startupMessage({ user = 'postgres', database = 'postgres' } = {}) {
46
+ const params = Buffer.from(`user\0${user}\0database\0${database}\0client_encoding\0UTF8\0\0`);
47
+ const buf = Buffer.alloc(8 + params.length);
48
+ buf.writeUInt32BE(buf.length, 0);
49
+ buf.writeUInt32BE(PROTOCOL_VERSION_3, 4);
50
+ params.copy(buf, 8);
51
+ return buf;
52
+ }
53
+
54
+ function passwordMessage(password = 'postgres') {
55
+ const body = Buffer.from(`${password}\0`);
56
+ const buf = Buffer.alloc(1 + 4 + body.length);
57
+ buf.write('p', 0);
58
+ buf.writeUInt32BE(4 + body.length, 1);
59
+ body.copy(buf, 5);
60
+ return buf;
61
+ }
62
+
63
+ async function connectWithCoalescedStartup(socketPath) {
64
+ return new Promise((resolve, reject) => {
65
+ const socket = net.createConnection(socketPath);
66
+ let buffer = Buffer.alloc(0);
67
+ let sawSslReject = false;
68
+ let sawAuthOk = false;
69
+
70
+ const timer = setTimeout(() => {
71
+ socket.destroy();
72
+ reject(new Error('timed out waiting for ReadyForQuery after coalesced startup'));
73
+ }, 5000);
74
+ timer.unref();
75
+
76
+ const done = (err, result) => {
77
+ clearTimeout(timer);
78
+ socket.destroy();
79
+ if (err) reject(err);
80
+ else resolve(result);
81
+ };
82
+
83
+ const pump = () => {
84
+ if (!sawSslReject) {
85
+ if (buffer.length < 1) return;
86
+ if (buffer[0] !== 78) {
87
+ done(new Error(`expected SSL reject byte N, got ${buffer[0]}`));
88
+ return;
89
+ }
90
+ sawSslReject = true;
91
+ buffer = buffer.subarray(1);
92
+ }
93
+
94
+ while (buffer.length >= 5) {
95
+ const type = String.fromCharCode(buffer[0]);
96
+ const length = buffer.readUInt32BE(1);
97
+ if (buffer.length < 1 + length) return;
98
+
99
+ const payload = buffer.subarray(5, 1 + length);
100
+ buffer = buffer.subarray(1 + length);
101
+
102
+ if (type === 'R') {
103
+ const authCode = payload.readUInt32BE(0);
104
+ if (authCode === 3) socket.write(passwordMessage());
105
+ if (authCode === 0) sawAuthOk = true;
106
+ } else if (type === 'E') {
107
+ done(new Error(`postgres error response: ${payload.toString('utf8')}`));
108
+ return;
109
+ } else if (type === 'Z') {
110
+ done(null, { sawSslReject, sawAuthOk });
111
+ return;
112
+ }
113
+ }
114
+ };
115
+
116
+ socket.on('connect', () => {
117
+ socket.write(Buffer.concat([sslRequest(), startupMessage()]));
118
+ });
119
+ socket.on('data', (chunk) => {
120
+ buffer = Buffer.concat([buffer, chunk]);
121
+ pump();
122
+ });
123
+ socket.on('error', done);
124
+ });
125
+ }
126
+
127
+ describe('daemon Unix control protocol', () => {
128
+ test('processes startup already buffered behind SSLRequest', async () => {
129
+ const dir = makeIsolated('coalesced');
130
+ const daemon = new PgserveDaemon({
131
+ controlSocketDir: dir,
132
+ controlSocketPath: resolveControlSocketPath(dir),
133
+ pidLockPath: resolvePidLockPath(dir),
134
+ pgPort: await freeTcpPort(),
135
+ logger: silentLogger(),
136
+ });
137
+
138
+ await daemon.start();
139
+ try {
140
+ const result = await connectWithCoalescedStartup(resolveControlSocketPath(dir));
141
+ expect(result).toEqual({ sawSslReject: true, sawAuthOk: true });
142
+ } finally {
143
+ await daemon.stop();
144
+ fs.rmSync(dir, { recursive: true, force: true });
145
+ }
146
+ });
147
+
148
+ test('processes startup after the admin client idles out', async () => {
149
+ const dir = makeIsolated('admin-idle');
150
+ const daemon = new PgserveDaemon({
151
+ controlSocketDir: dir,
152
+ controlSocketPath: resolveControlSocketPath(dir),
153
+ pidLockPath: resolvePidLockPath(dir),
154
+ pgPort: await freeTcpPort(),
155
+ adminIdleTimeout: 1,
156
+ adminLookupTimeoutMs: 1000,
157
+ logger: silentLogger(),
158
+ });
159
+
160
+ await daemon.start();
161
+ try {
162
+ await connectWithCoalescedStartup(resolveControlSocketPath(dir));
163
+ await Bun.sleep(1500);
164
+ const result = await connectWithCoalescedStartup(resolveControlSocketPath(dir));
165
+ expect(result).toEqual({ sawSslReject: true, sawAuthOk: true });
166
+ } finally {
167
+ await daemon.stop();
168
+ fs.rmSync(dir, { recursive: true, force: true });
169
+ }
170
+ });
171
+ });
@@ -20,6 +20,8 @@ import {
20
20
  initFingerprintFfi,
21
21
  getPeerCred,
22
22
  findNearestPackageJson,
23
+ parseDarwinLsofCwd,
24
+ readProcCwd,
23
25
  readPackageName,
24
26
  derivePackageFingerprint,
25
27
  deriveScriptFingerprint,
@@ -80,6 +82,18 @@ test('getPeerCred reads kernel-attested pid/uid/gid via Unix socket pair', async
80
82
  expect(cred.gid).toBe(expectedGid);
81
83
  });
82
84
 
85
+ test('macOS lsof parser extracts cwd field output', () => {
86
+ const cwd = path.join(scratch, 'project');
87
+ const output = `p12345\nn${cwd}\n`;
88
+ expect(parseDarwinLsofCwd(output)).toBe(cwd);
89
+ expect(parseDarwinLsofCwd('p12345\n')).toBeNull();
90
+ });
91
+
92
+ test('readProcCwd resolves the current process cwd on supported platforms', () => {
93
+ if (process.platform !== 'linux' && process.platform !== 'darwin') return;
94
+ expect(readProcCwd(process.pid)).toBe(process.cwd());
95
+ });
96
+
83
97
  // ---------------------------------------------------------------------------
84
98
  // Pure-function tests on derivation surface
85
99
  // ---------------------------------------------------------------------------