From 45f438c3f0159a83223cfcd80d6122657a91284e Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 10 Oct 2026 16:48:41 +0300 Subject: [PATCH] fix(mysql): forceClose and disconnect during connect end the attempt (#1313) forceClose returned before releasing the SSH tunnel when there was no connection object yet, and the attempt it overtook finished as a connected session. An attempt now notes the generation it started in; when disconnect or forceClose bumped it, the attempt closes what it opened, releases its tunnel and fails, and a connect after a forceClose starts a new attempt instead of joining the overtaken one. --- lib/core/database/mysql_connection.dart | 50 +++++++++-- .../database/mysql_connect_cancel_test.dart | 82 +++++++++++++++++++ test/support/fake_ssh.dart | 5 ++ 3 files changed, 130 insertions(+), 7 deletions(-) create mode 100644 test/core/database/mysql_connect_cancel_test.dart diff --git a/lib/core/database/mysql_connection.dart b/lib/core/database/mysql_connection.dart index 131204fa..d23914b4 100644 --- a/lib/core/database/mysql_connection.dart +++ b/lib/core/database/mysql_connection.dart @@ -196,8 +196,15 @@ class MysqlConnection { }); } + /// Bumped by [disconnect] and [forceClose]: an attempt that started before + /// one of them must not come back as a connected session (#1313). + int _generation = 0; + Future _connectOnce(int connectTimeoutMs) async { if (_isConnected && _conn != null) return; + final generation = _generation; + MySQLConnection? opened; + SshTunnelHandle? openedHandle; var effectivePassword = _password; var effectiveConnectionString = _connectionString; @@ -226,12 +233,14 @@ class MysqlConnection { jumpPassword: stored.jumpPassword, ); } - _sshTunnelHandle = await SshTunnelManager.instance.openTunnel( + openedHandle = await SshTunnelManager.instance.openTunnel( config: sshConfig!, secrets: sec ?? SshTunnelSecrets(), remoteHost: host, remotePort: port, ); + if (generation != _generation) throw _ConnectSuperseded(); + _sshTunnelHandle = openedHandle; } final user = username ?? ''; @@ -254,7 +263,7 @@ class MysqlConnection { final securityContext = buildSecurityContext(sslPaths); final effectiveHost = _sshTunnelHandle?.localHost ?? parsed.host; final effectivePort = _sshTunnelHandle?.localPort ?? parsed.port; - _conn = await MySQLConnection.createConnection( + opened = await MySQLConnection.createConnection( host: effectiveHost, port: effectivePort, userName: parsed.userName, @@ -265,14 +274,15 @@ class MysqlConnection { sslVerifyCertificates: sslMode.verifyCertificates, sslServerName: sslMode.verifyIdentity && parsed.host is String ? parsed.host : null, ); - await _conn!.connect(timeoutMs: connectTimeoutMs); + _conn = opened; + await opened.connect(timeoutMs: connectTimeoutMs); } else { final sslPaths = extractSslCertificatePathsFromString(effectiveConnectionString); final securityContext = buildSecurityContext(sslPaths); final effectiveHost = _sshTunnelHandle?.localHost ?? host; final effectivePort = _sshTunnelHandle?.localPort ?? port; - _conn = await MySQLConnection.createConnection( + opened = await MySQLConnection.createConnection( host: effectiveHost, port: effectivePort, userName: user, @@ -281,11 +291,27 @@ class MysqlConnection { databaseName: database, securityContext: securityContext, ); - await _conn!.connect(timeoutMs: connectTimeoutMs); + _conn = opened; + await opened.connect(timeoutMs: connectTimeoutMs); } + if (generation != _generation) throw _ConnectSuperseded(); _isConnected = true; scrubCredentials(); } catch (e, st) { + if (generation != _generation) { + // forceClose / disconnect ran while this attempt was in flight and + // reset the fields (a newer attempt may own them now): what this one + // opened is closed here, and it does not come back as connected. + if (identical(_conn, opened)) _conn = null; + try { + if (opened != null && opened.connected) await opened.close(); + } catch (_) {} + try { + await openedHandle?.release(); + } catch (_) {} + if (identical(_sshTunnelHandle, openedHandle)) _sshTunnelHandle = null; + throw StateError('The connection was closed while it was being opened'); + } _isConnected = false; _conn = null; try { @@ -375,6 +401,8 @@ class MysqlConnection { } Future disconnect() async { + _generation++; + _connecting = null; _isConnected = false; _inTransaction = false; final c = _conn; @@ -397,13 +425,17 @@ class MysqlConnection { /// Best-effort close. The `mysql_client` driver may not allow graceful [close] /// while a query is in progress. Future forceClose() async { + _generation++; + _connecting = null; _isConnected = false; _inTransaction = false; final c = _conn; _conn = null; - if (c == null) return; + // No early return when there is no connection yet: an attempt may be + // between opening the tunnel and the handshake, and the tunnel is ours to + // release (#1313). try { - if (c.connected) { + if (c != null && c.connected) { await c.close(); } } catch (e) { @@ -852,3 +884,7 @@ class MysqlConnectionException implements Exception { @override String toString() => message; } + +/// Marks a connect attempt that [MysqlConnection.disconnect] or `forceClose` +/// overtook; never leaves the library. +class _ConnectSuperseded implements Exception {} diff --git a/test/core/database/mysql_connect_cancel_test.dart b/test/core/database/mysql_connect_cancel_test.dart new file mode 100644 index 00000000..2b21a1ea --- /dev/null +++ b/test/core/database/mysql_connect_cancel_test.dart @@ -0,0 +1,82 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/mysql_connection.dart'; +import 'package:querya_desktop/core/security/ssh_tunnel_config.dart'; +import 'package:querya_desktop/core/security/ssh_tunnel_manager.dart'; + +import '../../support/fake_ssh.dart'; + +/// #1313: forceClose / disconnect during connect(). +void main() { + late FakeSshServer server; + late SshTunnelManager manager; + late SshTunnelManager original; + + setUp(() { + server = FakeSshServer(); + manager = SshTunnelManager.forTesting( + connector: server.connect, + clientBuilder: server.build, + ); + original = SshTunnelManager.instance; + SshTunnelManager.instance = manager; + }); + + tearDown(() async { + SshTunnelManager.instance = original; + await manager.closeAll(); + }); + + MysqlConnection tunnelled() => MysqlConnection( + id: 0, + name: 'm', + host: 'db.internal', + port: 3306, + sshConfig: const SshTunnelConfig( + enabled: true, + host: 'bastion.example', + port: 22, + username: 'deploy', + ), + sshSecrets: SshTunnelSecrets(password: 'secret'), + ); + + for (final closer in <(String, Future Function(MysqlConnection))>[ + ('forceClose', (c) => c.forceClose()), + ('disconnect', (c) => c.disconnect()), + ]) { + test('${closer.$1} while the tunnel is being opened ends the attempt ' + 'and releases the tunnel', () async { + server.connectDelay = const Duration(milliseconds: 300); + final c = tunnelled(); + final started = DateTime.now(); + + final attempt = c.connect(connectTimeoutMs: 6000); + final outcome = expectLater(attempt, throwsStateError); + await Future.delayed(const Duration(milliseconds: 100)); + await closer.$2(c); + await outcome; + + // It stopped when the tunnel came up, not after the 6 s handshake wait. + expect(DateTime.now().difference(started).inSeconds, lessThan(3)); + expect(c.isConnected, isFalse); + expect(manager.activeSessionCount, 0, + reason: 'the tunnel this attempt opened was released'); + }); + } + + test('a connect after a forceClose starts a new attempt', () async { + server.connectDelay = const Duration(milliseconds: 200); + final c = tunnelled(); + + final first = c.connect(connectTimeoutMs: 400); + final firstOutcome = expectLater(first, throwsStateError); + await Future.delayed(const Duration(milliseconds: 50)); + await c.forceClose(); + + // Not the failing attempt that was overtaken. + final second = c.connect(connectTimeoutMs: 400); + expect(identical(first, second), isFalse); + await firstOutcome; + await second.then((_) {}, onError: (_) {}); + }); +} diff --git a/test/support/fake_ssh.dart b/test/support/fake_ssh.dart index cb569a85..c45e9792 100644 --- a/test/support/fake_ssh.dart +++ b/test/support/fake_ssh.dart @@ -40,6 +40,9 @@ class FakeSshServer { /// Makes the TCP connect fail. bool failConnect; + /// How long the TCP connect takes, to land another call inside it. + Duration? connectDelay; + /// Every (host, port) the manager dialed, in order. final connects = <({String host, int port})>[]; @@ -52,6 +55,8 @@ class FakeSshServer { Duration? timeout, }) async { connects.add((host: host, port: port)); + final delay = connectDelay; + if (delay != null) await Future.delayed(delay); if (failConnect) throw StateError('connect to $host:$port failed'); return FakeSshSocket(); }