1
0
Fork 0
mirror of https://github.com/juce-framework/JUCE.git synced 2026-01-10 23:44:24 +00:00
JUCE/modules/juce_midi_ci/ci/juce_CISubscriptionManager.cpp
reuk 60757de2f2
CIDevice: Improve robustness of subscription API
The old API only allowed cancelling property "get" inquiries and
subscription updates. However, there are use-cases for cancelling other
requests too. e.g. switching between views in a JUCE app might mean that
it's no longer necessary to subscribe to a particular property.

Cancelling subscriptions ends up being quite involved. Different
handling is needed depending on whether the subscription is cancelled
before or after the responder replies to the initial request.
In addition, the responder may ask the initiator to retry a subscription
begin request.
2024-01-18 10:37:17 +00:00

812 lines
31 KiB
C++

/*
==============================================================================
This file is part of the JUCE library.
Copyright (c) 2022 - Raw Material Software Limited
JUCE is an open source library subject to commercial or open-source
licensing.
By using JUCE, you agree to the terms of both the JUCE 7 End-User License
Agreement and JUCE Privacy Policy.
End User License Agreement: www.juce.com/juce-7-licence
Privacy Policy: www.juce.com/juce-privacy-policy
Or: You may also use this code under the terms of the GPL v3 (see
www.gnu.org/licenses).
JUCE IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER
EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE
DISCLAIMED.
==============================================================================
*/
namespace juce::midi_ci
{
struct RequestRetryQueueEntry
{
PropertySubscriptionHeader msg;
Token64 key{}; ///< A unique identifier for this message
bool inFlight = false; ///< True if the message has been sent and we're waiting for a reply, false otherwise
};
/*
A queue to store pending property exchange messages.
A property exchange message may fail to send because the initiator doesn't have enough vacant
property exchange IDs.
Similarly, if the responder doesn't have enough vacant IDs, then it may tell us to retry the
request.
We store messages that we're planning to send, and mark them as in-flight once we've attempted
to send them.
We always try to send the first not-in-flight message in the queue.
If the responder informs us that the message was actioned, or there was an unrecoverable error,
then we can remove the message from the queue. We can also remove the message if the user
decides that the message is no longer important.
Otherwise, if the message wasn't sent successfully, we leave the message at its current
position in the queue, and mark it as not-in-flight again.
*/
class RequestRetryQueue
{
using Entry = RequestRetryQueueEntry;
private:
auto getIter (Token64 k)
{
const auto iter = std::lower_bound (entries.begin(),
entries.end(),
(uint64_t) k,
[] (const Entry& e, uint64_t v) { return (uint64_t) e.key < v; });
return iter != entries.end() && iter->key == k ? iter : entries.end();
}
public:
/* Add a new message at the end of the queue, and return the entry for that message. */
Entry* add (PropertySubscriptionHeader msg)
{
const auto key = ++lastKey;
entries.push_back (Entry { std::move (msg), Token64 { key }, false });
return &entries.back();
}
/* Erase the entry for a given key. */
std::optional<Entry> erase (Token64 k)
{
const auto iter = getIter (k);
if (iter == entries.end())
return {};
auto result = std::move (*iter);
entries.erase (iter);
return result;
}
/* Find the next entry that should be sent, and return it after marking it as in-flight. */
const Entry* markNextInFlight()
{
const auto iter = std::find_if (entries.begin(), entries.end(), [] (const Entry& e) { return ! e.inFlight; });
if (iter == entries.end())
return nullptr;
iter->inFlight = true;
return &*iter;
}
void markNotInFlight (Token64 k)
{
const auto iter = getIter (k);
if (iter != entries.end())
iter->inFlight = false;
}
private:
std::vector<Entry> entries;
uint64_t lastKey = 0;
};
/**
Info about a particular subscription.
You can think of this as a subscription agreement as identified by a subscribeId, but this
also holds state that is necessary to negotiate the subscribeId.
*/
struct SubscriptionState
{
// If we're waiting to send this subscription request, this is monostate
// If the request has been sent, but we haven't received a reply, this is the id of the request
// If the subscription started successfully, this is the subscribeId for the subscription
std::variant<std::monostate, Token64, String> state;
String resource;
};
/**
Info about all the subscriptions requested of a particular device/MUID.
This keeps track of the order in which subscription requests are made, so that requests can
be re-tried in order if the initial sending of a request fails.
*/
class DeviceSubscriptionStates
{
public:
Token64 postToQueue (const PropertySubscriptionHeader& header)
{
return queue.add (header)->key;
}
Token64 beginSubscription (const PropertySubscriptionHeader& header)
{
jassert (header.command == PropertySubscriptionCommand::start);
auto headerCopy = header;
headerCopy.command = PropertySubscriptionCommand::start;
const auto key = postToQueue (headerCopy);
stateForSubscription[key].resource = headerCopy.resource;
return key;
}
std::optional<SubscriptionState> endSubscription (Token64 key)
{
queue.erase (key);
const auto iter = stateForSubscription.find (key);
if (iter == stateForSubscription.end())
return {};
auto subInfo = iter->second;
stateForSubscription.erase (iter);
return { std::move (subInfo) };
}
std::vector<Token64> endSubscription (String subscribeId)
{
std::vector<Token64> ended;
for (auto it = stateForSubscription.begin(); it != stateForSubscription.end();)
{
if (const auto* id = std::get_if<String> (&it->second.state))
{
if (*id == subscribeId)
{
ended.push_back (it->first);
queue.erase (it->first);
it = stateForSubscription.erase (it);
continue;
}
}
++it;
}
return ended;
}
void endAll()
{
for (auto& item : stateForSubscription)
queue.erase (item.first);
stateForSubscription.clear();
}
void resetKey (Token64 key)
{
const auto iter = stateForSubscription.find (key);
if (iter != stateForSubscription.end())
iter->second.state = std::monostate{};
queue.markNotInFlight (key);
}
void setRequestIdForKey (Token64 key, Token64 request)
{
const auto iter = stateForSubscription.find (key);
if (iter != stateForSubscription.end())
iter->second.state = request;
}
void setSubscribeIdForKey (Token64 key, String subscribeId)
{
const auto iter = stateForSubscription.find (key);
if (iter != stateForSubscription.end())
iter->second.state = subscribeId;
queue.erase (key);
}
auto* markNextInFlight()
{
return queue.markNextInFlight();
}
std::optional<SubscriptionState> getInfoForSubscriptionKey (Token64 key) const
{
const auto iter = stateForSubscription.find (key);
if (iter != stateForSubscription.end())
return iter->second;
return {};
}
auto begin() const { return stateForSubscription.begin(); }
auto end() const { return stateForSubscription.end(); }
private:
RequestRetryQueue queue;
std::map<Token64, SubscriptionState> stateForSubscription;
};
class SubscriptionManager::Impl : public std::enable_shared_from_this<Impl>,
private DeviceListener
{
public:
explicit Impl (SubscriptionManagerDelegate& d)
: delegate (d) {}
SubscriptionKey beginSubscription (MUID m, const PropertySubscriptionHeader& header)
{
const auto key = infoForMuid[m].beginSubscription (header);
sendPendingMessages();
return SubscriptionKey { m, key };
}
void endSubscription (SubscriptionKey key)
{
const auto iter = infoForMuid.find (key.getMuid());
if (iter == infoForMuid.end())
return;
const auto ended = iter->second.endSubscription (key.getKey());
if (! ended.has_value())
return;
if (auto* request = std::get_if<Token64> (&ended->state))
{
delegate.abortPropertyRequest ({ key.getMuid(), *request });
}
else if (auto* subscribeId = std::get_if<String> (&ended->state))
{
PropertySubscriptionHeader header;
header.command = PropertySubscriptionCommand::end;
header.subscribeId = *subscribeId;
iter->second.postToQueue (header);
sendPendingMessages();
}
}
void endSubscriptionFromResponder (MUID m, String sub)
{
const auto iter = infoForMuid.find (m);
if (iter != infoForMuid.end())
for (const auto& ended : iter->second.endSubscription (sub))
delegate.propertySubscriptionChanged ({ m, ended }, std::nullopt);
}
void endSubscriptionsFromResponder (MUID m)
{
const auto iter = infoForMuid.find (m);
if (iter == infoForMuid.end())
return;
std::vector<Token64> tokens;
std::transform (iter->second.begin(),
iter->second.end(),
std::back_inserter (tokens),
[] (const auto& p) { return p.first; });
iter->second.endAll();
for (const auto& ended : tokens)
delegate.propertySubscriptionChanged ({ m, ended }, std::nullopt);
}
std::vector<SubscriptionKey> getOngoingSubscriptions() const
{
std::vector<SubscriptionKey> result;
for (const auto& pair : infoForMuid)
for (const auto& info : pair.second)
result.emplace_back (pair.first, info.first);
return result;
}
std::optional<SubscriptionState> getInfoForSubscriptionKey (SubscriptionKey key) const
{
const auto iter = infoForMuid.find (key.getMuid());
if (iter != infoForMuid.end())
return iter->second.getInfoForSubscriptionKey (key.getKey());
return {};
}
bool sendPendingMessages()
{
// Note: not using any_of here because we don't want the early-exit behaviour
bool result = true;
for (auto& pair : infoForMuid)
result &= sendPendingMessages (pair.first, pair.second);
return result;
}
private:
void handleReply (SubscriptionKey subscriptionKey, PropertySubscriptionCommand command, const PropertyExchangeResult& r)
{
const auto iter = infoForMuid.find (subscriptionKey.getMuid());
if (iter == infoForMuid.end())
return;
auto& second = iter->second;
if (const auto error = r.getError())
{
// If the responder requested a retry, keep the message in the queue so that
// it can be re-sent
if (*error == PropertyExchangeResult::Error::tooManyTransactions)
{
second.resetKey (subscriptionKey.getKey());
return;
}
// We tried to begin or end a subscription, but the responder said no!
// If the responder declined to start a subscription, we can just
// mark the subscription as ended.
// If the responder declined to end a subscription, that's a bit trickier.
// Hopefully this won't happen in practice, because all the options to resolve are pretty bad:
// - One option is to ignore the failure. The remote device can carry on sending us updates.
// This might be a bit dangerous if we repeatedly subscribe and then fail to unsubscribe, as this
// would result in lots of redundant subscription messages that could clog the connection.
// - Another option is to store the subscription-end request and to attempt to send it again later.
// This also has the potential to clog up the connection, depending on how frequently we attempt
// to re-send failed messages. Given that unsubscribing has already failed once, there's no
// guarantee that any future attempts will succeed, so we might end up in a loop, sending the
// same message over and over.
// On balance, I think the former option is best for now. If this ends up being an issue in
// practice, perhaps we could add a mechanism to do exponential back-off, but that would
// add complexity that isn't necessarily required.
jassert (*error != PropertyExchangeResult::Error::notify);
// If we failed to begin a subscription, then the subscription never started,
// and we should remove it from the set of ongoing subscriptions.
second.endSubscription (subscriptionKey.getKey());
// We only need to alert the delegate if the subscription failed to start.
// If the subscription fails to end, we'll treat the subscription as ended anyway.
if (command == PropertySubscriptionCommand::start)
delegate.propertySubscriptionChanged (subscriptionKey, std::nullopt);
return;
}
if (command == PropertySubscriptionCommand::start)
{
second.setSubscribeIdForKey (subscriptionKey.getKey(), r.getHeaderAsSubscriptionHeader().subscribeId);
delegate.propertySubscriptionChanged (subscriptionKey, r.getHeaderAsSubscriptionHeader().subscribeId);
}
}
bool sendPendingMessages (MUID m, DeviceSubscriptionStates& info)
{
while (auto* entry = info.markNextInFlight())
{
const auto requestKind = entry->msg.command;
const SubscriptionKey subscriptionKey { m, entry->key };
auto cb = [weak = weak_from_this(), requestKind, subscriptionKey] (const PropertyExchangeResult& r)
{
if (const auto locked = weak.lock())
locked->handleReply (subscriptionKey, requestKind, r);
};
if (const auto request = delegate.sendPropertySubscribe (m, entry->msg, std::move (cb)))
{
if (entry->msg.command == PropertySubscriptionCommand::start)
info.setRequestIdForKey (entry->key, request->getKey());
}
else
{
// Couldn't find a valid ID to use, so we must have exhausted all message slots.
// There's no point trying to send the rest of the messages that are queued for this
// MUID, so give up. It's probably a good idea to try again in a bit.
info.resetKey (entry->key);
return false;
}
}
return true;
}
SubscriptionManagerDelegate& delegate;
std::map<MUID, DeviceSubscriptionStates> infoForMuid;
};
//==============================================================================
SubscriptionManager::SubscriptionManager (SubscriptionManagerDelegate& delegate)
: pimpl (std::make_shared<Impl> (delegate)) {}
SubscriptionKey SubscriptionManager::beginSubscription (MUID m, const PropertySubscriptionHeader& header)
{
return pimpl->beginSubscription (m, header);
}
void SubscriptionManager::endSubscription (SubscriptionKey key)
{
pimpl->endSubscription (key);
}
void SubscriptionManager::endSubscriptionFromResponder (MUID m, String sub)
{
pimpl->endSubscriptionFromResponder (m, sub);
}
void SubscriptionManager::endSubscriptionsFromResponder (MUID m)
{
pimpl->endSubscriptionsFromResponder (m);
}
std::vector<SubscriptionKey> SubscriptionManager::getOngoingSubscriptions() const
{
return pimpl->getOngoingSubscriptions();
}
std::optional<String> SubscriptionManager::getSubscribeIdForKey (SubscriptionKey key) const
{
if (const auto info = pimpl->getInfoForSubscriptionKey (key))
if (const auto* subscribeId = std::get_if<String> (&info->state))
return *subscribeId;
return {};
}
std::optional<String> SubscriptionManager::getResourceForKey (SubscriptionKey key) const
{
if (const auto info = pimpl->getInfoForSubscriptionKey (key))
return info->resource;
return {};
}
bool SubscriptionManager::sendPendingMessages()
{
return pimpl->sendPendingMessages();
}
//==============================================================================
//==============================================================================
#if JUCE_UNIT_TESTS
class SubscriptionTests : public UnitTest
{
public:
SubscriptionTests() : UnitTest ("Subscription", UnitTestCategories::midi) {}
void runTest() override
{
auto random = getRandom();
class Delegate : public SubscriptionManagerDelegate
{
public:
std::optional<RequestKey> sendPropertySubscribe (MUID m,
const PropertySubscriptionHeader&,
std::function<void (const PropertyExchangeResult&)> cb) override
{
++sendCount;
if (! sendShouldSucceed)
return {};
const RequestKey key { m, Token64 { ++lastKey } };
callbacks[key] = std::move (cb);
return key;
}
void abortPropertyRequest (RequestKey k) override
{
++abortCount;
callbacks.erase (k);
}
void propertySubscriptionChanged (SubscriptionKey, const std::optional<String>&) override
{
subChanged = true;
}
void setSendShouldSucceed (bool b) { sendShouldSucceed = b; }
void sendResult (RequestKey key, const PropertyExchangeResult& r)
{
const auto iter = callbacks.find (key);
if (iter != callbacks.end())
NullCheckedInvocation::invoke (iter->second, r);
callbacks.erase (key);
}
std::vector<RequestKey> getOngoingRequests() const
{
std::vector<RequestKey> result;
result.reserve (callbacks.size());
std::transform (callbacks.begin(), callbacks.end(), std::back_inserter (result), [] (const auto& p) { return p.first; });
return result;
}
uint64_t getAndClearSendCount() { return std::exchange (sendCount, 0); }
uint64_t getAndClearAbortCount() { return std::exchange (abortCount, 0); }
bool getAndClearSubChanged() { return std::exchange (subChanged, false); }
private:
std::map<RequestKey, std::function<void (const PropertyExchangeResult&)>> callbacks;
uint64_t sendCount = 0, abortCount = 0;
uint64_t lastKey = 0;
bool sendShouldSucceed = true, subChanged = false;
};
Delegate delegate;
SubscriptionManager manager { delegate };
const auto inquiryMUID = MUID::makeRandom (random);
beginTest ("Beginning a subscription and ending it before the remote device replies aborts the request");
{
PropertySubscriptionHeader header;
header.command = PropertySubscriptionCommand::start;
header.resource = "X-CustomProp";
const auto a = manager.beginSubscription (inquiryMUID, header);
expect (manager.getOngoingSubscriptions() == std::vector { a });
expect (delegate.getAndClearSendCount() == 1);
// Sending a subscription request uses a request slot
expect (delegate.getOngoingRequests().size() == 1);
const auto request = delegate.getOngoingRequests().back();
// subscription id is empty until the responder confirms the subscription
expect (manager.getResourceForKey (a) == header.resource);
manager.endSubscription (a);
expect (delegate.getOngoingRequests().empty());
expect (delegate.getAndClearAbortCount() == 1);
expect (manager.getOngoingSubscriptions().empty());
const auto successHeader = []
{
auto ptr = std::make_unique<DynamicObject>();
ptr->setProperty ("status", 200);
ptr->setProperty ("subscribeId", "anId");
return var { ptr.release() };
}();
delegate.sendResult (request, PropertyExchangeResult { successHeader, {} });
// Already ended, the confirmation shouldn't do anything
expect (! delegate.getAndClearSubChanged());
expect (delegate.getOngoingRequests().empty());
expect (delegate.getAndClearSendCount() == 0);
expect (delegate.getAndClearAbortCount() == 0);
// There shouldn't be any queued messages.
expect (manager.sendPendingMessages());
expect (! delegate.getAndClearSubChanged());
expect (delegate.getOngoingRequests().empty());
expect (delegate.getAndClearSendCount() == 0);
expect (delegate.getAndClearAbortCount() == 0);
}
beginTest ("Starting a new subscription while the device is waiting for a previous subscription to be confirmed queues further requests");
{
PropertySubscriptionHeader header;
header.command = PropertySubscriptionCommand::start;
header.resource = "X-CustomProp";
delegate.setSendShouldSucceed (false);
const auto a = manager.beginSubscription (inquiryMUID, header);
expect (delegate.getAndClearSendCount() == 1);
expect (! manager.sendPendingMessages());
expect (delegate.getAndClearSendCount() == 1);
delegate.setSendShouldSucceed (true);
expect (manager.sendPendingMessages());
expect (delegate.getAndClearSendCount() == 1);
delegate.setSendShouldSucceed (false);
const auto b = manager.beginSubscription (inquiryMUID, header);
const auto c = manager.beginSubscription (inquiryMUID, header);
expect (manager.getOngoingSubscriptions() == std::vector { a, b, c });
expect (delegate.getOngoingRequests().size() == 1);
// subscription id is empty until the responder confirms the subscription
expect (manager.getResourceForKey (a) == header.resource);
expect (manager.getResourceForKey (b) == header.resource);
expect (manager.getResourceForKey (c) == header.resource);
expect (delegate.getAndClearSendCount() == 2);
expect (delegate.getAndClearAbortCount() == 0);
delegate.setSendShouldSucceed (true);
// The device has sent a subscription start for a, but not for c,
// so it should send a notify to end subscription a, but shouldn't emit any
// messages related to subscription c.
manager.endSubscription (a);
manager.endSubscription (c);
expect (manager.getOngoingSubscriptions() == std::vector { b });
expect (delegate.getOngoingRequests().empty());
expect (delegate.getAndClearSendCount() == 0);
expect (delegate.getAndClearAbortCount() == 1);
// There should still be requests related to subscription b pending
expect (manager.sendPendingMessages());
expect (delegate.getOngoingRequests().size() == 1);
expect (delegate.getAndClearSendCount() == 1);
expect (delegate.getAndClearAbortCount() == 0);
// Now, we should send a terminate request for subscription b
manager.endSubscription (b);
expect (delegate.getOngoingRequests().empty());
expect (delegate.getAndClearSendCount() == 0);
expect (delegate.getAndClearAbortCount() == 1);
// The manager never received any replies, so it shouldn't have notified listeners about
// changed subscriptions
expect (! delegate.getAndClearSubChanged());
}
beginTest ("If the device receives a retry or notify in response to a subscription start request, the subscription is retried or terminated as necessary");
{
PropertySubscriptionHeader header;
header.command = PropertySubscriptionCommand::start;
header.resource = "X-CustomProp";
const auto a = manager.beginSubscription (inquiryMUID, header);
expect (manager.getOngoingSubscriptions() == std::vector { a });
expect (delegate.getOngoingRequests().size() == 1);
expect (delegate.getAndClearSendCount() == 1);
expect (delegate.getAndClearAbortCount() == 0);
delegate.sendResult (delegate.getOngoingRequests().back(), PropertyExchangeResult { PropertyExchangeResult::Error::tooManyTransactions });
// The subscription is still active from the perspective of the manager, but the
// first request is over and should be retried
expect (manager.getOngoingSubscriptions() == std::vector { a });
expect (! delegate.getAndClearSubChanged());
expect (delegate.getOngoingRequests().empty());
expect (delegate.getAndClearSendCount() == 0);
expect (delegate.getAndClearAbortCount() == 0);
expect (manager.sendPendingMessages());
expect (manager.getOngoingSubscriptions() == std::vector { a });
expect (delegate.getOngoingRequests().size() == 1);
expect (delegate.getAndClearSendCount() == 1);
expect (delegate.getAndClearAbortCount() == 0);
delegate.sendResult (delegate.getOngoingRequests().back(), PropertyExchangeResult { PropertyExchangeResult::Error::notify });
// The request was terminated by the responder, so the delegate should get a sub-changed message
expect (delegate.getAndClearSubChanged());
expect (manager.getOngoingSubscriptions().empty());
expect (delegate.getOngoingRequests().empty());
expect (delegate.getAndClearSendCount() == 0);
expect (delegate.getAndClearAbortCount() == 0);
expect (manager.sendPendingMessages());
expect (! delegate.getAndClearSubChanged());
expect (manager.getOngoingSubscriptions().empty());
expect (delegate.getOngoingRequests().empty());
expect (delegate.getAndClearSendCount() == 0);
expect (delegate.getAndClearAbortCount() == 0);
}
beginTest ("If the device receives a retry or notify in response to a subscription end request, the subscription is retried as necessary");
{
PropertySubscriptionHeader header;
header.command = PropertySubscriptionCommand::start;
header.resource = "X-CustomProp";
const auto a = manager.beginSubscription (inquiryMUID, header);
expect (manager.getOngoingSubscriptions() == std::vector { a });
expect (manager.getResourceForKey (a) == header.resource);
expect (! manager.getSubscribeIdForKey (a).has_value());
const auto subscriptionResponseHeader = []
{
auto ptr = std::make_unique<DynamicObject>();
ptr->setProperty ("status", 200);
ptr->setProperty ("subscribeId", "newId");
return ptr.release();
}();
// Accept the subscription
delegate.sendResult (delegate.getOngoingRequests().back(), PropertyExchangeResult { subscriptionResponseHeader, {} });
// The subscription is still active from the perspective of the device, but the
// request is over and should be retried
expect (manager.getOngoingSubscriptions() == std::vector { a });
expect (delegate.getAndClearSubChanged());
// Now that the subscription was accepted, the subscription id should be non-empty
expect (manager.getResourceForKey (a) == header.resource);
expect (manager.getSubscribeIdForKey (a) == "newId");
expect (delegate.getOngoingRequests().empty());
expect (delegate.getAndClearSendCount() == 1);
expect (delegate.getAndClearAbortCount() == 0);
manager.endSubscription (a);
expect (manager.getOngoingSubscriptions().empty());
expect (! delegate.getAndClearSubChanged());
expect (delegate.getOngoingRequests().size() == 1);
expect (delegate.getAndClearSendCount() == 1);
expect (delegate.getAndClearAbortCount() == 0);
// The responder is busy, can't process the subscription end
delegate.sendResult (delegate.getOngoingRequests().back(), PropertyExchangeResult { PropertyExchangeResult::Error::tooManyTransactions });
expect (manager.getOngoingSubscriptions().empty());
expect (delegate.getOngoingRequests().empty());
expect (delegate.getAndClearSendCount() == 0);
expect (delegate.getAndClearAbortCount() == 0);
expect (manager.sendPendingMessages());
expect (manager.getOngoingSubscriptions().empty());
expect (delegate.getOngoingRequests().size() == 1);
expect (delegate.getAndClearSendCount() == 1);
expect (delegate.getAndClearAbortCount() == 0);
// The responder told us to immediately terminate our request to end the subscription!
// It's unclear how this should behave, so we'll just ignore the failure and assume
// the subscription is really over.
delegate.sendResult (delegate.getOngoingRequests().back(), PropertyExchangeResult { PropertyExchangeResult::Error::notify });
expect (manager.getOngoingSubscriptions().empty());
expect (delegate.getOngoingRequests().empty());
expect (delegate.getAndClearSendCount() == 0);
expect (delegate.getAndClearAbortCount() == 0);
expect (manager.sendPendingMessages());
expect (delegate.getAndClearSendCount() == 0);
expect (delegate.getAndClearAbortCount() == 0);
expect (! delegate.getAndClearSubChanged());
}
}
};
static SubscriptionTests subscriptionTests;
#endif
} // namespace juce::midi_ci