Part of #1212.
Problem
connect() on a pooled connection is not guarded against concurrent calls.
PostgresConnectionPool.acquire() reconnects a dropped entry with await entry.connection.connect() and then setSessionReadOnly(...) (lib/core/database/postgres_connection_pool.dart:92-95 and :128-131). Two callers that reuse the same dropped entry both get there.
PostgresConnection.connect() (lib/core/database/postgres_connection.dart:204) only checks _isConnected && _conn != null, which is false for both callers while the first is still connecting. Both:
- read the secrets,
- open an SSH tunnel when the connection uses one (
_sshTunnelHandle = await SshTunnelManager.instance.openTunnel(...)),
- open a socket (
_conn = await Connection.open(...)).
- The later assignment wins. The earlier
Connection and tunnel are never closed (leak until the app exits).
- If one attempt fails after the other succeeded, its
catch sets _conn = null, _isConnected = false and releases _sshTunnelHandle — by then the other attempt's tunnel. The working session is dropped and the next statement fails with Not connected to PostgreSQL.
MysqlConnection.connect() (lib/core/database/mysql_connection.dart:167-265) has the same structure and the same races (socket, SSH tunnel, failure path).
SQLite (SqliteConnection.connect, lib/core/database/sqlite_connection.dart:88) has the same guard, but sqflite reuses one instance per path, so it is likely unaffected (not verified).
When it happens: any time a shared slot was dropped (tab dispose, timeout, network blip) and two views ask for it at once — e.g. the tree and a table tab refreshing together, or a table tab and an MCP call.
Scope
connect() keeps the in-flight attempt (Future<void>? _connecting); concurrent callers await the same future. _connecting is cleared when it completes.
- On failure, clean up only what this attempt created (its socket, its tunnel), never fields set by another attempt.
- The pool reconnects a dropped entry once (per-entry reconnect future or under
PoolEntryLock), then runs setSessionReadOnly once.
- Same for MySQL.
Acceptance
Part of #1212.
Problem
connect()on a pooled connection is not guarded against concurrent calls.PostgresConnectionPool.acquire()reconnects a dropped entry withawait entry.connection.connect()and thensetSessionReadOnly(...)(lib/core/database/postgres_connection_pool.dart:92-95and:128-131). Two callers that reuse the same dropped entry both get there.PostgresConnection.connect()(lib/core/database/postgres_connection.dart:204) only checks_isConnected && _conn != null, which is false for both callers while the first is still connecting. Both:_sshTunnelHandle = await SshTunnelManager.instance.openTunnel(...)),_conn = await Connection.open(...)).Connectionand tunnel are never closed (leak until the app exits).catchsets_conn = null,_isConnected = falseand releases_sshTunnelHandle— by then the other attempt's tunnel. The working session is dropped and the next statement fails withNot connected to PostgreSQL.MysqlConnection.connect()(lib/core/database/mysql_connection.dart:167-265) has the same structure and the same races (socket, SSH tunnel, failure path).SQLite (
SqliteConnection.connect,lib/core/database/sqlite_connection.dart:88) has the same guard, but sqflite reuses one instance per path, so it is likely unaffected (not verified).When it happens: any time a shared slot was dropped (tab dispose, timeout, network blip) and two views ask for it at once — e.g. the tree and a table tab refreshing together, or a table tab and an MCP call.
Scope
connect()keeps the in-flight attempt (Future<void>? _connecting); concurrent callers await the same future._connectingis cleared when it completes.PoolEntryLock), then runssetSessionReadOnlyonce.Acceptance
connect()calls open one socket and one tunnel.acquire()on a dropped entry → one reconnect, oneSET default_transaction_read_only.