Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 43 additions & 7 deletions lib/core/database/mysql_connection.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> _connectOnce(int connectTimeoutMs) async {
if (_isConnected && _conn != null) return;
final generation = _generation;
MySQLConnection? opened;
SshTunnelHandle? openedHandle;

var effectivePassword = _password;
var effectiveConnectionString = _connectionString;
Expand Down Expand Up @@ -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 ?? '';
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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 {
Expand Down Expand Up @@ -375,6 +401,8 @@ class MysqlConnection {
}

Future<void> disconnect() async {
_generation++;
_connecting = null;
_isConnected = false;
_inTransaction = false;
final c = _conn;
Expand All @@ -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<void> 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) {
Expand Down Expand Up @@ -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 {}
82 changes: 82 additions & 0 deletions test/core/database/mysql_connect_cancel_test.dart
Original file line number Diff line number Diff line change
@@ -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<void> 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<void>.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<void>.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<void>((_) {}, onError: (_) {});
});
}
5 changes: 5 additions & 0 deletions test/support/fake_ssh.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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})>[];

Expand All @@ -52,6 +55,8 @@ class FakeSshServer {
Duration? timeout,
}) async {
connects.add((host: host, port: port));
final delay = connectDelay;
if (delay != null) await Future<void>.delayed(delay);
if (failConnect) throw StateError('connect to $host:$port failed');
return FakeSshSocket();
}
Expand Down
Loading