From da8a75b63937a1d5da79c26f9c7a5656db139d22 Mon Sep 17 00:00:00 2001 From: Dmytro Bezverkhnii Date: Tue, 1 Sep 2026 20:00:49 +0300 Subject: [PATCH] Add: IMAP interrup command support COR-173 --- Package.swift | 2 +- src/async/imap/MCIMAPAsyncConnection.cpp | 12 + src/async/imap/MCIMAPAsyncConnection.h | 1 + src/async/imap/MCIMAPOperation.cpp | 20 ++ src/async/imap/MCIMAPOperation.h | 12 + src/c/imap/CIMAPBaseOperation.cpp | 2 + src/c/imap/CIMAPBaseOperation.h | 1 + src/core/basetypes/MCOperation.cpp | 5 + src/core/basetypes/MCOperation.h | 6 + src/core/basetypes/MCOperationQueue.cpp | 28 +++ src/core/basetypes/MCOperationQueue.h | 12 + src/core/imap/MCIMAPSession.cpp | 13 + src/core/imap/MCIMAPSession.h | 7 + src/include/MailCore/CIMAPBaseOperation.h | 1 + src/include/MailCore/MCIMAPAsyncConnection.h | 1 + src/include/MailCore/MCIMAPOperation.h | 12 + src/include/MailCore/MCIMAPSession.h | 7 + src/include/MailCore/MCOperation.h | 6 + src/include/MailCore/MCOperationQueue.h | 12 + src/swift/imap/IMAPBaseOperation.swift | 21 ++ .../IMAPInterruptCurrentCommandTests.swift | 223 ++++++++++++++++++ 21 files changed, 403 insertions(+), 1 deletion(-) create mode 100644 unittest/IMAPInterruptCurrentCommandTests.swift diff --git a/Package.swift b/Package.swift index 60ef5ebef..b562cf7f1 100644 --- a/Package.swift +++ b/Package.swift @@ -265,7 +265,7 @@ var targets: [Target] = [ "unittest.cpp", "unittest.mm" ], - sources: ["LibetpanHelperTests.swift", "unittest.swift"], + sources: ["IMAPInterruptCurrentCommandTests.swift", "LibetpanHelperTests.swift", "unittest.swift"], resources: [ .copy("data") ] diff --git a/src/async/imap/MCIMAPAsyncConnection.cpp b/src/async/imap/MCIMAPAsyncConnection.cpp index a6870d44c..8b9b07efb 100644 --- a/src/async/imap/MCIMAPAsyncConnection.cpp +++ b/src/async/imap/MCIMAPAsyncConnection.cpp @@ -291,6 +291,18 @@ void IMAPAsyncConnection::cancelAllOperations() mQueue->cancelAllOperations(); } +bool IMAPAsyncConnection::interruptCurrentCommand(IMAPOperation * operation) +{ + // Only for the operation the queue is executing right now - its command is the one holding this + // connection. A queued operation holds nothing yet, and one that has already finished no longer + // owns the stream, so cancelling on its behalf would cut somebody else's command. The queue + // settles that question and interrupts under its own lock. + // + // Deliberately not scheduled through mQueue as an operation: the point is to unblock the + // operation the queue is running, and a queued request would wait behind that very operation. + return mQueue->interruptRunningOperation(operation); +} + void IMAPAsyncConnection::runOperation(IMAPOperation * operation) { if (mScheduledAutomaticDisconnect) { diff --git a/src/async/imap/MCIMAPAsyncConnection.h b/src/async/imap/MCIMAPAsyncConnection.h index 20a0936cb..41b3ddd2f 100644 --- a/src/async/imap/MCIMAPAsyncConnection.h +++ b/src/async/imap/MCIMAPAsyncConnection.h @@ -115,6 +115,7 @@ namespace mailcore { virtual IMAPSession * session(); virtual void cancelAllOperations(); + virtual bool interruptCurrentCommand(IMAPOperation * operation); virtual unsigned int operationsCount(); virtual void setLastFolder(String * folder); diff --git a/src/async/imap/MCIMAPOperation.cpp b/src/async/imap/MCIMAPOperation.cpp index 2f7d3c48d..dc5b56a34 100644 --- a/src/async/imap/MCIMAPOperation.cpp +++ b/src/async/imap/MCIMAPOperation.cpp @@ -176,6 +176,26 @@ void IMAPOperation::beforeMain() { } +bool IMAPOperation::interruptCurrentCommand() +{ + if (mSession == NULL) { + return false; + } + + return mSession->interruptCurrentCommand(this); +} + +void IMAPOperation::interrupt() +{ + // Called by the connection's queue while this operation is the one running, so the stream below + // is the one its command is blocked on. + if (mSession == NULL) { + return; + } + + mSession->session()->interruptCurrentCommand(); +} + void IMAPOperation::afterMain() { if (mSession->session()->isAutomaticConfigurationDone()) { diff --git a/src/async/imap/MCIMAPOperation.h b/src/async/imap/MCIMAPOperation.h index a57d74894..8075a733a 100644 --- a/src/async/imap/MCIMAPOperation.h +++ b/src/async/imap/MCIMAPOperation.h @@ -43,6 +43,18 @@ namespace mailcore { virtual void beforeMain(); virtual void afterMain(); + virtual void interrupt(); + + /** Aborts this operation's IMAP command if it is the one currently running on its + connection: the blocked read returns at once instead of waiting out the socket timeout, so + the operations queued behind it (a disconnect, most importantly) run immediately. + Does nothing when the operation is not the one running. + + Teardown of this connection only - it is left unusable and reconnects on next use, so call + it for a command that is being abandoned (cancelled, or given up on), never to hurry up a + command whose result still matters. + Returns whether a command was actually interrupted. */ + virtual bool interruptCurrentCommand(); virtual void start(); diff --git a/src/c/imap/CIMAPBaseOperation.cpp b/src/c/imap/CIMAPBaseOperation.cpp index d243c0b98..b6768026b 100644 --- a/src/c/imap/CIMAPBaseOperation.cpp +++ b/src/c/imap/CIMAPBaseOperation.cpp @@ -49,6 +49,8 @@ ErrorCode CIMAPBaseOperation_error(struct CIMAPBaseOperation self) { return static_cast(self.instance->error()); } +C_SYNTHESIZE_FUNC_WITH_SCALAR(bool, interruptCurrentCommand) + CIMAPBaseOperation CIMAPBaseOperation_setProgressBlocks(struct CIMAPBaseOperation self, CIMAPProgressBlock itemProgressBlock, CIMAPProgressBlock bodyProgressBlock, const void* userInfo) { CIMAPBaseOperationIMAPCallback *callback = new CIMAPBaseOperationIMAPCallback(userInfo, itemProgressBlock, bodyProgressBlock); self._callback = callback; diff --git a/src/c/imap/CIMAPBaseOperation.h b/src/c/imap/CIMAPBaseOperation.h index d204ec91f..ac711dd64 100644 --- a/src/c/imap/CIMAPBaseOperation.h +++ b/src/c/imap/CIMAPBaseOperation.h @@ -35,6 +35,7 @@ extern "C" { C_SYNTHESIZE_COBJECT_CAST_DEFINITION(CIMAPBaseOperation) C_SYNTHESIZE_FUNC_DEFINITION(CIMAPBaseOperation, ErrorCode, error) + C_SYNTHESIZE_FUNC_DEFINITION(CIMAPBaseOperation, bool, interruptCurrentCommand) C_SYNTHESIZE_FUNC_DEFINITION(CIMAPBaseOperation, CIMAPBaseOperation, setProgressBlocks, CIMAPProgressBlock, CIMAPProgressBlock, const void*) CMAILCORE_EXPORT void CIMAPBaseOperation_retain(CIMAPBaseOperation operation) diff --git a/src/core/basetypes/MCOperation.cpp b/src/core/basetypes/MCOperation.cpp index 6c7744839..1ca36de42 100644 --- a/src/core/basetypes/MCOperation.cpp +++ b/src/core/basetypes/MCOperation.cpp @@ -43,6 +43,11 @@ void Operation::cancel() MCB_UNLOCK(&mLock); } +void Operation::interrupt() +{ + // Nothing to interrupt by default. +} + bool Operation::isCancelled() { MCB_LOCK(&mLock); diff --git a/src/core/basetypes/MCOperation.h b/src/core/basetypes/MCOperation.h index c0d72d4e1..14737cdc8 100644 --- a/src/core/basetypes/MCOperation.h +++ b/src/core/basetypes/MCOperation.h @@ -20,6 +20,12 @@ namespace mailcore { virtual OperationCallback * callback(); virtual void cancel(); + + /** Aborts whatever this operation is doing right now. Called by the queue, and only while + this operation is the one it is executing - so an implementation may assume it owns the + resource it is about to break. Does nothing by default. */ + virtual void interrupt(); + virtual bool isCancelled(); // Will be called on main thread. diff --git a/src/core/basetypes/MCOperationQueue.cpp b/src/core/basetypes/MCOperationQueue.cpp index df2044b1a..d7ec063c9 100644 --- a/src/core/basetypes/MCOperationQueue.cpp +++ b/src/core/basetypes/MCOperationQueue.cpp @@ -32,6 +32,7 @@ OperationQueue::OperationQueue() mStopSem = mailsem_new(); mWaitingFinishedSem = mailsem_new(); mQuitting = false; + mRunningOperation = NULL; mCallback = NULL; #if MC_HAS_GCD mDispatchQueue = getMainQueue(); @@ -63,6 +64,26 @@ void OperationQueue::addOperation(Operation * op) startThread(); } +bool OperationQueue::interruptRunningOperation(Operation * op) +{ + bool interrupted = false; + + // interrupt() runs with the lock held on purpose: releasing it first would let the next + // operation start before the interruption lands, and it would be that one getting cut. The + // lock cannot keep op itself from finishing - main() runs without it - so an interrupt may + // still arrive just after the command completed; that costs a reconnect, nothing worse. + // Safe as long as interrupt() implementations stay non-blocking and never reach back into + // this queue. + MCB_LOCK(&mLock); + if ((op != NULL) && (mRunningOperation == op)) { + op->interrupt(); + interrupted = true; + } + MCB_UNLOCK(&mLock); + + return interrupted; +} + void OperationQueue::cancelAllOperations() { MCB_LOCK(&mLock); @@ -122,13 +143,20 @@ void OperationQueue::runOperations() MCAssert(op != NULL); performOnCallbackThread(op, (Object::Method) &OperationQueue::beforeMain, op, true); + // Published only around main(): a cancelled operation whose main() is skipped never counts + // as running, so nobody can mistake an idle connection for a busy one. if (!op->isCancelled() || op->shouldRunWhenCancelled()) { + MCB_LOCK(&mLock); + mRunningOperation = op; + MCB_UNLOCK(&mLock); + op->main(); } op->retain()->autorelease(); MCB_LOCK(&mLock); + mRunningOperation = NULL; mOperations->removeObjectAtIndex(0); if (mOperations->count() == 0) { if (mWaiting) { diff --git a/src/core/basetypes/MCOperationQueue.h b/src/core/basetypes/MCOperationQueue.h index eefe72f9e..d6d387eac 100644 --- a/src/core/basetypes/MCOperationQueue.h +++ b/src/core/basetypes/MCOperationQueue.h @@ -21,6 +21,17 @@ namespace mailcore { virtual void addOperation(Operation * op); virtual void cancelAllOperations(); + + /** Calls interrupt() on `op` if it is the operation whose main() the queue is executing + right now. The check and the call happen under the queue's lock, so no other operation can + become the running one in between: the interrupt cannot land on whatever started next. + It does not stop `op` itself from finishing - main() runs without the lock - so an + operation that completes just as the caller reaches it gets a harmless interrupt on an + idle stream. Lets a caller abort "the command my operation is running" without the risk + of aborting somebody else's. Returns whether interrupt() was called - a caller that + measures the effect needs to tell "there was a command to break" from "there was + nothing". */ + virtual bool interruptRunningOperation(Operation * op); virtual unsigned int count(); @@ -45,6 +56,7 @@ namespace mailcore { bool mWaiting; struct mailsem * mWaitingFinishedSem; bool mQuitting; + Operation * mRunningOperation; OperationQueueCallback * mCallback; #if MC_HAS_GCD dispatch_queue_t mDispatchQueue; diff --git a/src/core/imap/MCIMAPSession.cpp b/src/core/imap/MCIMAPSession.cpp index 7c091a24a..56e918374 100644 --- a/src/core/imap/MCIMAPSession.cpp +++ b/src/core/imap/MCIMAPSession.cpp @@ -3699,6 +3699,19 @@ void IMAPSession::disconnect() unsetup(); } +void IMAPSession::interruptCurrentCommand() +{ + // mailstream_cancel() must be called while holding the lock: unsetup() nils mImap under it and + // frees the stream right after releasing it, so a pointer grabbed and used outside the lock + // would be a use-after-free. Holding it here is safe - mailstream_cancel() only takes the + // cancel object's own mutex and writes one byte to a pipe, it never blocks. + LOCK(); + if (mImap != NULL && mImap->imap_stream != NULL) { + mailstream_cancel(mImap->imap_stream); + } + UNLOCK(); +} + IMAPIdentity * IMAPSession::identity(IMAPIdentity * clientIdentity, ErrorCode * pError) { connectIfNeeded(pError); diff --git a/src/core/imap/MCIMAPSession.h b/src/core/imap/MCIMAPSession.h index c91bdcf2a..be1fe6004 100644 --- a/src/core/imap/MCIMAPSession.h +++ b/src/core/imap/MCIMAPSession.h @@ -169,6 +169,13 @@ namespace mailcore { virtual void connect(ErrorCode * pError); virtual void disconnect(); + + /** Aborts the command currently running on this session by cancelling its stream: the + blocked read returns immediately instead of waiting out the socket timeout. Safe to call + from another thread while the session's thread is blocked in a command. + Teardown only: the cancelled state of a stream is never reset, so the session is unusable + afterwards and must be disconnected. A later connect() builds a fresh stream. */ + virtual void interruptCurrentCommand(); virtual void noop(ErrorCode * pError); diff --git a/src/include/MailCore/CIMAPBaseOperation.h b/src/include/MailCore/CIMAPBaseOperation.h index d204ec91f..ac711dd64 100644 --- a/src/include/MailCore/CIMAPBaseOperation.h +++ b/src/include/MailCore/CIMAPBaseOperation.h @@ -35,6 +35,7 @@ extern "C" { C_SYNTHESIZE_COBJECT_CAST_DEFINITION(CIMAPBaseOperation) C_SYNTHESIZE_FUNC_DEFINITION(CIMAPBaseOperation, ErrorCode, error) + C_SYNTHESIZE_FUNC_DEFINITION(CIMAPBaseOperation, bool, interruptCurrentCommand) C_SYNTHESIZE_FUNC_DEFINITION(CIMAPBaseOperation, CIMAPBaseOperation, setProgressBlocks, CIMAPProgressBlock, CIMAPProgressBlock, const void*) CMAILCORE_EXPORT void CIMAPBaseOperation_retain(CIMAPBaseOperation operation) diff --git a/src/include/MailCore/MCIMAPAsyncConnection.h b/src/include/MailCore/MCIMAPAsyncConnection.h index 20a0936cb..41b3ddd2f 100644 --- a/src/include/MailCore/MCIMAPAsyncConnection.h +++ b/src/include/MailCore/MCIMAPAsyncConnection.h @@ -115,6 +115,7 @@ namespace mailcore { virtual IMAPSession * session(); virtual void cancelAllOperations(); + virtual bool interruptCurrentCommand(IMAPOperation * operation); virtual unsigned int operationsCount(); virtual void setLastFolder(String * folder); diff --git a/src/include/MailCore/MCIMAPOperation.h b/src/include/MailCore/MCIMAPOperation.h index a57d74894..8075a733a 100644 --- a/src/include/MailCore/MCIMAPOperation.h +++ b/src/include/MailCore/MCIMAPOperation.h @@ -43,6 +43,18 @@ namespace mailcore { virtual void beforeMain(); virtual void afterMain(); + virtual void interrupt(); + + /** Aborts this operation's IMAP command if it is the one currently running on its + connection: the blocked read returns at once instead of waiting out the socket timeout, so + the operations queued behind it (a disconnect, most importantly) run immediately. + Does nothing when the operation is not the one running. + + Teardown of this connection only - it is left unusable and reconnects on next use, so call + it for a command that is being abandoned (cancelled, or given up on), never to hurry up a + command whose result still matters. + Returns whether a command was actually interrupted. */ + virtual bool interruptCurrentCommand(); virtual void start(); diff --git a/src/include/MailCore/MCIMAPSession.h b/src/include/MailCore/MCIMAPSession.h index c91bdcf2a..be1fe6004 100644 --- a/src/include/MailCore/MCIMAPSession.h +++ b/src/include/MailCore/MCIMAPSession.h @@ -169,6 +169,13 @@ namespace mailcore { virtual void connect(ErrorCode * pError); virtual void disconnect(); + + /** Aborts the command currently running on this session by cancelling its stream: the + blocked read returns immediately instead of waiting out the socket timeout. Safe to call + from another thread while the session's thread is blocked in a command. + Teardown only: the cancelled state of a stream is never reset, so the session is unusable + afterwards and must be disconnected. A later connect() builds a fresh stream. */ + virtual void interruptCurrentCommand(); virtual void noop(ErrorCode * pError); diff --git a/src/include/MailCore/MCOperation.h b/src/include/MailCore/MCOperation.h index c0d72d4e1..14737cdc8 100644 --- a/src/include/MailCore/MCOperation.h +++ b/src/include/MailCore/MCOperation.h @@ -20,6 +20,12 @@ namespace mailcore { virtual OperationCallback * callback(); virtual void cancel(); + + /** Aborts whatever this operation is doing right now. Called by the queue, and only while + this operation is the one it is executing - so an implementation may assume it owns the + resource it is about to break. Does nothing by default. */ + virtual void interrupt(); + virtual bool isCancelled(); // Will be called on main thread. diff --git a/src/include/MailCore/MCOperationQueue.h b/src/include/MailCore/MCOperationQueue.h index eefe72f9e..d6d387eac 100644 --- a/src/include/MailCore/MCOperationQueue.h +++ b/src/include/MailCore/MCOperationQueue.h @@ -21,6 +21,17 @@ namespace mailcore { virtual void addOperation(Operation * op); virtual void cancelAllOperations(); + + /** Calls interrupt() on `op` if it is the operation whose main() the queue is executing + right now. The check and the call happen under the queue's lock, so no other operation can + become the running one in between: the interrupt cannot land on whatever started next. + It does not stop `op` itself from finishing - main() runs without the lock - so an + operation that completes just as the caller reaches it gets a harmless interrupt on an + idle stream. Lets a caller abort "the command my operation is running" without the risk + of aborting somebody else's. Returns whether interrupt() was called - a caller that + measures the effect needs to tell "there was a command to break" from "there was + nothing". */ + virtual bool interruptRunningOperation(Operation * op); virtual unsigned int count(); @@ -45,6 +56,7 @@ namespace mailcore { bool mWaiting; struct mailsem * mWaitingFinishedSem; bool mQuitting; + Operation * mRunningOperation; OperationQueueCallback * mCallback; #if MC_HAS_GCD dispatch_queue_t mDispatchQueue; diff --git a/src/swift/imap/IMAPBaseOperation.swift b/src/swift/imap/IMAPBaseOperation.swift index dc022881e..84abb17fb 100644 --- a/src/swift/imap/IMAPBaseOperation.swift +++ b/src/swift/imap/IMAPBaseOperation.swift @@ -26,6 +26,27 @@ public class MCOIMAPBaseOperation : MCOOperation { internal func error() -> ErrorCode { return baseOperation.error() } + + /** + Aborts this operation's IMAP command if it is the one currently running on its connection: the + blocked read returns at once instead of waiting out the socket timeout, so whatever is queued + behind it - a disconnect, above all - runs immediately. Does nothing when this operation is not + the one running. + + Unlike cancel(), which only raises a flag mailcore checks before starting an operation, this + reaches the command already in flight. It costs the connection: the stream stays cancelled and + is rebuilt on next use, so call it for a command being abandoned, never to hurry up one whose + result still matters. + + - Returns: whether a command was actually interrupted, i.e. whether this operation was the one + running. `false` means nothing was holding the connection on its behalf. + */ + @discardableResult + public func interruptCurrentCommand() -> Bool { + return mailCoreAutoreleasePool { + baseOperation.interruptCurrentCommand() + } + } public func itemProgress(current: UInt32, maximum: UInt32) { diff --git a/unittest/IMAPInterruptCurrentCommandTests.swift b/unittest/IMAPInterruptCurrentCommandTests.swift new file mode 100644 index 000000000..427fe09c2 --- /dev/null +++ b/unittest/IMAPInterruptCurrentCommandTests.swift @@ -0,0 +1,223 @@ +// +// IMAPInterruptCurrentCommandTests.swift +// mailcore2 +// +// Tests for IMAPOperation::interruptCurrentCommand(). +// + +// Darwin only: the tests need a POSIX listening socket, and the Android job builds the test target +// without running it. Nothing here is platform-specific beyond that socket. +#if canImport(Darwin) + +import Darwin +import Dispatch +import Foundation +import XCTest + +#if SWIFT_PACKAGE +import CMailCore +#endif + +@testable import MailCore + +/// A TCP endpoint that accepts connections and then says nothing at all. A client connected to it +/// sits in its first read until the socket timeout expires - exactly the state that +/// `interruptCurrentCommand()` has to break, and reproducible without an IMAP server. +private final class SilentTCPEndpoint { + + private let listeningSocket: Int32 + private let acceptQueue = DispatchQueue(label: "SilentTCPEndpoint.accept") + private let lock = NSLock() + private var acceptedSockets: [Int32] = [] + private var isClosed = false + + let port: UInt16 + + init() throws { + // Everything below works on a local descriptor: a closure that touched `listeningSocket` + // would capture self before `port` is initialized. + let fileDescriptor = socket(AF_INET, SOCK_STREAM, 0) + guard fileDescriptor >= 0 else { + throw NSError(domain: "SilentTCPEndpoint", code: Int(errno), userInfo: nil) + } + + var reuse: Int32 = 1 + setsockopt(fileDescriptor, SOL_SOCKET, SO_REUSEADDR, &reuse, socklen_t(MemoryLayout.size)) + + var address = sockaddr_in() + address.sin_family = sa_family_t(AF_INET) + address.sin_port = 0 // any free port + address.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1")) + + let bound = withUnsafePointer(to: &address) { pointer -> Int32 in + return pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPointer in + return bind(fileDescriptor, sockaddrPointer, socklen_t(MemoryLayout.size)) + } + } + + guard bound == 0, listen(fileDescriptor, 8) == 0 else { + close(fileDescriptor) + throw NSError(domain: "SilentTCPEndpoint", code: Int(errno), userInfo: nil) + } + + var boundAddress = sockaddr_in() + var length = socklen_t(MemoryLayout.size) + let named = withUnsafeMutablePointer(to: &boundAddress) { pointer -> Int32 in + return pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPointer in + return getsockname(fileDescriptor, sockaddrPointer, &length) + } + } + + guard named == 0 else { + close(fileDescriptor) + throw NSError(domain: "SilentTCPEndpoint", code: Int(errno), userInfo: nil) + } + + listeningSocket = fileDescriptor + port = UInt16(bigEndian: boundAddress.sin_port) + + acceptQueue.async { [weak self] in + self?.acceptConnections() + } + } + + private func acceptConnections() { + while true { + let accepted = accept(listeningSocket, nil, nil) + guard accepted >= 0 else { + return // the listening socket was closed + } + + // Held open and silent on purpose. + lock.lock() + let closed = isClosed + if closed { + lock.unlock() + Darwin.close(accepted) + return + } + acceptedSockets.append(accepted) + lock.unlock() + } + } + + func stop() { + lock.lock() + guard !isClosed else { + lock.unlock() + return + } + isClosed = true + let sockets = acceptedSockets + acceptedSockets = [] + lock.unlock() + + Darwin.close(listeningSocket) + for accepted in sockets { + Darwin.close(accepted) + } + } +} + +final class IMAPInterruptCurrentCommandTests: XCTestCase { + + /// Well above every wait below: a command left to its own devices must not be able to finish on + /// its own and pass a test that is about being interrupted. + private let sessionTimeout: TimeInterval = 60 + + private func makeSession(port: UInt16) -> MCOIMAPSession { + let session = MCOIMAPSession() + session.hostname = "127.0.0.1" + session.port = UInt32(port) + session.connectionType = ConnectionTypeClear + session.username = "user" + session.password = "password" + session.timeout = sessionTimeout + session.maximumConnections = 1 + return session + } + + /// Runs the test body off the main thread while the main thread keeps spinning its run loop. + /// mailcore hands parts of an operation's lifecycle to the main queue and waits for them, so a + /// test that blocks the main thread never gets its operation started in the first place. + private func runOffMainThread(timeout: TimeInterval, _ body: @escaping () -> Void) { + let finished = expectation(description: "test body") + + DispatchQueue.global(qos: .userInitiated).async { + body() + finished.fulfill() + } + + waitForExpectations(timeout: timeout) + } + + private func start(_ operation: MCOIMAPOperation) -> DispatchSemaphore { + let finished = DispatchSemaphore(value: 0) + operation.start { _ in + finished.signal() + } + return finished + } + + func testInterruptEndsTheWaitOfTheRunningCommand() throws { + let endpoint = try SilentTCPEndpoint() + defer { endpoint.stop() } + + let session = makeSession(port: endpoint.port) + + runOffMainThread(timeout: 30) { + let operation = session.connectOperation() + let finished = self.start(operation) + + // The endpoint accepts the connection and stays silent, so the command is stuck reading + // the greeting. + XCTAssertEqual(finished.wait(timeout: .now() + 2), .timedOut, + "The command was expected to be blocked on the socket") + + XCTAssertTrue(operation.interruptCurrentCommand(), + "The running operation was expected to report that it interrupted a command") + + // Left alone this would return only when the 60s socket timeout expires. + XCTAssertEqual(finished.wait(timeout: .now() + 10), .success, + "interruptCurrentCommand() did not unblock the running command") + } + } + + func testInterruptDoesNothingForAnOperationThatIsNotRunning() throws { + let endpoint = try SilentTCPEndpoint() + defer { endpoint.stop() } + + let session = makeSession(port: endpoint.port) + + runOffMainThread(timeout: 60) { + // One connection, so the second operation waits in the queue while the first one blocks. + let running = session.connectOperation() + let runningFinished = self.start(running) + + XCTAssertEqual(runningFinished.wait(timeout: .now() + 2), .timedOut, + "The first command was expected to be blocked on the socket") + + let queued = session.noopOperation() + let queuedFinished = self.start(queued) + + // The queued operation owns no connection, so interrupting on its behalf must not touch + // the stream the running one is blocked on. + XCTAssertFalse(queued.interruptCurrentCommand(), + "A queued operation has no command of its own to interrupt") + + XCTAssertEqual(runningFinished.wait(timeout: .now() + 3), .timedOut, + "A queued operation must not interrupt the command of the running one") + + // ... while the running operation can still be interrupted itself. + XCTAssertTrue(running.interruptCurrentCommand(), + "The running operation was expected to report that it interrupted a command") + + XCTAssertEqual(runningFinished.wait(timeout: .now() + 10), .success, + "interruptCurrentCommand() did not unblock the running command") + XCTAssertEqual(queuedFinished.wait(timeout: .now() + 10), .success, + "The queued operation was expected to finish once the connection was freed") + } + } +} + +#endif