pgserve 1.1.3-rc.6 → 1.1.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.
@@ -0,0 +1,119 @@
1
+ # Windows Debug Context
2
+
3
+ ## RESOLVED (2025-12-12)
4
+
5
+ The Windows router binding issue has been fixed. See "Solution Applied" below.
6
+
7
+ ---
8
+
9
+ ## Original Issue
10
+ PostgreSQL starts successfully on port 9432, but the router/proxy on port 8432 was NOT listening.
11
+ Clients could not connect because 8432 wasn't bound.
12
+
13
+ ## Root Causes Found
14
+
15
+ ### 1. `reusePort: true` - Linux-only feature (PRIMARY CAUSE)
16
+ **Location:** `src/cluster.js:75`
17
+
18
+ The cluster mode uses `Bun.listen({ reusePort: true })` which maps to `SO_REUSEPORT`.
19
+ This socket option is Linux-only and **silently fails on Windows**.
20
+
21
+ ### 2. Auto-enabled cluster mode on multi-core Windows systems
22
+ **Location:** `bin/pglite-server.js:106`
23
+
24
+ Windows systems with multiple CPU cores automatically entered cluster mode,
25
+ triggering the `reusePort` failure.
26
+
27
+ ### 3. TCP port opens before PostgreSQL ready for protocol handshakes
28
+ **Location:** `src/postgres.js:803-821`
29
+
30
+ PostgreSQL was marked "ready" when TCP port opened, but it wasn't actually
31
+ ready for protocol-level handshakes. Admin pool connection timed out.
32
+
33
+ ---
34
+
35
+ ## Solution Applied
36
+
37
+ ### Fix 1: Disable cluster mode on Windows
38
+ **File:** `bin/pglite-server.js:98-108`
39
+ ```javascript
40
+ const isWindows = os.platform() === 'win32';
41
+ cluster: cpuCount > 1 && !isWindows,
42
+ ```
43
+
44
+ ### Fix 2: Add platform check to reusePort
45
+ **File:** `src/cluster.js:72-76`
46
+ ```javascript
47
+ const isWindows = os.platform() === 'win32';
48
+ this.server = Bun.listen({
49
+ reusePort: !isWindows, // SO_REUSEPORT only works on Linux/macOS
50
+ ...
51
+ });
52
+ ```
53
+
54
+ ### Fix 3: Add port binding verification
55
+ **File:** `src/cluster.js:99-102`
56
+ ```javascript
57
+ if (!this.server || !this.server.port) {
58
+ throw new Error(`Failed to bind to port ${this.port} - reusePort may not be supported`);
59
+ }
60
+ ```
61
+
62
+ ### Fix 4: Add Windows readiness delay
63
+ **File:** `src/postgres.js:813-817`
64
+ ```javascript
65
+ if (isWindows) {
66
+ await Bun.sleep(2000); // 2 second delay for Windows
67
+ }
68
+ ```
69
+
70
+ ### Fix 5: Increase admin pool retry for Windows
71
+ **File:** `src/postgres.js:598-599`
72
+ ```javascript
73
+ const maxRetries = isWindows ? 10 : 5;
74
+ const baseDelay = isWindows ? 2000 : 1000;
75
+ ```
76
+
77
+ ---
78
+
79
+ ## Verification
80
+
81
+ After applying fixes, server runs correctly on Windows:
82
+ - Router: `127.0.0.1:7432` ✅
83
+ - PostgreSQL: `127.0.0.1:8432` ✅
84
+ - Database auto-provisioning: ✅
85
+ - Query execution: ✅
86
+
87
+ ```
88
+ Server started successfully!
89
+
90
+ Endpoint: postgresql://127.0.0.1:7432/<database>
91
+ Mode: In-memory (ephemeral)
92
+ PostgreSQL: Port 8432 (internal)
93
+ Auto-create: Enabled
94
+ ```
95
+
96
+ ---
97
+
98
+ ## Test Commands (Run from Windows)
99
+ ```cmd
100
+ # Build binary
101
+ bun build --compile bin/pglite-server.js --outfile dist/pgserve-windows-x64.exe
102
+
103
+ # Start server
104
+ dist\pgserve-windows-x64.exe
105
+
106
+ # Check ports are listening (should see BOTH)
107
+ netstat -an | findstr "LISTEN" | findstr "8432 9432"
108
+
109
+ # Test connection
110
+ psql postgresql://localhost:8432/testdb
111
+ ```
112
+
113
+ ---
114
+
115
+ ## Related Files
116
+ - `bin/pglite-server.js` - Entry point, cluster mode decision
117
+ - `src/cluster.js` - Cluster mode with reusePort
118
+ - `src/router.js` - Single-process mode (works on Windows)
119
+ - `src/postgres.js` - PostgreSQL startup and admin pool
@@ -45,17 +45,14 @@ jobs:
45
45
  include:
46
46
  # Linux x64
47
47
  - platform: linux-x64
48
- pg_pkg: linux-x64
49
48
  output: pgserve-linux-x64
50
49
  os: ubuntu-latest
51
- # macOS ARM64 (Apple Silicon) - must build on macOS for native binaries
50
+ # macOS ARM64 (Apple Silicon)
52
51
  - platform: darwin-arm64
53
- pg_pkg: darwin-arm64
54
52
  output: pgserve-darwin-arm64
55
53
  os: macos-latest
56
54
  # Windows x64 - must build on Windows for --windows-icon
57
55
  - platform: windows-x64
58
- pg_pkg: windows-x64
59
56
  output: pgserve-windows-x64.exe
60
57
  os: windows-latest
61
58
 
@@ -73,33 +70,34 @@ jobs:
73
70
  - name: Install dependencies
74
71
  run: bun install
75
72
 
76
- # Bundle PostgreSQL binaries for standalone exe
77
- - name: Bundle PostgreSQL binaries
78
- run: |
79
- echo "Installing @embedded-postgres/${{ matrix.pg_pkg }}..."
80
- npm install @embedded-postgres/${{ matrix.pg_pkg }}
81
- mkdir -p embedded-postgres
82
- cp -r node_modules/@embedded-postgres/${{ matrix.pg_pkg }}/native/* embedded-postgres/
83
- echo "Bundled PostgreSQL binaries:"
84
- ls -la embedded-postgres/
85
- ls -la embedded-postgres/bin/ || true
86
- shell: bash
87
-
88
- # Windows: native build with custom icon
89
- - name: Build for Windows (with icon)
73
+ # Windows: native build with icon and proper metadata
74
+ - name: Build for Windows (with branding)
90
75
  if: matrix.platform == 'windows-x64'
91
76
  run: |
92
- mkdir -p dist
93
- bun build --compile --windows-icon=assets/icon.ico bin/pglite-server.js --outfile dist/${{ matrix.output }}
94
- ls -lh dist/
95
- shell: bash
77
+ New-Item -ItemType Directory -Force -Path dist | Out-Null
78
+ $RAW_VERSION = node -p "require('./package.json').version"
79
+ # Convert 1.1.3-rc.10 to 1.1.3.10 (Windows requires X.Y.Z.W format)
80
+ $WIN_VERSION = $RAW_VERSION -replace '-rc\.', '.'
81
+ Write-Host "Raw version: $RAW_VERSION -> Windows version: $WIN_VERSION"
82
+ bun build --compile `
83
+ --define BUILD_VERSION="'$RAW_VERSION'" `
84
+ --windows-icon=assets/icon.ico `
85
+ --windows-title="pgserve" `
86
+ --windows-publisher="Namastex Labs" `
87
+ --windows-description="Embedded PostgreSQL Server - Zero config, auto-provision, unlimited connections" `
88
+ --windows-version="$WIN_VERSION" `
89
+ --windows-copyright="Copyright (c) 2025 Namastex Labs" `
90
+ bin/pglite-server.js --outfile dist/${{ matrix.output }}
91
+ Get-ChildItem dist/
92
+ shell: pwsh
96
93
 
97
94
  # Linux/macOS: native build
98
95
  - name: Build for ${{ matrix.platform }}
99
96
  if: matrix.platform != 'windows-x64'
100
97
  run: |
101
98
  mkdir -p dist
102
- bun build --compile bin/pglite-server.js --outfile dist/${{ matrix.output }}
99
+ VERSION=$(node -p "require('./package.json').version")
100
+ bun build --compile --define BUILD_VERSION="'$VERSION'" bin/pglite-server.js --outfile dist/${{ matrix.output }}
103
101
  ls -lh dist/
104
102
 
105
103
  - name: Upload artifact
package/assets/icon.ico CHANGED
Binary file
@@ -94,7 +94,9 @@ FEATURES:
94
94
  */
95
95
  function parseArgs() {
96
96
  // Auto-enable cluster mode on multi-core systems for best performance
97
+ // Note: Cluster mode uses SO_REUSEPORT which is not supported on Windows
97
98
  const cpuCount = os.cpus().length;
99
+ const isWindows = os.platform() === 'win32';
98
100
 
99
101
  const options = {
100
102
  port: 8432,
@@ -103,7 +105,7 @@ function parseArgs() {
103
105
  useRam: false, // Use /dev/shm for true RAM storage (Linux only)
104
106
  logLevel: 'info',
105
107
  autoProvision: true,
106
- cluster: cpuCount > 1, // Auto-enable on multi-core (use --no-cluster to disable)
108
+ cluster: cpuCount > 1 && !isWindows, // Auto-enable on multi-core (disabled on Windows - no SO_REUSEPORT)
107
109
  workers: null, // null = use CPU count
108
110
  syncTo: null, // Sync target PostgreSQL URL
109
111
  syncDatabases: null // Database patterns to sync (comma-separated)
package/eslint.config.js CHANGED
@@ -24,6 +24,8 @@ export default [
24
24
  TextDecoder: 'readonly',
25
25
  TextEncoder: 'readonly',
26
26
  Response: 'readonly',
27
+ // Build-time constants (injected via --define)
28
+ BUILD_VERSION: 'readonly',
27
29
  },
28
30
  },
29
31
  rules: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pgserve",
3
- "version": "1.1.3-rc.6",
3
+ "version": "1.1.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",
package/src/cluster.js CHANGED
@@ -69,10 +69,11 @@ class ClusterRouter extends EventEmitter {
69
69
 
70
70
  // Create TCP server using Bun.listen() for 2-3x throughput
71
71
  const router = this;
72
+ const isWindows = os.platform() === 'win32';
72
73
  this.server = Bun.listen({
73
74
  hostname: this.host,
74
75
  port: this.port,
75
- reusePort: true, // Enable SO_REUSEPORT for multi-worker port sharing (Linux)
76
+ reusePort: !isWindows, // SO_REUSEPORT for multi-worker port sharing (Linux/macOS only)
76
77
  socket: {
77
78
  data(socket, data) {
78
79
  router.handleSocketData(socket, data);
@@ -95,6 +96,11 @@ class ClusterRouter extends EventEmitter {
95
96
  }
96
97
  });
97
98
 
99
+ // Verify port actually bound (detect silent failures on Windows)
100
+ if (!this.server || !this.server.port) {
101
+ throw new Error(`Failed to bind to port ${this.port} - reusePort may not be supported on this platform`);
102
+ }
103
+
98
104
  this.emit('listening');
99
105
  }
100
106
 
package/src/dashboard.js CHANGED
@@ -13,12 +13,18 @@ import { readFileSync } from 'fs';
13
13
  import { join, dirname } from 'path';
14
14
  import { fileURLToPath } from 'url';
15
15
 
16
- // Get package version
16
+ // Get package version - BUILD_VERSION is injected at compile time via --define
17
+ // Falls back to reading package.json for development mode
17
18
  let version = '1.0.0';
18
19
  try {
19
- const __dirname = dirname(fileURLToPath(import.meta.url));
20
- const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
21
- version = pkg.version;
20
+ // Check for build-time injected version first
21
+ if (typeof BUILD_VERSION !== 'undefined') {
22
+ version = BUILD_VERSION;
23
+ } else {
24
+ const __dirname = dirname(fileURLToPath(import.meta.url));
25
+ const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
26
+ version = pkg.version;
27
+ }
22
28
  } catch {
23
29
  // Fallback version
24
30
  }
package/src/postgres.js CHANGED
@@ -13,6 +13,7 @@
13
13
  * - No locale dependency (works on any system)
14
14
  */
15
15
 
16
+ /* global fetch, Bun */
16
17
  import os from 'os';
17
18
  import path from 'path';
18
19
  import fs from 'fs';
@@ -45,71 +46,105 @@ function getBinaryCacheDir() {
45
46
  }
46
47
 
47
48
  /**
48
- * Check if bundled PostgreSQL binaries exist (standalone exe mode)
49
- * When compiled with `bun build --compile`, the embedded-postgres folder
50
- * should be bundled alongside the executable.
51
- * @returns {string|null} Path to bundled binaries or null if not found
52
- */
53
- function getBundledBinaryDir() {
54
- // Check for embedded-postgres folder relative to the module
55
- // This works when binaries are bundled during build
56
- const bundledDir = path.join(import.meta.dirname, '..', 'embedded-postgres');
57
- if (fs.existsSync(path.join(bundledDir, 'bin'))) {
58
- return bundledDir;
59
- }
60
- return null;
61
- }
62
-
63
- /**
64
- * Extract bundled PostgreSQL binaries to cache directory.
65
- * Bun-compiled binaries can read embedded files but cannot execute them directly.
66
- * We extract to ~/.pgserve/bin/{platform}/ for execution.
49
+ * Download and extract PostgreSQL binaries on first run.
50
+ * Downloads from npm registry (@embedded-postgres packages).
67
51
  *
68
- * @returns {Promise<string>} Path to extracted bin directory
52
+ * @returns {Promise<string>} Path to extracted directory
69
53
  */
70
- async function extractBundledBinaries() {
54
+ async function downloadPostgresBinaries() {
71
55
  const platform = os.platform();
72
56
  const cacheDir = getBinaryCacheDir();
73
57
  const cacheBinDir = path.join(cacheDir, 'bin');
74
58
  const initdbName = platform === 'win32' ? 'initdb.exe' : 'initdb';
75
59
  const postgresName = platform === 'win32' ? 'postgres.exe' : 'postgres';
76
60
 
77
- // Check if already extracted
61
+ // Check if already downloaded
78
62
  if (fs.existsSync(path.join(cacheBinDir, initdbName)) &&
79
63
  fs.existsSync(path.join(cacheBinDir, postgresName))) {
80
64
  return cacheDir;
81
65
  }
82
66
 
83
- // Get bundled directory
84
- const bundledDir = getBundledBinaryDir();
85
- if (!bundledDir) {
86
- return null;
67
+ const platformKey = getPlatformKey();
68
+ const pkgName = `@embedded-postgres/${platformKey}`;
69
+ const pkgVersion = '17.7.0-beta.15';
70
+
71
+ console.log(`[pgserve] PostgreSQL binaries not found.`);
72
+ console.log(`[pgserve] Downloading ${pkgName}@${pkgVersion}...`);
73
+
74
+ // Get tarball URL from npm registry
75
+ const registryUrl = `https://registry.npmjs.org/${pkgName}`;
76
+ const registryRes = await fetch(registryUrl);
77
+ if (!registryRes.ok) {
78
+ throw new Error(`Failed to fetch package info: ${registryRes.status}`);
79
+ }
80
+ const pkgInfo = await registryRes.json();
81
+ const tarballUrl = pkgInfo.versions[pkgVersion]?.dist?.tarball;
82
+
83
+ if (!tarballUrl) {
84
+ throw new Error(`Version ${pkgVersion} not found for ${pkgName}`);
87
85
  }
88
86
 
89
- console.log('[pgserve] Extracting PostgreSQL binaries...');
87
+ // Download tarball
88
+ console.log(`[pgserve] Downloading from npm registry...`);
89
+ const tarballRes = await fetch(tarballUrl);
90
+ if (!tarballRes.ok) {
91
+ throw new Error(`Failed to download tarball: ${tarballRes.status}`);
92
+ }
90
93
 
91
- // Create cache directory
94
+ const tarballBuffer = await tarballRes.arrayBuffer();
95
+ const tarballSize = (tarballBuffer.byteLength / 1024 / 1024).toFixed(1);
96
+ console.log(`[pgserve] Downloaded ${tarballSize} MB`);
97
+
98
+ // Create temp file for tarball
99
+ const tempDir = path.join(os.tmpdir(), `pgserve-download-${Date.now()}`);
100
+ fs.mkdirSync(tempDir, { recursive: true });
101
+ const tarballPath = path.join(tempDir, 'package.tgz');
102
+ fs.writeFileSync(tarballPath, Buffer.from(tarballBuffer));
103
+
104
+ // Extract tarball using tar (available on all platforms via bun/node)
105
+ console.log(`[pgserve] Extracting binaries...`);
92
106
  fs.mkdirSync(cacheDir, { recursive: true });
93
107
 
94
- // Copy bundled binaries to cache
95
- // Use recursive copy to get bin/, lib/, share/ directories
96
- await copyRecursive(bundledDir, cacheDir);
108
+ // Use Bun.spawn for extraction
109
+ const extractProc = Bun.spawn(['tar', '-xzf', tarballPath, '-C', tempDir], {
110
+ stdout: 'pipe',
111
+ stderr: 'pipe'
112
+ });
113
+ await extractProc.exited;
114
+
115
+ // Copy native/* to cache dir
116
+ const nativeDir = path.join(tempDir, 'package', 'native');
117
+ if (!fs.existsSync(nativeDir)) {
118
+ throw new Error('Extracted package does not contain native/ directory');
119
+ }
120
+
121
+ // Copy files recursively
122
+ await copyDirRecursive(nativeDir, cacheDir);
97
123
 
98
124
  // Make executables executable (Unix only)
99
125
  if (platform !== 'win32') {
100
126
  const binDir = path.join(cacheDir, 'bin');
101
- const files = fs.readdirSync(binDir);
102
- for (const file of files) {
103
- const filePath = path.join(binDir, file);
104
- try {
105
- fs.chmodSync(filePath, 0o755);
106
- } catch {
107
- // Ignore permission errors for non-executables
127
+ if (fs.existsSync(binDir)) {
128
+ const files = fs.readdirSync(binDir);
129
+ for (const file of files) {
130
+ const filePath = path.join(binDir, file);
131
+ try {
132
+ fs.chmodSync(filePath, 0o755);
133
+ } catch {
134
+ // Ignore
135
+ }
108
136
  }
109
137
  }
110
138
  }
111
139
 
112
- console.log(`[pgserve] Binaries extracted to ${cacheDir}`);
140
+ // Cleanup temp
141
+ try {
142
+ fs.rmSync(tempDir, { recursive: true, force: true });
143
+ } catch {
144
+ // Ignore cleanup errors
145
+ }
146
+
147
+ console.log(`[pgserve] PostgreSQL binaries installed to ${cacheDir}`);
113
148
  return cacheDir;
114
149
  }
115
150
 
@@ -118,7 +153,7 @@ async function extractBundledBinaries() {
118
153
  * @param {string} src - Source directory
119
154
  * @param {string} dest - Destination directory
120
155
  */
121
- async function copyRecursive(src, dest) {
156
+ async function copyDirRecursive(src, dest) {
122
157
  const entries = fs.readdirSync(src, { withFileTypes: true });
123
158
 
124
159
  for (const entry of entries) {
@@ -127,11 +162,9 @@ async function copyRecursive(src, dest) {
127
162
 
128
163
  if (entry.isDirectory()) {
129
164
  fs.mkdirSync(destPath, { recursive: true });
130
- await copyRecursive(srcPath, destPath);
165
+ await copyDirRecursive(srcPath, destPath);
131
166
  } else {
132
- // Read and write file (works with Bun's virtual filesystem)
133
- const content = fs.readFileSync(srcPath);
134
- fs.writeFileSync(destPath, content);
167
+ fs.copyFileSync(srcPath, destPath);
135
168
  }
136
169
  }
137
170
  }
@@ -233,22 +266,20 @@ async function getBinaryPaths() {
233
266
  return { initdb: cachedInitdb, postgres: cachedPostgres, binDir: cacheBinDir, libDir };
234
267
  }
235
268
 
236
- // Priority 2: Check for bundled binaries and extract if found (standalone exe mode)
237
- const bundledDir = getBundledBinaryDir();
238
- if (bundledDir) {
239
- const extractedDir = await extractBundledBinaries();
240
- if (extractedDir) {
241
- const extractedBinDir = path.join(extractedDir, 'bin');
242
- const extractedInitdb = path.join(extractedBinDir, 'initdb' + exeSuffix);
243
- const extractedPostgres = path.join(extractedBinDir, 'postgres' + exeSuffix);
244
-
245
- if (fs.existsSync(extractedInitdb) && fs.existsSync(extractedPostgres)) {
246
- const libDir = path.join(extractedDir, 'lib');
247
- if ((platform === 'linux' || platform === 'darwin') && fs.existsSync(libDir)) {
248
- ensureLibrarySymlinks(libDir, platform);
249
- }
250
- return { initdb: extractedInitdb, postgres: extractedPostgres, binDir: extractedBinDir, libDir };
269
+ // Priority 2: Download binaries if not found (standalone exe mode)
270
+ // This downloads from npm registry on first run
271
+ const downloadedDir = await downloadPostgresBinaries();
272
+ if (downloadedDir) {
273
+ const downloadedBinDir = path.join(downloadedDir, 'bin');
274
+ const downloadedInitdb = path.join(downloadedBinDir, 'initdb' + exeSuffix);
275
+ const downloadedPostgres = path.join(downloadedBinDir, 'postgres' + exeSuffix);
276
+
277
+ if (fs.existsSync(downloadedInitdb) && fs.existsSync(downloadedPostgres)) {
278
+ const libDir = path.join(downloadedDir, 'lib');
279
+ if ((platform === 'linux' || platform === 'darwin') && fs.existsSync(libDir)) {
280
+ ensureLibrarySymlinks(libDir, platform);
251
281
  }
282
+ return { initdb: downloadedInitdb, postgres: downloadedPostgres, binDir: downloadedBinDir, libDir };
252
283
  }
253
284
  }
254
285
 
@@ -544,9 +575,11 @@ export class PostgresManager {
544
575
  */
545
576
  async _initAdminPool() {
546
577
  const { SQL } = await import('bun');
578
+ const isWindows = process.platform === 'win32';
547
579
 
548
580
  // Bun.sql config - uses TCP connections (Unix sockets not directly supported)
549
581
  // This is fine for admin queries (low volume, local connection)
582
+ // Windows needs longer timeout due to higher network stack latency
550
583
  this.adminPool = new SQL({
551
584
  hostname: '127.0.0.1',
552
585
  port: this.port,
@@ -555,16 +588,38 @@ export class PostgresManager {
555
588
  password: this.password,
556
589
  max: 5, // Small pool - only for CREATE DATABASE operations
557
590
  idleTimeout: 30,
558
- connectionTimeout: 5,
591
+ connectionTimeout: isWindows ? 15 : 5,
559
592
  });
560
593
 
561
- // Verify connection is working with a simple query
562
- await this.adminPool`SELECT 1`;
594
+ // Verify connection with retry logic
595
+ // TCP port being open doesn't mean PostgreSQL protocol is ready
596
+ // This handles the race condition on Windows where the port binds
597
+ // before the server is fully ready to accept protocol handshakes
598
+ const maxRetries = isWindows ? 10 : 5; // More retries on Windows
599
+ const baseDelay = isWindows ? 2000 : 1000; // Longer delay on Windows
563
600
 
564
- this.logger.debug({
565
- host: '127.0.0.1',
566
- maxConnections: 5
567
- }, 'Admin connection pool initialized (Bun.sql)');
601
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
602
+ try {
603
+ await this.adminPool`SELECT 1`;
604
+ this.logger.debug({
605
+ host: '127.0.0.1',
606
+ maxConnections: 5,
607
+ attempt
608
+ }, 'Admin connection pool initialized (Bun.sql)');
609
+ return; // Success
610
+ } catch (err) {
611
+ if (attempt === maxRetries) {
612
+ throw new Error(`Failed to initialize admin pool after ${maxRetries} attempts: ${err.message}`);
613
+ }
614
+ this.logger.debug({
615
+ attempt,
616
+ maxRetries,
617
+ error: err.message
618
+ }, 'Admin pool connection failed, retrying...');
619
+ // Exponential backoff: 1s, 2s, 3s, 4s
620
+ await new Promise(resolve => setTimeout(resolve, baseDelay * attempt));
621
+ }
622
+ }
568
623
  }
569
624
 
570
625
  /**
@@ -636,8 +691,24 @@ export class PostgresManager {
636
691
 
637
692
  let started = false;
638
693
  let startupOutput = '';
694
+ let processExited = false;
695
+ let portBindingSeen = false;
696
+ const portStr = this.port.toString();
697
+
698
+ // Hybrid startup detection:
699
+ // 1. TCP connection polling (works on Linux/macOS)
700
+ // 2. Log-based detection (fallback for Windows where Bun.connect may fail)
701
+ // Whichever succeeds first wins
639
702
 
640
- // Read stderr in streaming fashion to detect startup
703
+ const markReady = (method) => {
704
+ if (!started) {
705
+ started = true;
706
+ this.logger.info({ port: this.port, method }, 'PostgreSQL ready');
707
+ resolve();
708
+ }
709
+ };
710
+
711
+ // Read stderr - detect port binding in logs (locale-independent: just look for port number)
641
712
  const readStream = async (stream) => {
642
713
  const reader = stream.getReader();
643
714
  const decoder = new TextDecoder();
@@ -649,11 +720,16 @@ export class PostgresManager {
649
720
  startupOutput += message;
650
721
  this.logger.debug({ pgOutput: message.trim() }, 'PostgreSQL output');
651
722
 
652
- // Check for ready message
653
- if (!started && (message.includes('database system is ready to accept connections') ||
654
- message.includes('ready to accept connections'))) {
655
- started = true;
656
- resolve();
723
+ // Detect port binding - look for our port number in log output
724
+ // This is locale-independent (numbers are universal)
725
+ if (!portBindingSeen && message.includes(portStr)) {
726
+ portBindingSeen = true;
727
+ // Give PostgreSQL 500ms after port binding to finish startup
728
+ setTimeout(() => {
729
+ if (!started && !processExited) {
730
+ markReady('log-port-binding');
731
+ }
732
+ }, 500);
657
733
  }
658
734
  }
659
735
  } catch {
@@ -667,16 +743,97 @@ export class PostgresManager {
667
743
 
668
744
  // Handle process exit
669
745
  this.process.exited.then((code) => {
746
+ processExited = true;
670
747
  if (!started) {
671
748
  reject(new Error(`PostgreSQL exited with code ${code} before starting: ${startupOutput}`));
672
749
  }
673
750
  this.process = null;
674
751
  });
675
752
 
676
- // Timeout after 30 seconds
753
+ // Method 1: TCP connection polling (preferred, works on Linux/macOS)
754
+ const tryConnect = () => {
755
+ return new Promise((resolveConn, rejectConn) => {
756
+ let resolved = false;
757
+ const timeout = setTimeout(() => {
758
+ if (!resolved) {
759
+ resolved = true;
760
+ rejectConn(new Error('Connection timeout'));
761
+ }
762
+ }, 500);
763
+
764
+ Bun.connect({
765
+ hostname: '127.0.0.1',
766
+ port: this.port,
767
+ socket: {
768
+ open(socket) {
769
+ if (!resolved) {
770
+ resolved = true;
771
+ clearTimeout(timeout);
772
+ socket.end();
773
+ resolveConn(true);
774
+ }
775
+ },
776
+ connectError(_socket, error) {
777
+ if (!resolved) {
778
+ resolved = true;
779
+ clearTimeout(timeout);
780
+ rejectConn(error);
781
+ }
782
+ },
783
+ error(_socket, error) {
784
+ if (!resolved) {
785
+ resolved = true;
786
+ clearTimeout(timeout);
787
+ rejectConn(error);
788
+ }
789
+ },
790
+ data() {},
791
+ close() {},
792
+ },
793
+ }).catch((err) => {
794
+ if (!resolved) {
795
+ resolved = true;
796
+ clearTimeout(timeout);
797
+ rejectConn(err);
798
+ }
799
+ });
800
+ });
801
+ };
802
+
803
+ const pollConnection = async () => {
804
+ const startTime = Date.now();
805
+ const timeoutMs = 30000;
806
+ const isWindows = process.platform === 'win32';
807
+
808
+ while (Date.now() - startTime < timeoutMs && !started) {
809
+ if (processExited) return;
810
+
811
+ try {
812
+ await tryConnect();
813
+ // On Windows, TCP port opens before PostgreSQL is fully ready for protocol handshakes
814
+ // Add delay to let PostgreSQL complete its startup sequence
815
+ if (isWindows) {
816
+ await Bun.sleep(2000); // 2 second delay for Windows
817
+ }
818
+ markReady('tcp');
819
+ return;
820
+ } catch {
821
+ await Bun.sleep(200);
822
+ }
823
+ }
824
+ };
825
+
826
+ // Start TCP polling (log detection is handled inline above via setTimeout)
827
+ pollConnection();
828
+
829
+ // Overall timeout with helpful error for Windows firewall
677
830
  setTimeout(() => {
678
- if (!started) {
679
- reject(new Error(`PostgreSQL startup timed out after 30s. Output: ${startupOutput}`));
831
+ if (!started && !processExited) {
832
+ const isWindows = process.platform === 'win32';
833
+ const hint = isWindows
834
+ ? '\n\nOn Windows, this may be caused by Windows Firewall blocking localhost connections.\nTry: netsh advfirewall firewall add rule name="pgserve" dir=in action=allow protocol=TCP localport=' + this.port
835
+ : '';
836
+ reject(new Error(`PostgreSQL startup timed out after 30s.${hint}\n\nOutput: ${startupOutput}`));
680
837
  }
681
838
  }, 30000);
682
839
  });