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 +55 -0
- package/lib/index.d.ts +10 -0
- package/lib/pool.js +87 -9
- package/lib/wire/connection.js +1 -0
- package/package.json +1 -1
- package/poc/README.md +160 -0
- package/poc/helpers.js +59 -0
- package/poc/package-lock.json +21 -0
- package/poc/package.json +12 -0
- package/poc/reproduce-fixed.js +150 -0
- package/poc/reproduce.js +133 -0
- package/vitest.config.js +2 -1
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
|
|
10
|
-
this.internaldb = []; //
|
|
11
|
-
this.pooldb
|
|
12
|
-
this.dbinuse
|
|
13
|
-
this._creating = 0;
|
|
14
|
-
this.max
|
|
15
|
-
this.pending
|
|
16
|
-
this.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
|
|
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
|
}
|
package/lib/wire/connection.js
CHANGED
|
@@ -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
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
|
+
}
|
package/poc/package.json
ADDED
|
@@ -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
|
+
});
|
package/poc/reproduce.js
ADDED
|
@@ -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
|
+
});
|