From b1860044d642f3ff2ecb816f0b2129cf235c41a4 Mon Sep 17 00:00:00 2001 From: David Hobley Date: Wed, 23 Sep 2026 08:35:07 +1000 Subject: [PATCH] fix(imap): fail pending commands when the connection is lost onConnectionError only fired ImapConnectionLostEvent, 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. Every queued or in-flight CommandTask is now error-completed with an ImapException before the event is fired, mirroring what _failPendingIdleContinuation already does for idleStart. --- lib/src/imap/imap_client.dart | 28 +++++++++++++++++++++ test/imap/imap_client_test.dart | 43 ++++++++++++++++++++++++++++++++- 2 files changed, 70 insertions(+), 1 deletion(-) 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 = [];