node-firebird 2.3.1 → 2.3.2

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/README.md CHANGED
@@ -72,6 +72,7 @@ options.wireCompression = false; // set to true to enable firebird compression o
72
72
  options.wireCrypt = Firebird.WIRE_CRYPT_ENABLE; // default; set to Firebird.WIRE_CRYPT_DISABLE to disable wire encryption (FB >= 3)
73
73
  options.pluginName = undefined; // optional, auto-negotiated; can be set to Firebird.AUTH_PLUGIN_SRP256, Firebird.AUTH_PLUGIN_SRP, or Firebird.AUTH_PLUGIN_LEGACY
74
74
  options.dbCryptConfig = undefined; // optional; database encryption key for encrypted databases. Use 'base64:<value>' for base64-encoded keys or plain text
75
+ options.connectTimeout = 10000; // optional; timeout in ms for a single pool.get() attach operation (default: no timeout)
75
76
  ```
76
77
 
77
78
  ### Classic
@@ -109,6 +110,60 @@ pool.get(function (err, db) {
109
110
  pool.destroy();
110
111
  ```
111
112
 
113
+ #### Advanced Pooling Features
114
+
115
+ The pool implementation includes several safeguards for reliability:
116
+
117
+ 1. **Connection Timeout**: Use `options.connectTimeout` to prevent the pool from hanging if a server accepts the TCP connection but fails to respond to the Firebird wire protocol (e.g., during high load or authentication stalls).
118
+ 2. **Pool Destruction**: Calling `pool.destroy()` now immediately drains the `pending` queue, notifying all waiting callers with an error. It also prevents any further `pool.get()` calls.
119
+ 3. **Slot Recovery**: If a connection attempt times out, the pool slot is correctly freed so subsequent requests can be served. Late-arriving connections are automatically discarded to prevent resource leaks.
120
+
121
+ #### Pool Lifecycle State Diagram
122
+
123
+ ```mermaid
124
+ stateDiagram-v2
125
+ [*] --> Active
126
+ Active --> Destroying: pool.destroy()
127
+ Destroying --> Destroyed: all connections detached
128
+ Destroyed --> [*]
129
+
130
+ state Active {
131
+ [*] --> Idle
132
+ Idle --> Creating: pool.get() [no idle db]
133
+ Creating --> InUse: attach() success
134
+ Creating --> Idle: attach() failure/timeout
135
+ Idle --> InUse: pool.get() [idle db exists]
136
+ InUse --> Idle: db.detach()
137
+ }
138
+
139
+ state Destroying {
140
+ [*] --> DrainingPending
141
+ DrainingPending --> DetachingIdle
142
+ DetachingIdle --> WaitingForInUse
143
+ WaitingForInUse --> [*]
144
+ }
145
+ ```
146
+
147
+ #### Connect Timeout Sequence
148
+
149
+ ```mermaid
150
+ sequenceDiagram
151
+ participant User
152
+ participant Pool
153
+ participant Firebird
154
+ User->>Pool: pool.get()
155
+ Pool->>Pool: increment _creating
156
+ Pool->>Pool: start timer (connectTimeout)
157
+ Pool->>Firebird: attach(options)
158
+ Note over Firebird: Server accepts TCP but hangs
159
+ Pool-->>Pool: timer expires
160
+ Pool->>Pool: decrement _creating
161
+ Pool-->>User: callback(Error: Connection timeout)
162
+ Note over Firebird: Server eventually responds
163
+ Firebird-->>Pool: attach callback(db)
164
+ Pool->>Firebird: db.detach() (discard late connection)
165
+ ```
166
+
112
167
  ## Database object (db)
113
168
 
114
169
  ### Database Methods
package/lib/index.d.ts CHANGED
@@ -118,6 +118,16 @@ declare module 'node-firebird' {
118
118
  wireCompression?: boolean;
119
119
  pluginName?: string;
120
120
  dbCryptConfig?: string; // Database encryption key callback config (base64: prefix for base64, or plain string)
121
+ /**
122
+ * Timeout in milliseconds for a single pool.get() attach operation.
123
+ * If attach() does not complete within this time the slot is freed,
124
+ * the caller receives an error, and any late-arriving connection is
125
+ * safely discarded. Set to 0 or omit to disable (default: no timeout).
126
+ *
127
+ * Recommended value: 5000–10000 ms depending on network latency and
128
+ * expected Firebird server response time under load.
129
+ */
130
+ connectTimeout?: number;
121
131
  }
122
132
 
123
133
  export interface SvcMgrOptions extends Options {
package/lib/pool.js CHANGED
@@ -6,17 +6,23 @@
6
6
 
7
7
  class Pool {
8
8
  constructor(attach, max, options) {
9
- this.attach = attach;
10
- this.internaldb = []; // connection created by the pool (for destroy)
11
- this.pooldb = []; // available connection in the pool
12
- this.dbinuse = 0; // connection currently in use into the pool
13
- this._creating = 0; // connections currently being created
14
- this.max = max || 4;
15
- this.pending = [];
16
- this.options = options;
9
+ this.attach = attach;
10
+ this.internaldb = []; // connections created by the pool (for destroy)
11
+ this.pooldb = []; // connections available in the pool (idle)
12
+ this.dbinuse = 0; // connections currently handed out to callers
13
+ this._creating = 0; // connections currently being created (attach in flight)
14
+ this.max = max || 4;
15
+ this.pending = []; // callbacks waiting for a free slot
16
+ this.options = options;
17
+ this._destroyed = false; // true after destroy() — prevents further use
17
18
  }
18
19
 
19
20
  get(callback) {
21
+ // [Fix 2] Reject immediately if the pool has already been destroyed.
22
+ if (this._destroyed) {
23
+ callback(new Error('Pool has been destroyed'), null);
24
+ return this;
25
+ }
20
26
  var self = this;
21
27
  self.pending.push(callback);
22
28
  self.check();
@@ -25,6 +31,10 @@ class Pool {
25
31
 
26
32
  check() {
27
33
  var self = this;
34
+
35
+ // [Fix 2] Do not serve requests on a destroyed pool.
36
+ if (self._destroyed) return self;
37
+
28
38
  if ((self.dbinuse + self._creating) >= self.max)
29
39
  return self;
30
40
 
@@ -32,12 +42,65 @@ class Pool {
32
42
  if (!cb)
33
43
  return self;
34
44
  if (self.pooldb.length) {
45
+ // Idle connection available — hand it out immediately.
35
46
  self.dbinuse++;
36
47
  cb(null, self.pooldb.shift());
37
48
  } else {
49
+ // No idle connection — create a new one via attach().
38
50
  self._creating++;
51
+
52
+ var timedOut = false;
53
+ var timer = null;
54
+
55
+ // [Fix 1] Optional per-attach timeout.
56
+ // If attach() does not call back within connectTimeout ms (e.g. because
57
+ // the server accepted TCP but stalled on the Firebird wire protocol), we
58
+ // free the slot and notify the caller. Any connection that arrives late
59
+ // is discarded in the attach() callback below.
60
+ if (self.options.connectTimeout > 0) {
61
+ timer = setTimeout(function () {
62
+ timedOut = true;
63
+ self._creating--;
64
+ cb(new Error(
65
+ 'Connection timeout after ' + self.options.connectTimeout + 'ms'
66
+ ), null);
67
+ // Free the slot so the next pending request can be served.
68
+ setImmediate(function () { self.check(); });
69
+ }, self.options.connectTimeout);
70
+ }
71
+
39
72
  this.attach(self.options, function (err, db) {
73
+
74
+ // [Fix 1] Timeout already fired — discard this late connection.
75
+ // Without this guard the socket would stay open until the OS-level
76
+ // TCP timeout (potentially minutes), leaking a file descriptor.
77
+ if (timedOut) {
78
+ if (db) {
79
+ try {
80
+ // _pooled = false forces a real op_detach / socket close
81
+ // instead of a silent pool-return emit.
82
+ if (db.connection) db.connection._pooled = false;
83
+ db.detach();
84
+ } catch (e) { /* ignore cleanup errors */ }
85
+ }
86
+ return;
87
+ }
88
+
89
+ if (timer) clearTimeout(timer);
40
90
  self._creating--;
91
+
92
+ // [Fix 3] Pool was destroyed while attach() was in flight.
93
+ if (self._destroyed) {
94
+ if (db) {
95
+ try {
96
+ if (db.connection) db.connection._pooled = false;
97
+ db.detach();
98
+ } catch (e) { /* ignore cleanup errors */ }
99
+ }
100
+ cb(new Error('Pool has been destroyed'), null);
101
+ return;
102
+ }
103
+
41
104
  if (!err) {
42
105
  self.dbinuse++;
43
106
  self.internaldb.push(db);
@@ -45,7 +108,7 @@ class Pool {
45
108
  // also in pool (could be a twice call to detach)
46
109
  if (self.pooldb.indexOf(db) !== -1 || self.internaldb.indexOf(db) === -1)
47
110
  return;
48
- // if not usable don't put in again in the pool and remove reference on it
111
+ // if not usable don't put it back in the pool
49
112
  if (db.connection._isClosed || db.connection._isDetach || db.connection._pooled === false)
50
113
  self.internaldb.splice(self.internaldb.indexOf(db), 1);
51
114
  else
@@ -68,6 +131,16 @@ class Pool {
68
131
 
69
132
  destroy(callback) {
70
133
  var self = this;
134
+ self._destroyed = true;
135
+
136
+ // [Fix 4] Drain pending callbacks so callers are not left hanging.
137
+ // This is critical when destroy() is called as a recovery measure after
138
+ // a timeout: without draining, every concurrent pool.get() that had not
139
+ // yet received a slot would hang until the process exits.
140
+ var draining = self.pending.splice(0);
141
+ draining.forEach(function (cb) {
142
+ try { cb(new Error('Pool is being destroyed'), null); } catch (e) { /* ignore */ }
143
+ });
71
144
 
72
145
  var connectionCount = this.internaldb.length;
73
146
 
@@ -100,6 +173,11 @@ class Pool {
100
173
  self.pooldb.splice(_db_in_pool, 1);
101
174
  db.connection._pooled = false;
102
175
  db.detach(detachCallback);
176
+ } else {
177
+ // [Fix 5] Connection is currently in use (dbinuse > 0).
178
+ // The caller is responsible for releasing it via detach().
179
+ // Count it down here so the destroy() callback is not blocked forever.
180
+ detachCallback();
103
181
  }
104
182
  });
105
183
  }
@@ -1976,6 +1976,7 @@ function decodeResponse(data, callback, cnx, lowercase_keys, cb) {
1976
1976
  // Database encryption key callback
1977
1977
  // Read server data (plugin data sent by server)
1978
1978
  var serverPluginData = data.readArray();
1979
+ data.readInt(); // p_cc_reply
1979
1980
 
1980
1981
  // Get client response from dbCryptConfig option
1981
1982
  var clientPluginData = parseDbCryptConfig(cnx.options.dbCryptConfig);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-firebird",
3
- "version": "2.3.1",
3
+ "version": "2.3.2",
4
4
  "description": "Pure JavaScript and Asynchronous Firebird client for Node.js.",
5
5
  "keywords": [
6
6
  "firebird",
package/poc/README.md ADDED
@@ -0,0 +1,160 @@
1
+ # node-firebird pool — Bug PoC & Fix
2
+
3
+ Proof of concept for three bugs in `lib/pool.js` (node-firebird ≥ 2.x).
4
+ No real Firebird server is needed — a tiny fake TCP server simulates the failure scenario.
5
+
6
+ ---
7
+
8
+ ## The bugs
9
+
10
+ ### Bug 1 — `_creating` stuck forever (critical)
11
+
12
+ **File:** `lib/pool.js` → `Pool.check()`
13
+
14
+ When no idle connection exists, `check()` calls `this.attach()` and increments `_creating`. The counter is only decremented **inside the `attach()` callback**. If the server accepts TCP but never responds to the Firebird wire protocol (e.g. during SRP authentication under load), the callback never fires. `_creating` stays elevated permanently, the slot is lost, and every `pool.get()` caller waits forever.
15
+
16
+ ```
17
+ // original code
18
+ self._creating++;
19
+ this.attach(self.options, function (err, db) {
20
+ self._creating--; // ← never reached if attach() hangs
21
+ cb(err, db); // ← caller never notified
22
+ });
23
+ ```
24
+
25
+ **Real-world log signature:**
26
+ ```
27
+ *ERRO* Timeout (15000ms) ao aguardar conexão para tenant X.
28
+ Estado do pool: {"dbinuse":0,"_creating":1,"pending":0,"idle":4}
29
+ ```
30
+
31
+ **Fix:** `connectTimeout` option — a `setTimeout` wraps the `attach()` call. On expiry, `_creating` is decremented and the caller receives an error immediately. A late-arriving connection (if attach eventually completes) is discarded safely.
32
+
33
+ ---
34
+
35
+ ### Bug 2 — `destroy()` does not drain the `pending` queue
36
+
37
+ **File:** `lib/pool.js` → `Pool.destroy()`
38
+
39
+ Callbacks queued in `this.pending` (waiting for a free pool slot) are silently abandoned when `destroy()` is called. Their callers hang indefinitely — there is no error, no timeout, nothing. This is especially harmful when `destroy()` is used as a recovery strategy after a stuck pool.
40
+
41
+ **Fix:** `this.pending.splice(0)` at the start of `destroy()` calls every waiting callback with `Error('Pool is being destroyed')` before any connection is closed.
42
+
43
+ ---
44
+
45
+ ### Bug 3 — `pool.get()` after `pool.destroy()` silently accumulates
46
+
47
+ **File:** `lib/pool.js` → `Pool.get()` and `Pool.check()`
48
+
49
+ There is no `_destroyed` flag. Calling `pool.get()` on an already-destroyed pool pushes the callback into `this.pending` and calls `check()`. `check()` returns early because all slots are gone, but the callback is never served. The caller hangs forever.
50
+
51
+ **Fix:** `_destroyed = true` is set in `destroy()`. Both `get()` and `check()` check it and reject immediately with `Error('Pool has been destroyed')`.
52
+
53
+ ---
54
+
55
+ ### Bug 4 (minor) — `destroy()` callback never fires when connections are in use
56
+
57
+ **File:** `lib/pool.js` → `Pool.destroy()`
58
+
59
+ The original `destroy()` only calls `detachCallback()` for connections found in `pooldb` (idle). Connections currently in use (`dbinuse > 0`) fall through the `forEach` without decrementing `connectionCount`, so it never reaches zero and the `destroy()` completion callback is never invoked.
60
+
61
+ **Fix:** The `else` branch counts in-use connections down without forcing a detach — releasing them remains the caller's responsibility.
62
+
63
+ ---
64
+
65
+ ## Files
66
+
67
+ | File | Purpose |
68
+ |------|---------|
69
+ | `helpers.js` | Shared logging and fake-server factory |
70
+ | `reproduce.js` | Demonstrates all bugs — exits with code **1** |
71
+ | `reproduce-fixed.js` | Validates all fixes — exits with code **0** |
72
+ | `pool-patched.js` | Drop-in replacement for `lib/pool.js` |
73
+
74
+ ---
75
+
76
+ ## How to run
77
+
78
+ ```bash
79
+ npm install
80
+
81
+ # Show the bugs (process hangs → forced exit 1)
82
+ node reproduce.js
83
+
84
+ # Show the fixes (process exits cleanly → exit 0)
85
+ node reproduce-fixed.js
86
+ ```
87
+
88
+ ---
89
+
90
+ ## Expected output
91
+
92
+ ### `node reproduce.js` (bugs present)
93
+
94
+ ```
95
+ [fake-server] Listening on 127.0.0.1:13050 ...
96
+ [BUG-1] pool.get() → TCP connects, Firebird protocol hangs, callback NEVER fires
97
+ [pool-state] creating=1 idle=0 inuse=0 pending=0 destroyed=(no flag)
98
+ [pool-state] creating=1 idle=0 inuse=0 pending=0 destroyed=(no flag)
99
+ [BUG-2] Queuing a second get() in pending, then calling destroy()
100
+ [BUG-2] Before destroy — creating=1 idle=0 inuse=0 pending=1 ...
101
+ [BUG-2] After destroy — creating=1 idle=0 inuse=0 pending=1 ... ← pending NOT drained
102
+ [BUG-2] ↑ pending callback was NEVER called — Promise hangs forever
103
+ [BUG-3] pool.get() AFTER pool.destroy() — should be rejected immediately
104
+ [BUG-3] After post-destroy get — creating=1 idle=0 inuse=0 pending=2 ...
105
+ [SUMMARY] Bug 1 confirmed : _creating=1, permanently stuck (slot lost)
106
+ [SUMMARY] Bug 2 confirmed : pending callback from before destroy never fired
107
+ [SUMMARY] Bug 3 confirmed : post-destroy get silently accumulated in pending
108
+ ```
109
+
110
+ ### `node reproduce-fixed.js` (fixes applied)
111
+
112
+ ```
113
+ [fake-server] Listening on 127.0.0.1:13051 ...
114
+ [FIX-1] pool.get() — attach() will hang, timeout fires after 1500 ms
115
+ [pool-state] creating=1 idle=0 inuse=0 pending=0 destroyed=false
116
+ [pool-state] creating=1 idle=0 inuse=0 pending=0 destroyed=false
117
+ [FIX-1 ✓ OK] callback received error (expected): "Connection timeout after 1500ms"
118
+ [pool-state] creating=0 idle=0 inuse=0 pending=0 destroyed=false ← back to 0 ✓
119
+ [FIX-4] Queuing a get() in pending, then destroying pool immediately
120
+ [FIX-4 ✓ OK] pending callback received error: "Pool is being destroyed" ← immediate ✓
121
+ [FIX-4] After destroy — creating=0 idle=0 inuse=0 pending=0 destroyed=true
122
+ [FIX-2] pool.get() AFTER pool.destroy() — must be rejected immediately
123
+ [FIX-2 ✓ OK] immediately rejected: "Pool has been destroyed" ← no accumulation ✓
124
+ [SUMMARY] Fix 1 confirmed : _creating returned to 0 after 1500 ms timeout
125
+ [SUMMARY] Fix 2 confirmed : post-destroy get() rejected immediately (_destroyed flag)
126
+ [SUMMARY] Fix 4 confirmed : pending callback drained synchronously by destroy()
127
+ ```
128
+
129
+ ---
130
+
131
+ ## Proposed change to `lib/index.d.ts`
132
+
133
+ Add `connectTimeout` to the `Options` interface:
134
+
135
+ ```typescript
136
+ export interface Options {
137
+ // ... existing fields ...
138
+
139
+ /**
140
+ * Timeout in milliseconds for a single pool.get() attach operation.
141
+ * If attach() does not complete within this time the slot is freed,
142
+ * the caller receives an error, and any late-arriving connection is
143
+ * safely discarded. Set to 0 or omit to disable (default: no timeout).
144
+ *
145
+ * Recommended value: 5000–10000 ms depending on network latency and
146
+ * expected Firebird server response time under load.
147
+ */
148
+ connectTimeout?: number;
149
+ }
150
+ ```
151
+
152
+ ---
153
+
154
+ ## Backward compatibility
155
+
156
+ All changes are **fully backward-compatible**:
157
+
158
+ - `connectTimeout` is optional and defaults to disabled (`undefined > 0` is `false`)
159
+ - `_destroyed` defaults to `false` — existing code paths are unchanged
160
+ - `destroy()` draining of `pending` is new behaviour but harmless for callers that did not add pending requests before calling `destroy()`
package/poc/helpers.js ADDED
@@ -0,0 +1,59 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Shared helpers used by reproduce.js and reproduce-fixed.js.
5
+ */
6
+
7
+ const net = require('net');
8
+
9
+ /** Timestamp prefix for log lines (HH:MM:SS.mmm) */
10
+ function ts() {
11
+ return new Date().toISOString().slice(11, 23);
12
+ }
13
+
14
+ /** Structured log with fixed-width tag */
15
+ function log(tag, msg) {
16
+ console.log(`[${ts()}] ${tag.padEnd(22)} ${msg}`);
17
+ }
18
+
19
+ /** Compact pool state string */
20
+ function poolState(pool) {
21
+ return (
22
+ `creating=${pool._creating} ` +
23
+ `idle=${pool.pooldb.length} ` +
24
+ `inuse=${pool.dbinuse} ` +
25
+ `pending=${pool.pending.length} ` +
26
+ `destroyed=${pool._destroyed ?? '(no flag)'}`
27
+ );
28
+ }
29
+
30
+ /**
31
+ * Starts a TCP server that accepts connections but NEVER responds to data.
32
+ *
33
+ * This simulates a Firebird server that:
34
+ * - Completes the TCP three-way handshake (SYN / SYN-ACK / ACK)
35
+ * - Receives the initial op_connect packet from the client
36
+ * - Never sends op_accept_data back
37
+ *
38
+ * Result: node-firebird's attach() callback is never called, reproducing the
39
+ * scenario where _creating is permanently incremented.
40
+ */
41
+ function startFakeServer(port, onReady) {
42
+ const server = net.createServer((socket) => {
43
+ log('fake-server', `TCP accepted from :${socket.remotePort} — will NOT respond to Firebird protocol`);
44
+ socket.on('data', () => { /* receive op_connect but ignore it */ });
45
+ socket.on('error', () => { /* suppress errors on forced cleanup */ });
46
+ });
47
+
48
+ server.on('error', (err) => {
49
+ console.error(`[fake-server] Error: ${err.message}`);
50
+ process.exit(1);
51
+ });
52
+
53
+ server.listen(port, '127.0.0.1', () => {
54
+ log('fake-server', `Listening on 127.0.0.1:${port} (accepts TCP, ignores Firebird wire protocol)`);
55
+ onReady(server);
56
+ });
57
+ }
58
+
59
+ module.exports = { ts, log, poolState, startFakeServer };
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "node-firebird-pool-poc",
3
+ "version": "1.0.0",
4
+ "lockfileVersion": 3,
5
+ "requires": true,
6
+ "packages": {
7
+ "": {
8
+ "name": "node-firebird-pool-poc",
9
+ "version": "1.0.0",
10
+ "dependencies": {
11
+ "node-firebird": "^2.3.1"
12
+ }
13
+ },
14
+ "node_modules/node-firebird": {
15
+ "version": "2.3.1",
16
+ "resolved": "https://registry.npmjs.org/node-firebird/-/node-firebird-2.3.1.tgz",
17
+ "integrity": "sha512-3yTwiLHwgtLYRv+aS4frzsQEwHMDXkP5Z1SPM/W103TvMemVRoCIaxPYO7axnUrKhgTfPHGXzqC5umYlQail/w==",
18
+ "license": "MPL-2.0"
19
+ }
20
+ }
21
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "node-firebird-pool-poc",
3
+ "version": "1.0.0",
4
+ "description": "Proof of concept for node-firebird pool bugs: _creating stuck, pending leak, post-destroy get()",
5
+ "scripts": {
6
+ "bug": "node reproduce.js",
7
+ "fixed": "node reproduce-fixed.js"
8
+ },
9
+ "dependencies": {
10
+ "node-firebird": "^2.3.1"
11
+ }
12
+ }
@@ -0,0 +1,150 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * reproduce-fixed.js — Validates all fixes in pool-patched.js.
5
+ *
6
+ * Uses the same fake TCP server from reproduce.js (accepts connections but never
7
+ * responds to the Firebird wire protocol). The patched Pool is instantiated
8
+ * directly with Firebird's original attach function, so no server-side changes
9
+ * are needed and the fix is purely in lib/pool.js.
10
+ *
11
+ * ─── Fix 1 · connectTimeout in check() ──────────────────────────────────────
12
+ *
13
+ * A new options.connectTimeout value (ms) wraps the attach() call with a
14
+ * setTimeout. If attach() does not complete in time:
15
+ * • _creating is decremented (slot freed)
16
+ * • cb(Error) is called immediately (no hanging Promise)
17
+ * • setImmediate(check) is triggered (next pending request can be served)
18
+ * If attach() eventually returns a db after the timeout, the connection is
19
+ * discarded via db.connection._pooled = false + db.detach() (no socket leak).
20
+ *
21
+ * ─── Fix 2 · _destroyed flag in get() and check() ──────────────────────────
22
+ *
23
+ * pool.get() called after pool.destroy() is immediately rejected with
24
+ * "Pool has been destroyed". No silent accumulation in pending.
25
+ *
26
+ * ─── Fix 3 · _destroyed guard inside the attach() callback ─────────────────
27
+ *
28
+ * If pool.destroy() is called while an attach() is in flight, the late-
29
+ * arriving connection is discarded and the caller is notified with an error.
30
+ *
31
+ * ─── Fix 4 · destroy() drains pending ───────────────────────────────────────
32
+ *
33
+ * All callbacks queued in this.pending when destroy() is called receive an
34
+ * immediate "Pool is being destroyed" error instead of hanging forever.
35
+ *
36
+ * ─── Fix 5 · destroy() counts in-use connections ────────────────────────────
37
+ *
38
+ * Connections currently in use (dbinuse) are now counted down so that the
39
+ * destroy() completion callback is not blocked when in-use connections exist.
40
+ *
41
+ * ─── How to run ──────────────────────────────────────────────────────────────
42
+ *
43
+ * npm install
44
+ * node reproduce-fixed.js
45
+ *
46
+ * Expected: all callbacks receive proper errors, _creating returns to 0,
47
+ * process exits cleanly with code 0.
48
+ */
49
+
50
+ const Firebird = require('node-firebird');
51
+ const PatchedPool = require('../lib/pool');
52
+ const { log, poolState, startFakeServer } = require('./helpers');
53
+
54
+ const FAKE_PORT = 13051; // different port from reproduce.js to avoid conflicts
55
+ const CONNECT_TIMEOUT = 1500; // 1.5 s — short for demo; use 5–10 s in production
56
+ const TICK_MS = 600;
57
+
58
+ startFakeServer(FAKE_PORT, (server) => {
59
+
60
+ const options = {
61
+ host: '127.0.0.1',
62
+ port: FAKE_PORT,
63
+ database: '/tmp/poc.fdb',
64
+ user: 'SYSDBA',
65
+ password: 'masterkey',
66
+ lowercase_keys: true,
67
+ isPool: true, // mirrors what exports.pool() adds internally
68
+ connectTimeout: CONNECT_TIMEOUT, // ← the new option (Fix 1)
69
+ };
70
+
71
+ // Instantiate the patched Pool directly with Firebird's original attach function.
72
+ // This is equivalent to what exports.pool() does internally:
73
+ // return new Pool(exports.attach, max, Object.assign({}, options, { isPool: true }));
74
+ const pool = new PatchedPool(Firebird.attach, 5, options);
75
+
76
+ console.log('\n══════════════════════════════════════════════════════════════════');
77
+ console.log(' node-firebird · pool fix validation (pool-patched.js)');
78
+ console.log(` connectTimeout = ${CONNECT_TIMEOUT} ms`);
79
+ console.log('══════════════════════════════════════════════════════════════════\n');
80
+
81
+ // ── Fix 1 ─────────────────────────────────────────────────────────────────
82
+ log('FIX-1', `pool.get() — attach() will hang, timeout fires after ${CONNECT_TIMEOUT} ms`);
83
+
84
+ pool.get((err, db) => {
85
+ if (err) log('FIX-1 ✓ OK ', `callback received error (expected): "${err.message}"`);
86
+ else log('FIX-1 ✗ WRONG ', `got db — should have timed out`);
87
+ });
88
+
89
+ let tick = 0;
90
+
91
+ const interval = setInterval(() => {
92
+ tick++;
93
+ log('pool-state', poolState(pool));
94
+
95
+ // ── Fix 2 + Fix 4 (setup) ─────────────────────────────────────────────
96
+ if (tick === 3) {
97
+ // At this point the connectTimeout for tick-1 has already fired
98
+ // (1500 ms < 3 × 600 ms = 1800 ms), so pool is idle again.
99
+ log('FIX-4', 'Queuing a get() in pending, then destroying pool immediately');
100
+
101
+ pool.get((err, db) => {
102
+ // [Fix 4] destroy() must drain this callback right away.
103
+ if (err) log('FIX-4 ✓ OK ', `pending callback received error: "${err.message}"`);
104
+ else log('FIX-4 ✗ WRONG ', `got db after destroy: ${!!db}`);
105
+ });
106
+
107
+ log('FIX-4', `Before destroy — ${poolState(pool)}`);
108
+
109
+ // Suspend check() by inflating _creating so our queued get() stays in pending.
110
+ // (In real usage a second concurrent pool.get() while one is stuck in attach()
111
+ // would naturally be in pending — this simulates that scenario without timing
112
+ // tricks that make the PoC fragile.)
113
+ pool._creating = 5; // fill all slots so check() won't serve the pending cb
114
+ pool.destroy();
115
+ pool._creating = 0; // reset (already orphaned pool, just for readable output)
116
+
117
+ log('FIX-4', `After destroy — ${poolState(pool)}`);
118
+ log('FIX-4', '↑ pending callback fired SYNCHRONOUSLY inside destroy()');
119
+ }
120
+
121
+ // ── Fix 2 ──────────────────────────────────────────────────────────────
122
+ if (tick === 4) {
123
+ log('FIX-2', 'pool.get() AFTER pool.destroy() — must be rejected immediately');
124
+
125
+ pool.get((err, db) => {
126
+ // [Fix 2] _destroyed guard in get() rejects this synchronously.
127
+ if (err) log('FIX-2 ✓ OK ', `immediately rejected: "${err.message}"`);
128
+ else log('FIX-2 ✗ WRONG ', `got db after destroy: ${!!db}`);
129
+ });
130
+
131
+ log('FIX-2', `After post-destroy get — ${poolState(pool)}`);
132
+ }
133
+
134
+ // ── Summary ────────────────────────────────────────────────────────────
135
+ if (tick === 5) {
136
+ clearInterval(interval);
137
+
138
+ console.log('\n══════════════════════════════════════════════════════════════════');
139
+ log('SUMMARY', `Final state : ${poolState(pool)}`);
140
+ log('SUMMARY', `Fix 1 confirmed : _creating returned to 0 after ${CONNECT_TIMEOUT} ms timeout`);
141
+ log('SUMMARY', 'Fix 2 confirmed : post-destroy get() rejected immediately (_destroyed flag)');
142
+ log('SUMMARY', 'Fix 4 confirmed : pending callback drained synchronously by destroy()');
143
+ log('SUMMARY', 'Process exits cleanly — no hanging sockets or unresolved callbacks');
144
+ console.log('══════════════════════════════════════════════════════════════════\n');
145
+
146
+ server.close();
147
+ process.exit(0); // exit(0) = all fixes validated
148
+ }
149
+ }, TICK_MS);
150
+ });
@@ -0,0 +1,133 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * reproduce.js — Demonstrates three bugs in node-firebird's Pool class (lib/pool.js).
5
+ *
6
+ * No real Firebird server is required. A fake TCP server (see helpers.js) accepts
7
+ * connections but never responds to the Firebird wire protocol, triggering the bugs.
8
+ *
9
+ * ─── Bug 1 · _creating stuck forever ────────────────────────────────────────
10
+ *
11
+ * In Pool.check(), when no idle connection exists:
12
+ *
13
+ * self._creating++;
14
+ * this.attach(options, function(err, db) {
15
+ * self._creating--; // ← only here
16
+ * ...
17
+ * cb(err, db);
18
+ * });
19
+ *
20
+ * If attach() never calls back (TCP connects, but server stalls on Firebird
21
+ * protocol, e.g. SRP auth), _creating is never decremented. The pool slot is
22
+ * permanently locked. pool.get() callers wait forever.
23
+ *
24
+ * ─── Bug 2 · destroy() does not drain pending callbacks ─────────────────────
25
+ *
26
+ * pool.destroy() only iterates this.internaldb (connected databases). Callbacks
27
+ * queued in this.pending (waiting for a free slot) are silently abandoned — they
28
+ * are never called, so any awaiting Promise hangs until the process exits.
29
+ *
30
+ * Additionally, if any connection is currently in use (dbinuse > 0), the
31
+ * destroy() callback itself is never invoked because connectionCount never
32
+ * reached zero (the in-use branch falls through without calling detachCallback).
33
+ *
34
+ * ─── Bug 3 · pool.get() after pool.destroy() silently accumulates ────────────
35
+ *
36
+ * There is no _destroyed guard. Calling pool.get() on a destroyed pool pushes
37
+ * the callback into this.pending and calls check(), which returns early because
38
+ * _destroyed is never checked. The callback is never served.
39
+ *
40
+ * ─── How to run ──────────────────────────────────────────────────────────────
41
+ *
42
+ * npm install
43
+ * node reproduce.js
44
+ *
45
+ * Expected: process does NOT exit cleanly (hangs due to open sockets / pending
46
+ * callbacks). Exit code 1 is forced after the demo to confirm bugs are present.
47
+ */
48
+
49
+ const Firebird = require('node-firebird');
50
+ const { log, poolState, startFakeServer } = require('./helpers');
51
+
52
+ const FAKE_PORT = 13050;
53
+ const TICK_MS = 600; // interval between pool-state snapshots
54
+
55
+ startFakeServer(FAKE_PORT, (server) => {
56
+
57
+ const options = {
58
+ host: '127.0.0.1',
59
+ port: FAKE_PORT,
60
+ database: '/tmp/poc.fdb',
61
+ user: 'SYSDBA',
62
+ password: 'masterkey',
63
+ lowercase_keys: true,
64
+ isPool: true,
65
+ // No connectTimeout — original (buggy) behaviour
66
+ };
67
+
68
+ const pool = Firebird.pool(5, options);
69
+
70
+ console.log('\n══════════════════════════════════════════════════════════════════');
71
+ console.log(' node-firebird · pool bug reproduction (original lib/pool.js)');
72
+ console.log('══════════════════════════════════════════════════════════════════\n');
73
+
74
+ // ── Bug 1 ─────────────────────────────────────────────────────────────────
75
+ log('BUG-1', 'pool.get() → TCP connects, Firebird protocol hangs, callback NEVER fires');
76
+
77
+ pool.get((err, db) => {
78
+ // ← BUG: this line is never reached
79
+ log('BUG-1 ✗ WRONG', `callback fired (unexpected): err=${err?.message} db=${!!db}`);
80
+ });
81
+
82
+ let tick = 0;
83
+
84
+ const interval = setInterval(() => {
85
+ tick++;
86
+ log('pool-state', poolState(pool));
87
+
88
+ // ── Bug 2 (setup) ──────────────────────────────────────────────────────
89
+ if (tick === 2) {
90
+ log('BUG-2', 'Queuing a second get() in pending, then calling destroy()');
91
+
92
+ pool.get((err, db) => {
93
+ // ← BUG: destroy() never calls this; the Promise hangs forever
94
+ if (err) log('BUG-2 ✓ OK ', `pending callback received error: "${err.message}"`);
95
+ else log('BUG-2 ✗ WRONG ', `got db unexpectedly: ${!!db}`);
96
+ });
97
+
98
+ log('BUG-2', `Before destroy — ${poolState(pool)}`);
99
+ pool.destroy();
100
+ log('BUG-2', `After destroy — ${poolState(pool)}`);
101
+ log('BUG-2', '↑ pending callback was NEVER called — Promise hangs forever');
102
+ }
103
+
104
+ // ── Bug 3 ──────────────────────────────────────────────────────────────
105
+ if (tick === 3) {
106
+ log('BUG-3', 'pool.get() AFTER pool.destroy() — should be rejected immediately');
107
+
108
+ pool.get((err, db) => {
109
+ // ← BUG: no _destroyed guard; callback silently queued, never served
110
+ if (err) log('BUG-3 ✓ OK ', `rejected: "${err.message}"`);
111
+ else log('BUG-3 ✗ WRONG ', `got db after destroy: ${!!db}`);
112
+ });
113
+
114
+ log('BUG-3', `After post-destroy get — ${poolState(pool)}`);
115
+ }
116
+
117
+ // ── Summary ────────────────────────────────────────────────────────────
118
+ if (tick === 5) {
119
+ clearInterval(interval);
120
+
121
+ console.log('\n══════════════════════════════════════════════════════════════════');
122
+ log('SUMMARY', `Final state : ${poolState(pool)}`);
123
+ log('SUMMARY', 'Bug 1 confirmed : _creating=1, permanently stuck (slot lost)');
124
+ log('SUMMARY', 'Bug 2 confirmed : pending callback from before destroy never fired');
125
+ log('SUMMARY', 'Bug 3 confirmed : post-destroy get silently accumulated in pending');
126
+ log('SUMMARY', 'Process would hang forever without forced exit below');
127
+ console.log('══════════════════════════════════════════════════════════════════\n');
128
+
129
+ server.close();
130
+ process.exit(1); // exit(1) = bugs confirmed
131
+ }
132
+ }, TICK_MS);
133
+ });
package/vitest.config.js CHANGED
@@ -18,7 +18,8 @@ module.exports = defineConfig({
18
18
  'test/db-crypt-config.js',
19
19
  'test/timezone.js',
20
20
  'test/decfloat.js',
21
- 'test/mock-server.js'
21
+ 'test/mock-server.js',
22
+ 'test/pool-fixes.js'
22
23
  ],
23
24
  },
24
25
  });