Hi Zihan,
Thank you for this thorough audit — these are genuinely subtle concurrency
bugs, and I appreciate that you've reproduced each one with tests and
prepared fixes. I've gone through all seven findings against the current
develop code, and they all check out. Some inline notes:

*Finding 1 (waiter never removed on timeout):* Confirmed. The
waitQueue.toArray() returns wrapper closures, but the timeout handler
searches for the raw resolve function — so indexOf(resolve) is always -1,
the wrapper stays in the queue forever, and a later releaseSession feeds
the session to a dead waiter. The fix should either store a reference to
the wrapper or use a different bookkeeping scheme for timeout cleanup.

*Finding 2 (blind shift() after await createSession):* Confirmed.
createSession() pushes to the *back* of idleSessions, but after the await,
getSession does this.idleSessions.shift() from the *front*. A session
released during the await lands at the front and gets evicted. The fix
should find and remove the specific newly-created pooledSession rather than
blindly shifting the front.

*Finding 3 (idle-reuse branch missing inUse = true):* Confirmed. The fast
path at line 235-245 of BaseSessionPool.getSession() never sets
pooledSession.inUse
= true. This means TableSessionPool.syncDatabaseContextToPool — which
filters on !ps.inUse — can issue USE <db> on a session that is currently
serving another caller's query. The fix is a one-liner: add pooledSession.inUse
= true after the isOpen check in the idle-reuse branch.

*Finding 4 (minPoolSize guard evaluated once pre-cleanup):* Confirmed.
The this.pool.length
> minSize check in the for-loop is evaluated against the pre-cleanup size.
If the pool has 10 sessions, minSize is 5, and 6 are idle-timed-out, all 6
pass the guard (10 > 5) and get removed, leaving the pool at 4. The fix
should re-evaluate the pool size after each removal, or pre-calculate how
many to keep.

*Finding 5 (close before idle-queue removal):* Confirmed.
cleanupIdleSessions awaits ps.session.close() before removing from
idleSessions. Between the close completing and the remove, a concurrent
getSession() can shift the closed session from the idle queue. The fix
should remove from the idle queue *first*, then close.

*Finding 6 (Connection.open socket leak):* Confirmed. The catch block in
Connection.open() rethrows without destroying the TCP connection or
removing listeners. Each failed connect attempt leaks one socket. The fix
should add this.connection.destroy() + removeAllListeners() in the catch
block.

*Finding 7 (redirect sessions shared between endpoint cache and general
pool):* Confirmed. getSessionForEndpoint pushes into this.pool (line 217),
and releaseSession pushes into this.idleSessions (line 326/330), so a
session created for a specific redirect endpoint can later be handed out by
a plain getSession() — while simultaneously being used by a redirect-path
caller via endPointToSession. Two different callers could drive the same
Thrift socket concurrently.

I think *option (a) — keep endpoint-owned sessions out of the general pool
entirely* — is the cleaner direction. A redirect-owned session has a
different lifecycle contract: it's tied to a specific endpoint, and its
reuse should be scoped to future writes targeting that same device/table.
Mixing it into the general pool creates the double-ownership problem you
identified. Concretely, I'd suggest:

   - getSessionForEndpoint should *not* push into this.pool or
   this.idleSessions
   - Release of a redirect-owned session should route back to the
   endPointToSession map (mark it idle there), not to the general idle queue
   - Maybe even track redirect-owned sessions in a separate set so close() /
   cleanup still reaches them

Regarding the fix plan: the two-PR split (pool lifecycle 1-5 + connection
leak 6) makes sense. Holding 7 for a follow-up after we settle on the
design is fine. Once you send the PRs I'll review them against the code.

(By the way, the mention of iotdb-mcp-server seems unrelated to the above —
maybe intended for a different thread?)

Thanks again for the careful audit!

Best,
Xuan Wang

Zh D <[email protected]> 于2026年7月22日周三 14:19写道:

> Hi all,
>
> While working with the nodejs client, I audited the session pool and
> connection lifecycle in iotdb-client-nodejs and found seven related
> issues. Before sending patches I'd like to share the findings and the
> fix plan here, and get input on one design question.
>
> Findings (all reproduced with unit tests against develop):
>
> Acquisition path (BaseSessionPool.getSession):
>
> 1. A timed-out waiter is never removed from the wait queue: the timeout
> handler searches the queue for the resolve function, but the queue
> stores a wrapper closure, so the lookup never matches. A later
> releaseSession then hands the session to the dead waiter, the session
> is marked in-use forever, and the pool starves.
>
> 2. The create-new-session branch removes the wrong idle entry under
> interleaving: it does a blind shift() from the front after an await,
> while createSession pushes the new session to the back. A session
> released into the idle queue during the await gets evicted instead,
> and the new session ends up tracked as both idle and active.
>
> 3. The idle-reuse branch never sets inUse = true (the new-session and
> waiter branches both do), so syncDatabaseContextToPool, which filters
> on !inUse, can run "USE <db>" on a session that is mid-request for
> another caller.
>
> Cleanup path (cleanupIdleSessions):
>
> 4. The minPoolSize guard is evaluated against the pre-cleanup pool size,
> so one cleanup round can retire every idle session and shrink the
> pool below minPoolSize (down to zero).
>
> 5. Sessions are closed before being removed from the idle queue, and
> isOpen() stays true until the async close completes, so a concurrent
> getSession() can be handed a session that is being destroyed.
>
> Connection:
>
> 6. Connection.open() leaks the established socket and its listeners when
> openSession/requestStatementId fails after the TCP connect succeeded;
> through the pool, each failed connect attempt leaks one socket.
>
> Design question (redirect sessions):
>
> 7. With enableRedirection, a session created for a redirect endpoint is
> tracked in endPointToSession AND ends up in the general idle queue
> after release, so a plain getSession() and a redirect insert can
> drive the same Thrift socket concurrently. Two possible directions:
> (a) keep endpoint-owned sessions out of the general pool entirely, or
> (b) route their release back to the endpoint cache instead of the
> idle queue. Which of the two matches the intended redirect-reuse
> design?
>
> Fix plan: I have fixes for 1-6 ready with regression tests (each test
> fails on current develop and passes with the fix; the full unit suite is
> green). I would propose landing them as two focused PRs, one for the
> pool lifecycle (1-5, they share a test harness) and one for the
> connection open leak (6), and holding 7 for whichever direction the
> maintainers prefer. The same pool-misuse pattern also affects
> iotdb-mcp-server in tree mode (sessions are returned with close()
> instead of put_back(), which exhausts the pool after max_pool_size
> queries); I can follow up there once the approach here is agreed.
>
> Feedback welcome - happy to adjust the scope or split differently.
>
> Best,
> Zihan Dai
>

Reply via email to