diff --git a/lib/src/imap/imap_client.dart b/lib/src/imap/imap_client.dart index 2d90f3ec..f980e6cd 100644 --- a/lib/src/imap/imap_client.dart +++ b/lib/src/imap/imap_client.dart @@ -335,9 +335,37 @@ class ImapClient extends ClientBase { _isInIdleMode = false; _selectedMailbox = null; _failPendingIdleContinuation('connection error: $error'); + // Previously only the event below was fired, so a command that was + // awaiting a response when the socket died never completed: with no + // responseTimeout set, `await sendCommand(...)` hung for the lifetime of + // the process, and even with one it waited out the full timeout for an + // error that had already arrived. The event cannot complete the caller's + // future on its own. + _completePendingTasksWithError(error); fireEvent(ImapConnectionLostEvent(this)); } + /// Error-completes every task that is queued or awaiting a response. + void _completePendingTasksWithError(dynamic error) { + final pending = [..._queue, ..._tasks.values]; + _queue.clear(); + _tasks.clear(); + _currentCommandTask = null; + _idleCommandTask = null; + for (final task in pending) { + if (task.completer.isCompleted) { + continue; + } + try { + task.completer.completeError( + ImapException(this, 'connection lost: $error'), + ); + } catch (e) { + logApp('unable to completeError for task $task: $e'); + } + } + } + @override Future disconnect() async { // Fail-first so any future returned by idleStart(waitForContinuation:true) diff --git a/test/imap/imap_client_test.dart b/test/imap/imap_client_test.dart index 7c712d48..927419c0 100644 --- a/test/imap/imap_client_test.dart +++ b/test/imap/imap_client_test.dart @@ -2,7 +2,7 @@ // cSpell:disable import 'dart:async'; -import 'dart:io' show Platform; +import 'dart:io' show Platform, SocketException; import 'package:enough_mail/enough_mail.dart'; import 'package:enough_mail/src/private/util/client_base.dart'; @@ -1440,6 +1440,47 @@ void main() { expect(uidResponseCode?.targetSequence.toList().first, 176); }); + test( + 'ImapClient fails commands awaiting a response when the connection is lost', + () async { + await _selectInbox(); + // No server response is configured, so this NOOP stays in flight. + mockServer.response = null; + final pending = client.noop(); + await Future.delayed(const Duration(milliseconds: 15)); + + client.onConnectionError(const SocketException('connection reset')); + + await expectLater( + pending, + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('connection lost'), + ), + ), + ); + }, + ); + + test( + 'ImapClient fails queued commands when the connection is lost', + () async { + await _selectInbox(); + mockServer.response = null; + // The first command is sent and waits; the second is queued behind it. + final inFlight = client.noop(); + final queued = client.noop(); + await Future.delayed(const Duration(milliseconds: 15)); + + client.onConnectionError(const SocketException('connection reset')); + + await expectLater(inFlight, throwsA(isA())); + await expectLater(queued, throwsA(isA())); + }, + ); + test('ImapClient idle', () async { final box = await _selectInbox(); expungedMessages = [];