diff --git a/Package.swift b/Package.swift index f107c34c8..ce136f90e 100644 --- a/Package.swift +++ b/Package.swift @@ -265,7 +265,7 @@ var targets: [Target] = [ "unittest.cpp", "unittest.mm" ], - sources: ["CertificateUtilsTests.swift", "IMAPConnectionLeaseTests.swift", "IMAPConnectionOwnerLifetimeTests.swift", "IMAPIdleCancellationTests.swift", "IMAPInterruptCurrentCommandTests.swift", "IMAPLoginTests.swift", "LeaseTestTCPEndpoint.swift", "LibetpanHelperTests.swift", "unittest.swift"], + sources: ["CertificateUtilsTests.swift", "IMAPAutomaticDisconnectDelayTests.swift", "IMAPConnectionLeaseTests.swift", "IMAPConnectionOwnerLifetimeTests.swift", "IMAPIdleCancellationTests.swift", "IMAPInterruptCurrentCommandTests.swift", "IMAPLoginTests.swift", "LeaseTestTCPEndpoint.swift", "LibetpanHelperTests.swift", "unittest.swift"], resources: [ .copy("data") ] diff --git a/src/async/imap/MCIMAPAsyncConnection.cpp b/src/async/imap/MCIMAPAsyncConnection.cpp index fbcf95f97..36fc9eb56 100644 --- a/src/async/imap/MCIMAPAsyncConnection.cpp +++ b/src/async/imap/MCIMAPAsyncConnection.cpp @@ -115,7 +115,6 @@ IMAPAsyncConnection::IMAPAsyncConnection() mScheduledAutomaticDisconnect = false; mReserved = false; mLeaseGeneration = 0; - mAutomaticDisconnectDelay = 30; } IMAPAsyncConnection::~IMAPAsyncConnection() @@ -363,9 +362,11 @@ bool IMAPAsyncConnection::isReserved() return reserved; } -void IMAPAsyncConnection::setAutomaticDisconnectDelay(time_t delay) +void IMAPAsyncConnection::restartAutomaticDisconnect() { - mAutomaticDisconnectDelay = delay; + if (mScheduledAutomaticDisconnect) { + tryAutomaticDisconnect(); + } } bool IMAPAsyncConnection::interruptCurrentCommand(IMAPOperation * operation) @@ -413,9 +414,9 @@ void IMAPAsyncConnection::tryAutomaticDisconnect() mOwner->retain(); mScheduledAutomaticDisconnect = true; #if MC_HAS_GCD - performMethodOnDispatchQueueAfterDelay((Object::Method) &IMAPAsyncConnection::tryAutomaticDisconnectAfterDelay, NULL, dispatchQueue(), (double) mAutomaticDisconnectDelay); + performMethodOnDispatchQueueAfterDelay((Object::Method) &IMAPAsyncConnection::tryAutomaticDisconnectAfterDelay, NULL, dispatchQueue(), mOwner->automaticDisconnectDelay()); #else - performMethodAfterDelay((Object::Method) &IMAPAsyncConnection::tryAutomaticDisconnectAfterDelay, NULL, (double) mAutomaticDisconnectDelay); + performMethodAfterDelay((Object::Method) &IMAPAsyncConnection::tryAutomaticDisconnectAfterDelay, NULL, mOwner->automaticDisconnectDelay()); #endif if (scheduledAutomaticDisconnect) { diff --git a/src/async/imap/MCIMAPAsyncConnection.h b/src/async/imap/MCIMAPAsyncConnection.h index 630332c56..bdd68b3ac 100644 --- a/src/async/imap/MCIMAPAsyncConnection.h +++ b/src/async/imap/MCIMAPAsyncConnection.h @@ -93,8 +93,9 @@ namespace mailcore { virtual IMAPOperation * disconnectOperation(); - // How long an idle connection stays open once its queue drains. - virtual void setAutomaticDisconnectDelay(time_t delay); + // Dispatch queue only. An idle timer already running starts over with the owner's current + // automaticDisconnectDelay. + virtual void restartAutomaticDisconnect(); private: IMAPSession * mSession; @@ -115,7 +116,6 @@ namespace mailcore { MCB_LOCK_TYPE mReservationLock; bool mReserved; unsigned int mLeaseGeneration; - time_t mAutomaticDisconnectDelay; virtual void tryAutomaticDisconnectAfterDelay(void * context); virtual void scheduleAutomaticDisconnectOnQueue(void * context); diff --git a/src/async/imap/MCIMAPAsyncSession.cpp b/src/async/imap/MCIMAPAsyncSession.cpp index a0b047c6b..05e25c8c8 100644 --- a/src/async/imap/MCIMAPAsyncSession.cpp +++ b/src/async/imap/MCIMAPAsyncSession.cpp @@ -56,6 +56,7 @@ IMAPAsyncSession::IMAPAsyncSession() mMaximumConnections = DEFAULT_MAX_CONNECTIONS; mAllowsFolderConcurrentAccessEnabled = true; mAutomaticDisconnectDelay = 30; + MCB_LOCK_INIT(&mAutomaticDisconnectDelayLock); mHostname = NULL; mPort = 0; @@ -84,6 +85,7 @@ IMAPAsyncSession::IMAPAsyncSession() IMAPAsyncSession::~IMAPAsyncSession() { + MCB_LOCK_DESTROY(&mAutomaticDisconnectDelayLock); #if MC_HAS_GCD if (mDispatchQueue != NULL) { dispatch_release(mDispatchQueue); @@ -240,14 +242,37 @@ unsigned int IMAPAsyncSession::maximumConnections() return mMaximumConnections; } -void IMAPAsyncSession::setAutomaticDisconnectDelay(time_t delay) +void IMAPAsyncSession::setAutomaticDisconnectDelay(double delay) { + MCB_LOCK(&mAutomaticDisconnectDelayLock); mAutomaticDisconnectDelay = delay; + MCB_UNLOCK(&mAutomaticDisconnectDelayLock); + + // The pool and the idle timers belong to the dispatch queue. Kept alive until the hop lands, + // like the connection's own hop in scheduleAutomaticDisconnect. + retain(); +#if MC_HAS_GCD + performMethodOnDispatchQueue((Object::Method) &IMAPAsyncSession::applyAutomaticDisconnectDelay, NULL, dispatchQueue()); +#else + performMethodOnMainThread((Object::Method) &IMAPAsyncSession::applyAutomaticDisconnectDelay, NULL); +#endif +} + +double IMAPAsyncSession::automaticDisconnectDelay() +{ + MCB_LOCK(&mAutomaticDisconnectDelayLock); + double delay = mAutomaticDisconnectDelay; + MCB_UNLOCK(&mAutomaticDisconnectDelayLock); + return delay; } -time_t IMAPAsyncSession::automaticDisconnectDelay() +void IMAPAsyncSession::applyAutomaticDisconnectDelay(void * context) { - return mAutomaticDisconnectDelay; + for (unsigned int i = 0 ; i < mSessions->count() ; i ++) { + IMAPAsyncConnection * connection = (IMAPAsyncConnection *) mSessions->objectAtIndex(i); + connection->restartAutomaticDisconnect(); + } + release(); } IMAPIdentity * IMAPAsyncSession::serverIdentity() @@ -290,7 +315,6 @@ IMAPAsyncConnection * IMAPAsyncSession::session() session->setAuthType(mAuthType); session->setConnectionType(mConnectionType); session->setTimeout(mTimeout); - session->setAutomaticDisconnectDelay(mAutomaticDisconnectDelay); session->setCheckCertificateEnabled(mCheckCertificateEnabled); session->setVoIPEnabled(mVoIPEnabled); session->setDefaultNamespace(mDefaultNamespace); diff --git a/src/async/imap/MCIMAPAsyncSession.h b/src/async/imap/MCIMAPAsyncSession.h index 2dfdb9ab3..61191e637 100644 --- a/src/async/imap/MCIMAPAsyncSession.h +++ b/src/async/imap/MCIMAPAsyncSession.h @@ -94,10 +94,11 @@ namespace mailcore { virtual void setMaximumConnections(unsigned int maxConnections); virtual unsigned int maximumConnections(); - // How long an idle connection stays open once its queue drains, in seconds. - // Applied to connections created after the change. - virtual void setAutomaticDisconnectDelay(time_t delay); - virtual time_t automaticDisconnectDelay(); + // How long an idle connection stays open once its queue drains, in seconds. Callable from + // any thread: an idle timer already running starts over with the new delay, on the + // session's dispatch queue. + virtual void setAutomaticDisconnectDelay(double delay); + virtual double automaticDisconnectDelay(); /*! Reserves a connection for exclusive use: the regular per-operation selection stops seeing it, so only operations explicitly pointed at it (IMAPOperation::setSession) run @@ -123,8 +124,9 @@ namespace mailcore { connection to both: the start sees it free, the acquire reserves it, and the operation is already queued. Serialize acquireConnection and releaseConnection with every start() on this session, and do it on the session's own dispatch queue: the pool's - bookkeeping is touched from that queue as well - operationRunningStateChanged() walks - the connection list that a new connection is appended to - so any other queue + bookkeeping is touched from that queue as well - operationRunningStateChanged() and a + change of automaticDisconnectDelay walk the connection list that a new connection is + appended to - so any other queue serializes the callers against each other and against nothing else. */ virtual IMAPAsyncConnection * acquireConnection(String * folder); /*! Returns a reserved connection to the shared pool and re-arms its idle @@ -234,11 +236,14 @@ namespace mailcore { public: // private virtual void automaticConfigurationDone(IMAPSession * session); + virtual void applyAutomaticDisconnectDelay(void * context); virtual void operationRunningStateChanged(); virtual IMAPAsyncConnection * sessionForFolder(String * folder, bool urgent = false); private: Array * mSessions; + // Written on the caller's thread, read by the connections when they arm their idle timer. + MCB_LOCK_TYPE mAutomaticDisconnectDelayLock; String * mHostname; unsigned int mPort; @@ -254,7 +259,7 @@ namespace mailcore { time_t mTimeout; bool mAllowsFolderConcurrentAccessEnabled; unsigned int mMaximumConnections; - time_t mAutomaticDisconnectDelay; + double mAutomaticDisconnectDelay; ConnectionLogger * mConnectionLogger; bool mAutomaticConfigurationDone; IMAPIdentity * mServerIdentity; diff --git a/src/c/imap/CIMAPAsyncSession.cpp b/src/c/imap/CIMAPAsyncSession.cpp index 3665a5542..d09fd0d3a 100644 --- a/src/c/imap/CIMAPAsyncSession.cpp +++ b/src/c/imap/CIMAPAsyncSession.cpp @@ -21,7 +21,7 @@ C_SYNTHESIZE_STRING(setUsername, username) C_SYNTHESIZE_STRING(setPassword, password) C_SYNTHESIZE_ENUM(ConnectionType, mailcore::ConnectionType, setConnectionType, connectionType) C_SYNTHESIZE_SCALAR(time_t, time_t, setTimeout, timeout) -C_SYNTHESIZE_SCALAR(time_t, time_t, setAutomaticDisconnectDelay, automaticDisconnectDelay) +C_SYNTHESIZE_SCALAR(double, double, setAutomaticDisconnectDelay, automaticDisconnectDelay) C_SYNTHESIZE_BOOL(setCheckCertificateEnabled, isCheckCertificateEnabled) C_SYNTHESIZE_STRING(setOAuth2Token, OAuth2Token) C_SYNTHESIZE_ENUM(CAuthType, mailcore::AuthType, setAuthType, authType) diff --git a/src/c/imap/CIMAPAsyncSession.h b/src/c/imap/CIMAPAsyncSession.h index 413ae9595..84f3c93cd 100644 --- a/src/c/imap/CIMAPAsyncSession.h +++ b/src/c/imap/CIMAPAsyncSession.h @@ -71,7 +71,7 @@ extern "C" { C_SYNTHESIZE_PROPERTY_DEFINITION(CIMAPAsyncSession, MailCoreString, OAuth2Token, setOAuth2Token) C_SYNTHESIZE_PROPERTY_DEFINITION(CIMAPAsyncSession, CAuthType, authType, setAuthType) C_SYNTHESIZE_PROPERTY_DEFINITION(CIMAPAsyncSession, unsigned int, maximumConnections, setMaximumConnections) - C_SYNTHESIZE_PROPERTY_DEFINITION(CIMAPAsyncSession, time_t, automaticDisconnectDelay, setAutomaticDisconnectDelay) + C_SYNTHESIZE_PROPERTY_DEFINITION(CIMAPAsyncSession, double, automaticDisconnectDelay, setAutomaticDisconnectDelay) C_SYNTHESIZE_PROPERTY_DEFINITION(CIMAPAsyncSession, bool, allowsFolderConcurrentAccessEnabled, setAllowsFolderConcurrentAccessEnabled) C_SYNTHESIZE_PROPERTY_DEFINITION(CIMAPAsyncSession, CIMAPNamespace, defaultNamespace, setDefaultNamespace) C_SYNTHESIZE_PROPERTY_DEFINITION(CIMAPAsyncSession, CIMAPIdentity, clientIdentity, setClientIdentity) diff --git a/src/include/MailCore/CIMAPAsyncSession.h b/src/include/MailCore/CIMAPAsyncSession.h index 413ae9595..84f3c93cd 100644 --- a/src/include/MailCore/CIMAPAsyncSession.h +++ b/src/include/MailCore/CIMAPAsyncSession.h @@ -71,7 +71,7 @@ extern "C" { C_SYNTHESIZE_PROPERTY_DEFINITION(CIMAPAsyncSession, MailCoreString, OAuth2Token, setOAuth2Token) C_SYNTHESIZE_PROPERTY_DEFINITION(CIMAPAsyncSession, CAuthType, authType, setAuthType) C_SYNTHESIZE_PROPERTY_DEFINITION(CIMAPAsyncSession, unsigned int, maximumConnections, setMaximumConnections) - C_SYNTHESIZE_PROPERTY_DEFINITION(CIMAPAsyncSession, time_t, automaticDisconnectDelay, setAutomaticDisconnectDelay) + C_SYNTHESIZE_PROPERTY_DEFINITION(CIMAPAsyncSession, double, automaticDisconnectDelay, setAutomaticDisconnectDelay) C_SYNTHESIZE_PROPERTY_DEFINITION(CIMAPAsyncSession, bool, allowsFolderConcurrentAccessEnabled, setAllowsFolderConcurrentAccessEnabled) C_SYNTHESIZE_PROPERTY_DEFINITION(CIMAPAsyncSession, CIMAPNamespace, defaultNamespace, setDefaultNamespace) C_SYNTHESIZE_PROPERTY_DEFINITION(CIMAPAsyncSession, CIMAPIdentity, clientIdentity, setClientIdentity) diff --git a/src/include/MailCore/MCIMAPAsyncConnection.h b/src/include/MailCore/MCIMAPAsyncConnection.h index 630332c56..bdd68b3ac 100644 --- a/src/include/MailCore/MCIMAPAsyncConnection.h +++ b/src/include/MailCore/MCIMAPAsyncConnection.h @@ -93,8 +93,9 @@ namespace mailcore { virtual IMAPOperation * disconnectOperation(); - // How long an idle connection stays open once its queue drains. - virtual void setAutomaticDisconnectDelay(time_t delay); + // Dispatch queue only. An idle timer already running starts over with the owner's current + // automaticDisconnectDelay. + virtual void restartAutomaticDisconnect(); private: IMAPSession * mSession; @@ -115,7 +116,6 @@ namespace mailcore { MCB_LOCK_TYPE mReservationLock; bool mReserved; unsigned int mLeaseGeneration; - time_t mAutomaticDisconnectDelay; virtual void tryAutomaticDisconnectAfterDelay(void * context); virtual void scheduleAutomaticDisconnectOnQueue(void * context); diff --git a/src/include/MailCore/MCIMAPAsyncSession.h b/src/include/MailCore/MCIMAPAsyncSession.h index 2dfdb9ab3..61191e637 100644 --- a/src/include/MailCore/MCIMAPAsyncSession.h +++ b/src/include/MailCore/MCIMAPAsyncSession.h @@ -94,10 +94,11 @@ namespace mailcore { virtual void setMaximumConnections(unsigned int maxConnections); virtual unsigned int maximumConnections(); - // How long an idle connection stays open once its queue drains, in seconds. - // Applied to connections created after the change. - virtual void setAutomaticDisconnectDelay(time_t delay); - virtual time_t automaticDisconnectDelay(); + // How long an idle connection stays open once its queue drains, in seconds. Callable from + // any thread: an idle timer already running starts over with the new delay, on the + // session's dispatch queue. + virtual void setAutomaticDisconnectDelay(double delay); + virtual double automaticDisconnectDelay(); /*! Reserves a connection for exclusive use: the regular per-operation selection stops seeing it, so only operations explicitly pointed at it (IMAPOperation::setSession) run @@ -123,8 +124,9 @@ namespace mailcore { connection to both: the start sees it free, the acquire reserves it, and the operation is already queued. Serialize acquireConnection and releaseConnection with every start() on this session, and do it on the session's own dispatch queue: the pool's - bookkeeping is touched from that queue as well - operationRunningStateChanged() walks - the connection list that a new connection is appended to - so any other queue + bookkeeping is touched from that queue as well - operationRunningStateChanged() and a + change of automaticDisconnectDelay walk the connection list that a new connection is + appended to - so any other queue serializes the callers against each other and against nothing else. */ virtual IMAPAsyncConnection * acquireConnection(String * folder); /*! Returns a reserved connection to the shared pool and re-arms its idle @@ -234,11 +236,14 @@ namespace mailcore { public: // private virtual void automaticConfigurationDone(IMAPSession * session); + virtual void applyAutomaticDisconnectDelay(void * context); virtual void operationRunningStateChanged(); virtual IMAPAsyncConnection * sessionForFolder(String * folder, bool urgent = false); private: Array * mSessions; + // Written on the caller's thread, read by the connections when they arm their idle timer. + MCB_LOCK_TYPE mAutomaticDisconnectDelayLock; String * mHostname; unsigned int mPort; @@ -254,7 +259,7 @@ namespace mailcore { time_t mTimeout; bool mAllowsFolderConcurrentAccessEnabled; unsigned int mMaximumConnections; - time_t mAutomaticDisconnectDelay; + double mAutomaticDisconnectDelay; ConnectionLogger * mConnectionLogger; bool mAutomaticConfigurationDone; IMAPIdentity * mServerIdentity; diff --git a/src/swift/imap/IMAPSession.swift b/src/swift/imap/IMAPSession.swift index 90f568c7d..ddafe9b90 100644 --- a/src/swift/imap/IMAPSession.swift +++ b/src/swift/imap/IMAPSession.swift @@ -144,6 +144,15 @@ public class MCOIMAPSession: NSObjectCompat { set { session.maximumConnections = newValue } } + /** + How long an idle connection stays open once its work is done, in seconds. Settable from any + thread at any time: an idle timer already running starts over with the new delay. + */ + public var automaticDisconnectDelay: TimeInterval { + get { return session.automaticDisconnectDelay } + set { session.automaticDisconnectDelay = newValue } + } + /** Reserves one connection of the pool for exclusive use: the regular per-operation selection stops seeing it, so new commands run on it only when explicitly pointed at it with diff --git a/unittest/IMAPAutomaticDisconnectDelayTests.swift b/unittest/IMAPAutomaticDisconnectDelayTests.swift new file mode 100644 index 000000000..751185181 --- /dev/null +++ b/unittest/IMAPAutomaticDisconnectDelayTests.swift @@ -0,0 +1,171 @@ +// +// IMAPAutomaticDisconnectDelayTests.swift +// mailcore2 +// +// Tests for changing IMAPAsyncSession's automaticDisconnectDelay while connections are open. +// + +// Darwin only: the tests need a POSIX listening socket, and the Android job builds the test target +// without running it. +#if canImport(Darwin) + +import Darwin +import Dispatch +import Foundation +import XCTest + +#if SWIFT_PACKAGE +import CMailCore +#endif + +@testable import MailCore + +final class IMAPAutomaticDisconnectDelayTests: XCTestCase { + + private static let greeting = "* OK [CAPABILITY IMAP4rev1] LeaseTestTCPEndpoint ready\r\n" + + /// A connection's queue reports that it ran dry about a second after its last operation + /// (OperationQueue::checkRunningAfterDelay), and only then is the idle timer armed. + private func waitForTheIdleTimer() { + Thread.sleep(forTimeInterval: 2) + } + + 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 = 60 + session.maximumConnections = 1 + return session + } + + /// mailcore hands parts of an operation's lifecycle to the main queue and waits for them, so + /// the body runs off the main thread while the main thread spins its run loop. + 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 run(_ operation: MCOIMAPOperation) -> Error? { + var error: Error? + let finished = DispatchSemaphore(value: 0) + operation.start { operationError in + error = operationError + finished.signal() + } + XCTAssertEqual(finished.wait(timeout: .now() + 5), .success, "The operation was expected to finish") + return error + } + + func testShorterDelayClosesAConnectionArmedWithTheDefault() throws { + let endpoint = try LeaseTestTCPEndpoint(greeting: Self.greeting) + defer { endpoint.stop() } + let session = makeSession(port: endpoint.port) + + runOffMainThread(timeout: 30) { + XCTAssertNil(self.run(session.connectOperation())) + self.waitForTheIdleTimer() // armed with the default 30s + + session.automaticDisconnectDelay = 0.5 + + XCTAssertTrue(endpoint.waitForClientDisconnect(timeout: 3), + "The running idle timer was expected to restart with the new delay") + } + } + + func testSubSecondDelayIsNotRoundedDown() throws { + let endpoint = try LeaseTestTCPEndpoint(greeting: Self.greeting) + defer { endpoint.stop() } + let session = makeSession(port: endpoint.port) + + runOffMainThread(timeout: 30) { + XCTAssertNil(self.run(session.connectOperation())) + self.waitForTheIdleTimer() + + session.automaticDisconnectDelay = 0.8 + + XCTAssertFalse(endpoint.waitForClientDisconnect(timeout: 0.3), + "Whole seconds would have turned 0.8 into an immediate disconnect") + XCTAssertTrue(endpoint.waitForClientDisconnect(timeout: 3)) + } + } + + func testDelayChangedDuringACommandWaitsForTheCommand() throws { + var session: MCOIMAPSession! + let endpoint = try LeaseTestTCPEndpoint(greeting: Self.greeting, + answers: ["LOGIN": "", + "CAPABILITY": "* CAPABILITY IMAP4rev1\r\n", + "LIST": "* LIST (\\Noselect) \"/\" \"\"\r\n", + "NOOP": ""], + beforeAnswering: ["NOOP": { + session.automaticDisconnectDelay = 0.2 + // Well past the new delay, with the command still on the wire. + Thread.sleep(forTimeInterval: 0.8) + }]) + defer { endpoint.stop() } + session = makeSession(port: endpoint.port) + + runOffMainThread(timeout: 30) { + XCTAssertNil(self.run(session.noopOperation()), "The command must not be cut by the new delay") + + XCTAssertTrue(endpoint.waitForClientDisconnect(timeout: 3), + "Once the command is done, the connection idles out with the new delay") + } + } + + func testLeasedConnectionWaitsForItsRelease() throws { + let endpoint = try LeaseTestTCPEndpoint(greeting: Self.greeting) + defer { endpoint.stop() } + let session = makeSession(port: endpoint.port) + + guard let leased = session.acquireConnection(folder: nil) else { + return XCTFail("An empty pool with room for 1 connection must satisfy the lease") + } + + runOffMainThread(timeout: 30) { + let connect = session.connectOperation() + connect.setConnection(leased) + XCTAssertNil(self.run(connect)) + + session.automaticDisconnectDelay = 0.3 + + XCTAssertFalse(endpoint.waitForClientDisconnect(timeout: 1.5), + "A leased connection stays open whatever the delay") + + session.releaseConnection(leased, disconnect: false) + + XCTAssertTrue(endpoint.waitForClientDisconnect(timeout: 3), + "The release re-arms the idle timer with the new delay") + } + } + + func testConcurrentChangesSettleOnTheLastOne() throws { + let endpoint = try LeaseTestTCPEndpoint(greeting: Self.greeting) + defer { endpoint.stop() } + let session = makeSession(port: endpoint.port) + + runOffMainThread(timeout: 30) { + XCTAssertNil(self.run(session.connectOperation())) + self.waitForTheIdleTimer() + + // Every change restarts the running idle timer. + DispatchQueue.concurrentPerform(iterations: 200) { index in + session.automaticDisconnectDelay = index.isMultiple(of: 2) ? 30 : 60 + } + XCTAssertFalse(endpoint.waitForClientDisconnect(timeout: 1)) + + session.automaticDisconnectDelay = 0.3 + + XCTAssertTrue(endpoint.waitForClientDisconnect(timeout: 3)) + } + } +} + +#endif