pgserve 1.1.10 → 1.2.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.
- package/.genie/wishes/release-system-genie-pattern/WISH.md +268 -0
- package/.genie/wishes/release-system-genie-pattern/validation.md +172 -0
- package/.github/workflows/release.yml +233 -111
- package/.github/workflows/{build-all-platforms.yml → version.yml} +30 -6
- package/AGENTS.md +10 -8
- package/Makefile +18 -41
- package/SECURITY.md +109 -0
- package/package.json +1 -1
- package/src/postgres.js +54 -0
- package/src/router.js +70 -5
- package/tests/multi-tenant.test.js +164 -0
- package/.github/release.yml +0 -30
- package/scripts/release.cjs +0 -198
package/SECURITY.md
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# Security Policy
|
|
2
|
+
|
|
3
|
+
`pgserve` is maintained by [Automagik](https://automagik.dev). We take the security of this package seriously and appreciate responsible disclosure from the community.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Reporting a Vulnerability
|
|
8
|
+
|
|
9
|
+
**Please do not open public issues for security reports.**
|
|
10
|
+
|
|
11
|
+
Send private reports to one of the following channels:
|
|
12
|
+
|
|
13
|
+
| Channel | Address | Best for |
|
|
14
|
+
|---------|---------|----------|
|
|
15
|
+
| Security email | `privacidade@namastex.ai` | Anything security-related, including coordinated disclosure |
|
|
16
|
+
| DPO (privacy + security officer) | `dpo@khal.ai` | Privacy, LGPD, data protection concerns |
|
|
17
|
+
| Private GitHub advisory | [Report via GitHub](https://github.com/namastexlabs/pgserve/security/advisories/new) | Preferred for CVE assignment and coordinated release |
|
|
18
|
+
|
|
19
|
+
**PGP** available on request.
|
|
20
|
+
|
|
21
|
+
### Response SLA
|
|
22
|
+
|
|
23
|
+
- Acknowledgement: **within 2 business hours** (UTC-3).
|
|
24
|
+
- Initial triage and severity assessment: **within 24 hours**.
|
|
25
|
+
- Fix or mitigation plan: **within 7 days** for critical/high severity.
|
|
26
|
+
- Public disclosure: coordinated with reporter, typically within 30 days of fix.
|
|
27
|
+
|
|
28
|
+
We will credit reporters publicly (with their permission) in the released advisory.
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## Supported Versions
|
|
33
|
+
|
|
34
|
+
| Version line | Status |
|
|
35
|
+
|--------------|--------|
|
|
36
|
+
| `1.1.10` and later clean releases | ✅ Supported — current |
|
|
37
|
+
| `1.1.11` – `1.1.14` | ❌ **COMPROMISED — do not use** |
|
|
38
|
+
| `1.1.0` – `1.1.9` | ⚠️ Legacy — security patches only |
|
|
39
|
+
| `1.0.x` and earlier | ❌ End of life |
|
|
40
|
+
|
|
41
|
+
Always install from the current stable line. Pin explicit versions in your `package.json` and avoid `latest` for supply-chain sensitive packages.
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## Past Incidents
|
|
46
|
+
|
|
47
|
+
### 2026-04 — CanisterWorm supply-chain compromise
|
|
48
|
+
|
|
49
|
+
Between 2026-04-21 (~22:14 UTC) and 2026-04-22 (~14:00 UTC), versions `1.1.11`, `1.1.12`, `1.1.13`, and `1.1.14` were published to npm by a threat actor after a developer GitHub OAuth token was exfiltrated. The malicious versions contained a `TeamPCP` payload in `scripts/check-env.js` that executed via `postinstall` to harvest local credentials.
|
|
50
|
+
|
|
51
|
+
- **Exposure window:** ~16 hours
|
|
52
|
+
- **Detection-to-containment:** under 20 hours
|
|
53
|
+
- **Current status:** malicious versions `npm unpublish`-ed and no longer installable
|
|
54
|
+
|
|
55
|
+
**If you installed versions `1.1.11` – `1.1.14` between April 21–22, 2026, assume your machine is compromised.** Follow the remediation guide linked below.
|
|
56
|
+
|
|
57
|
+
**Resources:**
|
|
58
|
+
- 📖 [Full incident response manual](https://github.com/namastexlabs/genie-dpo/blob/main/knowledge/canisterworm-incident-response.md)
|
|
59
|
+
- 🌐 [Public advisory (English)](https://automagik.dev/security)
|
|
60
|
+
- 🌐 [Aviso público (Português)](https://automagik.dev/seguranca)
|
|
61
|
+
- 🛡️ [GitHub Security Advisories](https://github.com/namastexlabs/pgserve/security/advisories) for this repository
|
|
62
|
+
|
|
63
|
+
A full public post-mortem will be published within 30 days of containment.
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## Acknowledgments
|
|
68
|
+
|
|
69
|
+
We thank the researchers and organizations that identified and tracked this incident:
|
|
70
|
+
|
|
71
|
+
- [**Socket Research Team**](https://socket.dev/blog/namastex-npm-packages-compromised-canisterworm) — primary discovery and continued tracking at [socket.dev/supply-chain-attacks/canistersprawl](https://socket.dev/supply-chain-attacks/canistersprawl).
|
|
72
|
+
- **Endor Labs**, **Kodem Security**, **BleepingComputer**, **The Register**, **CSO Online**, **GBHackers**, **Cybersecurity News** — for coverage, analysis, and technical breakdowns that helped defenders respond quickly.
|
|
73
|
+
|
|
74
|
+
We also thank the Automagik team that ran the end-to-end response during the incident window, and the broader open-source community whose scrutiny, tools, and unfiltered feedback keep this ecosystem healthy. We will keep earning it.
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## Our Commitments
|
|
79
|
+
|
|
80
|
+
Effective 2026-04-23, all `pgserve` releases are governed by:
|
|
81
|
+
|
|
82
|
+
- **Provenance attestation** — every publication is signed with `npm --provenance` and verifiable via Sigstore.
|
|
83
|
+
- **OIDC trusted publishing** — migrating to GitHub Actions OIDC publish, eliminating long-lived npm tokens. (in progress)
|
|
84
|
+
- **Mandatory 2FA** on every maintainer account with publish rights.
|
|
85
|
+
- **Environment protection** — production publishes require manual approval from a second maintainer.
|
|
86
|
+
- **Quarterly token audit** — scope and permission review.
|
|
87
|
+
- **External pentest** — scheduled ahead of the original roadmap.
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## Hardening Recommendations for Consumers
|
|
92
|
+
|
|
93
|
+
- Pin explicit versions, not `latest`: `"pgserve": "1.1.10"`.
|
|
94
|
+
- Use `npm ci` in CI. It enforces lockfile-based installs by default.
|
|
95
|
+
- Evaluate `--ignore-scripts` per-package for untrusted dependencies. The current `pgserve` release does not require any lifecycle script to function.
|
|
96
|
+
- Verify package provenance: `npm view pgserve --json | jq '.dist.attestations'`.
|
|
97
|
+
- Monitor advisories: subscribe to GitHub security alerts for this repository.
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
101
|
+
## Contact
|
|
102
|
+
|
|
103
|
+
- **Security & incidents:** `privacidade@namastex.ai`
|
|
104
|
+
- **Data Protection Officer (DPO):** Cezar Vasconcelos — `dpo@khal.ai`
|
|
105
|
+
- **Security disclosure page:** [automagik.dev/security](https://automagik.dev/security)
|
|
106
|
+
|
|
107
|
+
Namastex Labs Serviços em Tecnologia Ltda · CNPJ 46.156.854/0001-62
|
|
108
|
+
|
|
109
|
+
*Last updated: 2026-04-23 · v1.0*
|
package/package.json
CHANGED
package/src/postgres.js
CHANGED
|
@@ -444,8 +444,20 @@ export class PostgresManager {
|
|
|
444
444
|
|
|
445
445
|
/**
|
|
446
446
|
* Start the embedded PostgreSQL instance
|
|
447
|
+
*
|
|
448
|
+
* Re-entry guard: if a previous start() left `this.process` or stale state
|
|
449
|
+
* behind, refuse silently rather than leaking another socketDir/databaseDir.
|
|
450
|
+
* Callers must call stop() first if they want to restart.
|
|
447
451
|
*/
|
|
448
452
|
async start() {
|
|
453
|
+
if (this.process) {
|
|
454
|
+
this.logger?.warn(
|
|
455
|
+
{ pid: this.process.pid, socketDir: this.socketDir },
|
|
456
|
+
'PostgresManager.start() called while already started — returning existing instance'
|
|
457
|
+
);
|
|
458
|
+
return this;
|
|
459
|
+
}
|
|
460
|
+
|
|
449
461
|
// Get binary paths (may extract bundled binaries on first run)
|
|
450
462
|
this.binaries = await getBinaryPaths();
|
|
451
463
|
|
|
@@ -773,12 +785,33 @@ export class PostgresManager {
|
|
|
773
785
|
readStream(this.process.stdout);
|
|
774
786
|
|
|
775
787
|
// Handle process exit
|
|
788
|
+
//
|
|
789
|
+
// When the postgres subprocess exits (normal stop OR crash), we must
|
|
790
|
+
// null `this.process` AND `this.socketDir`/`this.databaseDir` so that
|
|
791
|
+
// subsequent `getSocketPath()` calls do not return a path to a directory
|
|
792
|
+
// that no longer exists. This is the issue #24 root cause: the router
|
|
793
|
+
// was receiving stale socketPaths pointing to cleaned-up tmp dirs.
|
|
794
|
+
//
|
|
795
|
+
// NOTE: we do NOT null socketDir here if `stop()` is in flight, because
|
|
796
|
+
// stop() already handles cleanup+null. We only need to self-heal when
|
|
797
|
+
// the exit is unexpected (external kill, crash, OOM).
|
|
776
798
|
this.process.exited.then((code) => {
|
|
777
799
|
processExited = true;
|
|
778
800
|
if (!started) {
|
|
779
801
|
reject(new Error(`PostgreSQL exited with code ${code} before starting: ${startupOutput}`));
|
|
780
802
|
}
|
|
781
803
|
this.process = null;
|
|
804
|
+
// On unexpected exit (not via stop()), reset cached paths so that
|
|
805
|
+
// getSocketPath() returns null and callers can fall back to TCP
|
|
806
|
+
// or force a fresh start().
|
|
807
|
+
if (!this._stopping) {
|
|
808
|
+
this.socketDir = null;
|
|
809
|
+
this.databaseDir = null;
|
|
810
|
+
this.logger?.warn(
|
|
811
|
+
{ code },
|
|
812
|
+
'PostgreSQL subprocess exited unexpectedly — socketDir/databaseDir reset'
|
|
813
|
+
);
|
|
814
|
+
}
|
|
782
815
|
});
|
|
783
816
|
|
|
784
817
|
// Method 1: TCP connection polling (preferred, works on Linux/macOS)
|
|
@@ -1294,8 +1327,19 @@ export class PostgresManager {
|
|
|
1294
1327
|
|
|
1295
1328
|
/**
|
|
1296
1329
|
* Stop the PostgreSQL instance
|
|
1330
|
+
*
|
|
1331
|
+
* Cleanup order matters: we null `this.socketDir`/`this.databaseDir` AFTER
|
|
1332
|
+
* the rmSync so any concurrent `getSocketPath()` call either sees the old
|
|
1333
|
+
* path (while it still exists) or null (after cleanup) — never a path
|
|
1334
|
+
* pointing to a deleted directory.
|
|
1335
|
+
*
|
|
1336
|
+
* The `_stopping` flag tells the process.exited handler to NOT redundantly
|
|
1337
|
+
* null the paths (avoids a race where start() called immediately after
|
|
1338
|
+
* stop() sees nulls that stop() was about to set anyway).
|
|
1297
1339
|
*/
|
|
1298
1340
|
async stop() {
|
|
1341
|
+
this._stopping = true;
|
|
1342
|
+
|
|
1299
1343
|
// Close admin pool first (Bun.sql)
|
|
1300
1344
|
if (this.adminPool) {
|
|
1301
1345
|
await this.adminPool.close();
|
|
@@ -1340,6 +1384,16 @@ export class PostgresManager {
|
|
|
1340
1384
|
}
|
|
1341
1385
|
}
|
|
1342
1386
|
}
|
|
1387
|
+
|
|
1388
|
+
// Reset cached paths UNCONDITIONALLY after cleanup so getSocketPath()
|
|
1389
|
+
// returns null for anyone still holding a reference to this instance.
|
|
1390
|
+
// This is the core fix for issue #24.
|
|
1391
|
+
this.socketDir = null;
|
|
1392
|
+
if (!this.persistent) {
|
|
1393
|
+
this.databaseDir = null;
|
|
1394
|
+
}
|
|
1395
|
+
this.createdDatabases.clear();
|
|
1396
|
+
this._stopping = false;
|
|
1343
1397
|
}
|
|
1344
1398
|
|
|
1345
1399
|
/**
|
package/src/router.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* PERFORMANCE: Uses Bun.listen() and Bun.connect() for 2-3x throughput improvement
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
+
import fs from 'fs';
|
|
17
18
|
import { PostgresManager } from './postgres.js';
|
|
18
19
|
import { SyncManager } from './sync.js';
|
|
19
20
|
import { RestoreManager } from './restore.js';
|
|
@@ -28,6 +29,14 @@ const SSL_REQUEST_CODE = 80877103;
|
|
|
28
29
|
const GSSAPI_REQUEST_CODE = 80877104;
|
|
29
30
|
const CANCEL_REQUEST_CODE = 80877102;
|
|
30
31
|
|
|
32
|
+
// Maximum size for the pre-handshake startup buffer. A legitimate PG
|
|
33
|
+
// startup message is at most a few hundred bytes; anything approaching
|
|
34
|
+
// 1 MiB is a runaway client or an attempted buffer-growth DoS. Bound
|
|
35
|
+
// this to stop the proxy from accumulating gigabytes of orphaned data
|
|
36
|
+
// when a client sends garbage and the handshake never completes.
|
|
37
|
+
// (Issue #18 root cause #2 — unbounded growth at state.buffer.)
|
|
38
|
+
const MAX_STARTUP_BUFFER_SIZE = 1024 * 1024; // 1 MiB
|
|
39
|
+
|
|
31
40
|
/**
|
|
32
41
|
* Attempt to write a pending buffer to a target socket.
|
|
33
42
|
* Returns remaining unwritten bytes, or null if fully flushed.
|
|
@@ -231,6 +240,12 @@ export class MultiTenantRouter extends EventEmitter {
|
|
|
231
240
|
pgSocket: null,
|
|
232
241
|
dbName: null,
|
|
233
242
|
handshakeComplete: false,
|
|
243
|
+
// startupInProgress serializes processStartupMessage() against async
|
|
244
|
+
// reentrancy — without it, every data event fired while the previous
|
|
245
|
+
// processStartupMessage() is still awaiting createDatabase() would
|
|
246
|
+
// launch another async task on the same state, racing to overwrite
|
|
247
|
+
// state.pgSocket and leaking the losers (issue #18 root cause #1).
|
|
248
|
+
startupInProgress: false,
|
|
234
249
|
pendingToPg: null,
|
|
235
250
|
pendingToClient: null
|
|
236
251
|
});
|
|
@@ -249,9 +264,13 @@ export class MultiTenantRouter extends EventEmitter {
|
|
|
249
264
|
|
|
250
265
|
// If handshake complete, forward to PostgreSQL
|
|
251
266
|
if (state.handshakeComplete && state.pgSocket) {
|
|
252
|
-
// If there's already pending data, append
|
|
267
|
+
// If there's already pending data, append and re-pause.
|
|
268
|
+
// (Re-pause is defensive: client should already be paused from the
|
|
269
|
+
// earlier partial-write, but kernel-buffered data can still arrive
|
|
270
|
+
// before the pause takes effect — issue #18 root cause #3.)
|
|
253
271
|
if (state.pendingToPg) {
|
|
254
272
|
state.pendingToPg = Buffer.concat([state.pendingToPg, data]);
|
|
273
|
+
socket.pause();
|
|
255
274
|
return;
|
|
256
275
|
}
|
|
257
276
|
const written = state.pgSocket.write(data);
|
|
@@ -263,7 +282,20 @@ export class MultiTenantRouter extends EventEmitter {
|
|
|
263
282
|
return;
|
|
264
283
|
}
|
|
265
284
|
|
|
266
|
-
// Buffer data for startup message parsing
|
|
285
|
+
// Buffer data for startup message parsing.
|
|
286
|
+
// Bound the pre-handshake buffer so a client that never completes its
|
|
287
|
+
// startup (or sends garbage) cannot grow state.buffer without limit —
|
|
288
|
+
// the 74 GiB VmSize in the production deadlock report traces to this
|
|
289
|
+
// path (issue #18 root cause #2).
|
|
290
|
+
const incomingSize = state.buffer ? state.buffer.length + data.byteLength : data.byteLength;
|
|
291
|
+
if (incomingSize > MAX_STARTUP_BUFFER_SIZE) {
|
|
292
|
+
this.logger.warn(
|
|
293
|
+
{ incomingSize, limit: MAX_STARTUP_BUFFER_SIZE },
|
|
294
|
+
'Pre-handshake buffer exceeded limit — closing connection'
|
|
295
|
+
);
|
|
296
|
+
socket.end();
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
267
299
|
if (state.buffer) {
|
|
268
300
|
state.buffer = Buffer.concat([state.buffer, data]);
|
|
269
301
|
} else {
|
|
@@ -275,9 +307,17 @@ export class MultiTenantRouter extends EventEmitter {
|
|
|
275
307
|
}
|
|
276
308
|
|
|
277
309
|
/**
|
|
278
|
-
* Process PostgreSQL startup message and establish proxy connection
|
|
310
|
+
* Process PostgreSQL startup message and establish proxy connection.
|
|
311
|
+
*
|
|
312
|
+
* Guarded against async reentrancy: multiple data events arriving while
|
|
313
|
+
* the first processStartupMessage() is still awaiting createDatabase()
|
|
314
|
+
* or Bun.connect() must not launch concurrent tasks on the same state —
|
|
315
|
+
* they would race to assign state.pgSocket, leaking the losing sockets
|
|
316
|
+
* and double-writing the startup message (issue #18 root cause #1).
|
|
279
317
|
*/
|
|
280
318
|
async processStartupMessage(socket, state) {
|
|
319
|
+
if (state.startupInProgress) return;
|
|
320
|
+
|
|
281
321
|
const buffer = state.buffer;
|
|
282
322
|
if (!buffer || buffer.length < 8) return; // Need at least length + protocol
|
|
283
323
|
|
|
@@ -315,6 +355,11 @@ export class MultiTenantRouter extends EventEmitter {
|
|
|
315
355
|
const dbName = extractDatabaseName(startupMessage);
|
|
316
356
|
state.dbName = dbName;
|
|
317
357
|
|
|
358
|
+
// Claim the reentrancy guard BEFORE the first await so subsequent data
|
|
359
|
+
// events (buffered into state.buffer by handleSocketData) cannot launch
|
|
360
|
+
// a second async task on the same state.
|
|
361
|
+
state.startupInProgress = true;
|
|
362
|
+
|
|
318
363
|
try {
|
|
319
364
|
// Auto-provision database if needed
|
|
320
365
|
if (this.autoProvision) {
|
|
@@ -328,9 +373,13 @@ export class MultiTenantRouter extends EventEmitter {
|
|
|
328
373
|
// Shared handler for pgSocket (used by both unix and TCP paths)
|
|
329
374
|
const pgHandler = {
|
|
330
375
|
data(_pgSocket, pgData) {
|
|
331
|
-
// Forward PostgreSQL response to client with backpressure
|
|
376
|
+
// Forward PostgreSQL response to client with backpressure.
|
|
377
|
+
// Re-pause defensively when pendingToClient already exists —
|
|
378
|
+
// kernel-buffered PG data can arrive before the earlier pause()
|
|
379
|
+
// takes effect (issue #18 root cause #3).
|
|
332
380
|
if (state.pendingToClient) {
|
|
333
381
|
state.pendingToClient = Buffer.concat([state.pendingToClient, pgData]);
|
|
382
|
+
_pgSocket.pause();
|
|
334
383
|
return;
|
|
335
384
|
}
|
|
336
385
|
const written = socket.write(pgData);
|
|
@@ -362,9 +411,18 @@ export class MultiTenantRouter extends EventEmitter {
|
|
|
362
411
|
}
|
|
363
412
|
};
|
|
364
413
|
|
|
365
|
-
if
|
|
414
|
+
// Safety net for issue #24: if socketPath points to a directory that was
|
|
415
|
+
// cleaned up (e.g. pgManager was stopped+started, or the PG subprocess
|
|
416
|
+
// exited unexpectedly and socketDir was reset to null but a stale cached
|
|
417
|
+
// path is still hanging around), fall back to TCP instead of Bun.connect
|
|
418
|
+
// hanging on a missing unix socket.
|
|
419
|
+
const useUnix = socketPath && fs.existsSync(socketPath);
|
|
420
|
+
if (useUnix) {
|
|
366
421
|
state.pgSocket = await Bun.connect({ unix: socketPath, socket: pgHandler });
|
|
367
422
|
} else {
|
|
423
|
+
if (socketPath && !useUnix) {
|
|
424
|
+
this.logger.warn({ socketPath, dbName }, 'Unix socket path stale — falling back to TCP');
|
|
425
|
+
}
|
|
368
426
|
state.pgSocket = await Bun.connect({ hostname: '127.0.0.1', port: this.pgPort, socket: pgHandler });
|
|
369
427
|
}
|
|
370
428
|
|
|
@@ -373,6 +431,13 @@ export class MultiTenantRouter extends EventEmitter {
|
|
|
373
431
|
this.logger.error({ dbName, err: error }, 'Connection error');
|
|
374
432
|
socket.end();
|
|
375
433
|
this.emit('connection-error', { error, dbName });
|
|
434
|
+
} finally {
|
|
435
|
+
// Release the reentrancy guard whether handshake succeeded or not.
|
|
436
|
+
// If it succeeded, handshakeComplete is now true and further data
|
|
437
|
+
// events will bypass processStartupMessage anyway (handleSocketData
|
|
438
|
+
// takes the handshakeComplete path). If it failed, socket.end()
|
|
439
|
+
// has been called and the connection is tearing down.
|
|
440
|
+
state.startupInProgress = false;
|
|
376
441
|
}
|
|
377
442
|
}
|
|
378
443
|
|
|
@@ -161,6 +161,170 @@ test('Multi-tenant router - multiple databases isolated', async () => {
|
|
|
161
161
|
cleanup();
|
|
162
162
|
});
|
|
163
163
|
|
|
164
|
+
test('Router - pre-handshake buffer is bounded (issue #18 root cause #2)', async () => {
|
|
165
|
+
// Regression test for issue #18: without a bound on state.buffer, a
|
|
166
|
+
// client that sends garbage and never completes the PG startup would
|
|
167
|
+
// grow router memory unbounded (traced to the production 74 GiB VmSize).
|
|
168
|
+
// After fix, the router must close the connection once the buffer
|
|
169
|
+
// exceeds MAX_STARTUP_BUFFER_SIZE (1 MiB).
|
|
170
|
+
cleanup();
|
|
171
|
+
|
|
172
|
+
const router = await startMultiTenantServer({
|
|
173
|
+
port: 15546,
|
|
174
|
+
baseDir: testDataDir,
|
|
175
|
+
logLevel: 'warn',
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const net = await import('net');
|
|
179
|
+
const sock = net.connect(15546, '127.0.0.1');
|
|
180
|
+
await new Promise((resolve) => sock.once('connect', resolve));
|
|
181
|
+
|
|
182
|
+
const garbage = Buffer.alloc(256 * 1024, 0x41); // 256 KiB of 'A'
|
|
183
|
+
let closed = false;
|
|
184
|
+
sock.on('close', () => { closed = true; });
|
|
185
|
+
|
|
186
|
+
// Send 5 × 256 KiB = 1.25 MiB, exceeding the 1 MiB cap.
|
|
187
|
+
for (let i = 0; i < 5 && !closed; i++) {
|
|
188
|
+
await new Promise((resolve) => sock.write(garbage, resolve));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Wait up to 2s for the proxy to close the connection.
|
|
192
|
+
const deadline = Date.now() + 2000;
|
|
193
|
+
while (!closed && Date.now() < deadline) {
|
|
194
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
expect(closed).toBe(true);
|
|
198
|
+
sock.destroy();
|
|
199
|
+
|
|
200
|
+
await router.stop();
|
|
201
|
+
cleanup();
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('Router - socket state has startupInProgress flag (issue #18 root cause #1)', async () => {
|
|
205
|
+
// White-box regression test for the reentrancy guard. Without
|
|
206
|
+
// state.startupInProgress, two data events arriving during the first
|
|
207
|
+
// processStartupMessage() await would launch concurrent async tasks
|
|
208
|
+
// that race to assign state.pgSocket, leaking the loser. This test
|
|
209
|
+
// verifies the flag is wired into the state object.
|
|
210
|
+
cleanup();
|
|
211
|
+
|
|
212
|
+
const router = await startMultiTenantServer({
|
|
213
|
+
port: 15547,
|
|
214
|
+
baseDir: testDataDir,
|
|
215
|
+
logLevel: 'warn',
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const net = await import('net');
|
|
219
|
+
const sock = net.connect(15547, '127.0.0.1');
|
|
220
|
+
await new Promise((resolve) => sock.once('connect', resolve));
|
|
221
|
+
|
|
222
|
+
// Router tracks client sockets in this.connections; introspect to pull
|
|
223
|
+
// the state object and confirm the flag exists and defaults to false.
|
|
224
|
+
// On some platforms (macOS in particular) the client-side 'connect'
|
|
225
|
+
// event fires slightly before the server-side handleSocketOpen runs,
|
|
226
|
+
// so poll until the router has registered the connection rather than
|
|
227
|
+
// asserting synchronously.
|
|
228
|
+
const deadline = Date.now() + 2000;
|
|
229
|
+
while (router.connections.size === 0 && Date.now() < deadline) {
|
|
230
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
231
|
+
}
|
|
232
|
+
expect(router.connections.size).toBeGreaterThan(0);
|
|
233
|
+
const bunSocket = [...router.connections][0];
|
|
234
|
+
const state = router.socketState.get(bunSocket);
|
|
235
|
+
expect(state).toBeDefined();
|
|
236
|
+
expect(state.startupInProgress).toBe(false);
|
|
237
|
+
|
|
238
|
+
sock.destroy();
|
|
239
|
+
await router.stop();
|
|
240
|
+
cleanup();
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test('PostgresManager - stop() nulls socketDir/databaseDir (issue #24)', async () => {
|
|
244
|
+
// Regression test for issue #24: router used to cache stale socketPath
|
|
245
|
+
// pointing to a directory that stop() had already rmSync'd. After fix,
|
|
246
|
+
// stop() nulls socketDir/databaseDir UNCONDITIONALLY so subsequent
|
|
247
|
+
// getSocketPath() returns null (forcing TCP fallback in the router).
|
|
248
|
+
cleanup();
|
|
249
|
+
|
|
250
|
+
const { PostgresManager } = await import('../src/postgres.js');
|
|
251
|
+
const { createLogger } = await import('../src/logger.js');
|
|
252
|
+
const pg = new PostgresManager({
|
|
253
|
+
port: 15543,
|
|
254
|
+
logger: createLogger({ level: 'warn' }),
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
await pg.start();
|
|
258
|
+
const socketPathBeforeStop = pg.getSocketPath();
|
|
259
|
+
expect(socketPathBeforeStop).not.toBeNull();
|
|
260
|
+
expect(fs.existsSync(pg.socketDir)).toBe(true);
|
|
261
|
+
|
|
262
|
+
await pg.stop();
|
|
263
|
+
|
|
264
|
+
// CORE ASSERTION: socketDir must be nulled after stop
|
|
265
|
+
expect(pg.socketDir).toBeNull();
|
|
266
|
+
expect(pg.getSocketPath()).toBeNull();
|
|
267
|
+
// databaseDir nulled only in memory mode (persistent mode keeps user-owned path)
|
|
268
|
+
expect(pg.databaseDir).toBeNull();
|
|
269
|
+
// And the dir on disk must actually be gone
|
|
270
|
+
// (socketPathBeforeStop points inside the deleted socketDir)
|
|
271
|
+
const staleSocketDir = path.dirname(socketPathBeforeStop);
|
|
272
|
+
expect(fs.existsSync(staleSocketDir)).toBe(false);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test('PostgresManager - start()+stop()+start() yields fresh socketDir (issue #24)', async () => {
|
|
276
|
+
// Regression test for issue #24: pgManager.start() called after stop()
|
|
277
|
+
// must produce a FRESH socketDir (different path). Without the fix, a
|
|
278
|
+
// re-entry guard was missing and socketDir could leak across restarts.
|
|
279
|
+
cleanup();
|
|
280
|
+
|
|
281
|
+
const { PostgresManager } = await import('../src/postgres.js');
|
|
282
|
+
const { createLogger } = await import('../src/logger.js');
|
|
283
|
+
const pg = new PostgresManager({
|
|
284
|
+
port: 15544,
|
|
285
|
+
logger: createLogger({ level: 'warn' }),
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
await pg.start();
|
|
289
|
+
const socketDir1 = pg.socketDir;
|
|
290
|
+
expect(socketDir1).not.toBeNull();
|
|
291
|
+
|
|
292
|
+
await pg.stop();
|
|
293
|
+
expect(pg.socketDir).toBeNull();
|
|
294
|
+
|
|
295
|
+
await pg.start();
|
|
296
|
+
const socketDir2 = pg.socketDir;
|
|
297
|
+
expect(socketDir2).not.toBeNull();
|
|
298
|
+
expect(socketDir2).not.toBe(socketDir1);
|
|
299
|
+
expect(fs.existsSync(socketDir2)).toBe(true);
|
|
300
|
+
|
|
301
|
+
await pg.stop();
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test('PostgresManager - double start() is a no-op (issue #24 re-entry guard)', async () => {
|
|
305
|
+
// Without the guard, a second start() would overwrite socketDir/databaseDir
|
|
306
|
+
// and leak the previous tmp dir (the "1,457 stale sock dirs" symptom).
|
|
307
|
+
cleanup();
|
|
308
|
+
|
|
309
|
+
const { PostgresManager } = await import('../src/postgres.js');
|
|
310
|
+
const { createLogger } = await import('../src/logger.js');
|
|
311
|
+
const pg = new PostgresManager({
|
|
312
|
+
port: 15545,
|
|
313
|
+
logger: createLogger({ level: 'warn' }),
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
await pg.start();
|
|
317
|
+
const socketDir1 = pg.socketDir;
|
|
318
|
+
|
|
319
|
+
// Second start() should silently return the same instance without
|
|
320
|
+
// reassigning socketDir/databaseDir.
|
|
321
|
+
const result = await pg.start();
|
|
322
|
+
expect(result).toBe(pg);
|
|
323
|
+
expect(pg.socketDir).toBe(socketDir1);
|
|
324
|
+
|
|
325
|
+
await pg.stop();
|
|
326
|
+
});
|
|
327
|
+
|
|
164
328
|
test('Multi-tenant router - instance reuse', async () => {
|
|
165
329
|
cleanup();
|
|
166
330
|
|
package/.github/release.yml
DELETED
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
# GitHub Release Notes Configuration
|
|
2
|
-
# Automatically generates release notes from merged PRs
|
|
3
|
-
# See: https://docs.github.com/en/repositories/releasing-projects-on-github/automatically-generated-release-notes
|
|
4
|
-
|
|
5
|
-
changelog:
|
|
6
|
-
exclude:
|
|
7
|
-
labels:
|
|
8
|
-
- ignore-for-changelog
|
|
9
|
-
- internal
|
|
10
|
-
- bot
|
|
11
|
-
authors:
|
|
12
|
-
- github-actions[bot]
|
|
13
|
-
categories:
|
|
14
|
-
- title: "Features"
|
|
15
|
-
labels:
|
|
16
|
-
- feature
|
|
17
|
-
- enhancement
|
|
18
|
-
- title: "Bug Fixes"
|
|
19
|
-
labels:
|
|
20
|
-
- bug
|
|
21
|
-
- fix
|
|
22
|
-
- title: "Breaking Changes"
|
|
23
|
-
labels:
|
|
24
|
-
- breaking
|
|
25
|
-
- title: "Dependencies"
|
|
26
|
-
labels:
|
|
27
|
-
- dependencies
|
|
28
|
-
- title: "Other Changes"
|
|
29
|
-
labels:
|
|
30
|
-
- "*"
|