diff --git a/CHANGELOG.md b/CHANGELOG.md index a60a6d7..6e073a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +## [2.22.1] - 2026-07-13 +- Fixed a keepalive issue where overlapping pings could occur and caught errors during ping execution. Thanks [@vicajilau]. + ## [2.22.0] - 2026-07-03 - Added optional `handshakeTimeout` and `authTimeout` to `SSHClient` to limit connection negotiation and user authentication times [#182]. Thanks [@GT-610]. diff --git a/lib/src/ssh_keepalive.dart b/lib/src/ssh_keepalive.dart index a6bc75d..d60047f 100644 --- a/lib/src/ssh_keepalive.dart +++ b/lib/src/ssh_keepalive.dart @@ -9,6 +9,8 @@ class SSHKeepAlive { final Future Function() ping; + bool _isPinging = false; + SSHKeepAlive({ required this.ping, this.interval = const Duration(seconds: 10), @@ -16,7 +18,15 @@ class SSHKeepAlive { void start() { _timer ??= Timer.periodic(interval, (timer) async { - await ping(); + if (_isPinging) return; + _isPinging = true; + try { + await ping(); + } catch (_) { + // Ignore errors, the client transport will handle disconnection. + } finally { + _isPinging = false; + } }); } diff --git a/pubspec.yaml b/pubspec.yaml index 0357813..4e5db83 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: dartssh2 -version: 2.22.0 +version: 2.22.1 description: SSH and SFTP client written in pure Dart, aiming to be feature-rich as well as easy to use. homepage: https://github.com/TerminalStudio/dartssh2 diff --git a/test/src/ssh_keepalive_test.dart b/test/src/ssh_keepalive_test.dart new file mode 100644 index 0000000..16815ac --- /dev/null +++ b/test/src/ssh_keepalive_test.dart @@ -0,0 +1,84 @@ +import 'dart:async'; + +import 'package:dartssh2/src/ssh_keepalive.dart'; +import 'package:test/test.dart'; + +void main() { + group('SSHKeepAlive', () { + test('calls ping at specified interval', () async { + var pingCount = 0; + final completer = Completer(); + + final keepAlive = SSHKeepAlive( + interval: const Duration(milliseconds: 10), + ping: () async { + pingCount++; + if (pingCount >= 3) { + completer.complete(); + } + }, + ); + + keepAlive.start(); + await completer.future; + keepAlive.stop(); + + expect(pingCount, greaterThanOrEqualTo(3)); + }); + + test('prevents overlapping pings', () async { + var pingCount = 0; + var activePings = 0; + var maxActivePings = 0; + final completer = Completer(); + + final keepAlive = SSHKeepAlive( + interval: const Duration(milliseconds: 10), + ping: () async { + pingCount++; + activePings++; + if (activePings > maxActivePings) { + maxActivePings = activePings; + } + // Sleep longer than the interval to cause an overlapping tick + await Future.delayed(const Duration(milliseconds: 50)); + activePings--; + if (pingCount >= 2 && !completer.isCompleted) { + completer.complete(); + } + }, + ); + + keepAlive.start(); + // Wait for a few intervals. If overlapping wasn't prevented, activePings would exceed 1. + await Future.delayed(const Duration(milliseconds: 100)); + keepAlive.stop(); + + expect(maxActivePings, equals(1)); + }); + + test('handles ping errors and resets isPinging status', () async { + var pingCount = 0; + final completer = Completer(); + + final keepAlive = SSHKeepAlive( + interval: const Duration(milliseconds: 10), + ping: () async { + pingCount++; + if (pingCount == 1) { + throw Exception('ping failed'); + } + if (pingCount == 2) { + completer.complete(); + } + }, + ); + + keepAlive.start(); + await completer.future; + keepAlive.stop(); + + expect(pingCount, equals(2)); + }); + }); +}