mirror of
https://codeberg.org/anoncontributorxmr/monero.git
synced 2024-11-23 10:37:37 -07:00
551 lines
18 KiB
C++
551 lines
18 KiB
C++
// Copyright (c) 2020-2022, The Monero Project
|
|
|
|
//
|
|
// All rights reserved.
|
|
//
|
|
// Redistribution and use in source and binary forms, with or without modification, are
|
|
// permitted provided that the following conditions are met:
|
|
//
|
|
// 1. Redistributions of source code must retain the above copyright notice, this list of
|
|
// conditions and the following disclaimer.
|
|
//
|
|
// 2. Redistributions in binary form must reproduce the above copyright notice, this list
|
|
// of conditions and the following disclaimer in the documentation and/or other
|
|
// materials provided with the distribution.
|
|
//
|
|
// 3. Neither the name of the copyright holder nor the names of its contributors may be
|
|
// used to endorse or promote products derived from this software without specific
|
|
// prior written permission.
|
|
//
|
|
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY
|
|
// EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
|
|
// MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL
|
|
// THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
|
// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
|
|
// PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
|
// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
|
|
// STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF
|
|
// THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
|
|
#include "zmq_pub.h"
|
|
|
|
#include <algorithm>
|
|
#include <boost/range/adaptor/filtered.hpp>
|
|
#include <boost/range/adaptor/transformed.hpp>
|
|
#include <boost/thread/locks.hpp>
|
|
#include <cassert>
|
|
#include <cstdint>
|
|
#include <cstring>
|
|
#include <rapidjson/document.h>
|
|
#include <rapidjson/stringbuffer.h>
|
|
#include <rapidjson/writer.h>
|
|
#include <stdexcept>
|
|
#include <string>
|
|
#include <utility>
|
|
|
|
#include "common/expect.h"
|
|
#include "crypto/crypto.h"
|
|
#include "cryptonote_basic/cryptonote_format_utils.h"
|
|
#include "cryptonote_basic/events.h"
|
|
#include "misc_log_ex.h"
|
|
#include "serialization/json_object.h"
|
|
#include "ringct/rctTypes.h"
|
|
#include "cryptonote_core/cryptonote_tx_utils.h"
|
|
|
|
#undef MONERO_DEFAULT_LOG_CATEGORY
|
|
#define MONERO_DEFAULT_LOG_CATEGORY "net.zmq"
|
|
|
|
namespace
|
|
{
|
|
constexpr const char txpool_signal[] = "tx_signal";
|
|
|
|
using chain_writer = void(epee::byte_stream&, std::uint64_t, epee::span<const cryptonote::block>);
|
|
using miner_writer = void(epee::byte_stream&, uint8_t, uint64_t, const crypto::hash&, const crypto::hash&, cryptonote::difficulty_type, uint64_t, uint64_t, const std::vector<cryptonote::tx_block_template_backlog_entry>&);
|
|
using txpool_writer = void(epee::byte_stream&, epee::span<const cryptonote::txpool_event>);
|
|
|
|
template<typename F>
|
|
struct context
|
|
{
|
|
char const* const name;
|
|
F* generate_pub;
|
|
};
|
|
|
|
template<typename T>
|
|
bool operator<(const context<T>& lhs, const context<T>& rhs) noexcept
|
|
{
|
|
return std::strcmp(lhs.name, rhs.name) < 0;
|
|
}
|
|
|
|
template<typename T>
|
|
bool operator<(const context<T>& lhs, const boost::string_ref rhs) noexcept
|
|
{
|
|
return lhs.name < rhs;
|
|
}
|
|
|
|
struct is_valid
|
|
{
|
|
bool operator()(const cryptonote::txpool_event& event) const noexcept
|
|
{
|
|
return event.res;
|
|
}
|
|
};
|
|
|
|
template<typename T, std::size_t N>
|
|
void verify_sorted(const std::array<context<T>, N>& elems, const char* name)
|
|
{
|
|
auto unsorted = std::is_sorted_until(elems.begin(), elems.end());
|
|
if (unsorted != elems.end())
|
|
throw std::logic_error{name + std::string{" array is not properly sorted, see: "} + unsorted->name};
|
|
}
|
|
|
|
void write_header(epee::byte_stream& buf, const boost::string_ref name)
|
|
{
|
|
buf.write(name.data(), name.size());
|
|
buf.put(':');
|
|
}
|
|
|
|
//! \return `name:...` where `...` is JSON and `name` is directly copied (no quotes - not JSON).
|
|
template<typename T>
|
|
void json_pub(epee::byte_stream& buf, const T value)
|
|
{
|
|
rapidjson::Writer<epee::byte_stream> dest{buf};
|
|
using cryptonote::json::toJsonValue;
|
|
toJsonValue(dest, value);
|
|
}
|
|
|
|
//! Object for "minimal" block serialization
|
|
struct minimal_chain
|
|
{
|
|
const std::uint64_t height;
|
|
const epee::span<const cryptonote::block> blocks;
|
|
};
|
|
|
|
//! Object for miner data serialization
|
|
struct miner_data
|
|
{
|
|
uint8_t major_version;
|
|
uint64_t height;
|
|
const crypto::hash& prev_id;
|
|
const crypto::hash& seed_hash;
|
|
cryptonote::difficulty_type diff;
|
|
uint64_t median_weight;
|
|
uint64_t already_generated_coins;
|
|
const std::vector<cryptonote::tx_block_template_backlog_entry>& tx_backlog;
|
|
};
|
|
|
|
//! Object for "minimal" tx serialization
|
|
struct minimal_txpool
|
|
{
|
|
const cryptonote::transaction& tx;
|
|
crypto::hash hash;
|
|
uint64_t blob_size;
|
|
uint64_t weight;
|
|
uint64_t fee;
|
|
};
|
|
|
|
void toJsonValue(rapidjson::Writer<epee::byte_stream>& dest, const minimal_chain& self)
|
|
{
|
|
namespace adapt = boost::adaptors;
|
|
|
|
const auto to_block_id = [](const cryptonote::block& bl)
|
|
{
|
|
crypto::hash id;
|
|
if (!get_block_hash(bl, id))
|
|
MERROR("ZMQ/Pub failure: get_block_hash");
|
|
return id;
|
|
};
|
|
|
|
assert(!self.blocks.empty()); // checked in zmq_pub::send_chain_main
|
|
|
|
dest.StartObject();
|
|
INSERT_INTO_JSON_OBJECT(dest, first_height, self.height);
|
|
INSERT_INTO_JSON_OBJECT(dest, first_prev_id, self.blocks[0].prev_id);
|
|
INSERT_INTO_JSON_OBJECT(dest, ids, (self.blocks | adapt::transformed(to_block_id)));
|
|
dest.EndObject();
|
|
}
|
|
|
|
void toJsonValue(rapidjson::Writer<epee::byte_stream>& dest, const miner_data& self)
|
|
{
|
|
dest.StartObject();
|
|
INSERT_INTO_JSON_OBJECT(dest, major_version, self.major_version);
|
|
INSERT_INTO_JSON_OBJECT(dest, height, self.height);
|
|
INSERT_INTO_JSON_OBJECT(dest, prev_id, self.prev_id);
|
|
INSERT_INTO_JSON_OBJECT(dest, seed_hash, self.seed_hash);
|
|
INSERT_INTO_JSON_OBJECT(dest, difficulty, cryptonote::hex(self.diff));
|
|
INSERT_INTO_JSON_OBJECT(dest, median_weight, self.median_weight);
|
|
INSERT_INTO_JSON_OBJECT(dest, already_generated_coins, self.already_generated_coins);
|
|
INSERT_INTO_JSON_OBJECT(dest, tx_backlog, self.tx_backlog);
|
|
dest.EndObject();
|
|
}
|
|
|
|
void toJsonValue(rapidjson::Writer<epee::byte_stream>& dest, const minimal_txpool& self)
|
|
{
|
|
dest.StartObject();
|
|
INSERT_INTO_JSON_OBJECT(dest, id, self.hash);
|
|
INSERT_INTO_JSON_OBJECT(dest, blob_size, self.blob_size);
|
|
INSERT_INTO_JSON_OBJECT(dest, weight, self.weight);
|
|
INSERT_INTO_JSON_OBJECT(dest, fee, self.fee);
|
|
dest.EndObject();
|
|
}
|
|
|
|
void json_full_chain(epee::byte_stream& buf, const std::uint64_t height, const epee::span<const cryptonote::block> blocks)
|
|
{
|
|
json_pub(buf, blocks);
|
|
}
|
|
|
|
void json_minimal_chain(epee::byte_stream& buf, const std::uint64_t height, const epee::span<const cryptonote::block> blocks)
|
|
{
|
|
json_pub(buf, minimal_chain{height, blocks});
|
|
}
|
|
|
|
void json_miner_data(epee::byte_stream& buf, uint8_t major_version, uint64_t height, const crypto::hash& prev_id, const crypto::hash& seed_hash, cryptonote::difficulty_type diff, uint64_t median_weight, uint64_t already_generated_coins, const std::vector<cryptonote::tx_block_template_backlog_entry>& tx_backlog)
|
|
{
|
|
json_pub(buf, miner_data{major_version, height, prev_id, seed_hash, diff, median_weight, already_generated_coins, tx_backlog});
|
|
}
|
|
|
|
// boost::adaptors are in place "views" - no copy/move takes place
|
|
// moving transactions (via sort, etc.), is expensive!
|
|
|
|
void json_full_txpool(epee::byte_stream& buf, epee::span<const cryptonote::txpool_event> txes)
|
|
{
|
|
namespace adapt = boost::adaptors;
|
|
const auto to_full_tx = [](const cryptonote::txpool_event& event)
|
|
{
|
|
return event.tx;
|
|
};
|
|
json_pub(buf, (txes | adapt::filtered(is_valid{}) | adapt::transformed(to_full_tx)));
|
|
}
|
|
|
|
void json_minimal_txpool(epee::byte_stream& buf, epee::span<const cryptonote::txpool_event> txes)
|
|
{
|
|
namespace adapt = boost::adaptors;
|
|
const auto to_minimal_tx = [](const cryptonote::txpool_event& event)
|
|
{
|
|
return minimal_txpool{event.tx, event.hash, event.blob_size, event.weight, cryptonote::get_tx_fee(event.tx)};
|
|
};
|
|
json_pub(buf, (txes | adapt::filtered(is_valid{}) | adapt::transformed(to_minimal_tx)));
|
|
}
|
|
|
|
constexpr const std::array<context<chain_writer>, 2> chain_contexts =
|
|
{{
|
|
{u8"json-full-chain_main", json_full_chain},
|
|
{u8"json-minimal-chain_main", json_minimal_chain}
|
|
}};
|
|
|
|
constexpr const std::array<context<miner_writer>, 1> miner_contexts =
|
|
{{
|
|
{u8"json-full-miner_data", json_miner_data},
|
|
}};
|
|
|
|
constexpr const std::array<context<txpool_writer>, 2> txpool_contexts =
|
|
{{
|
|
{u8"json-full-txpool_add", json_full_txpool},
|
|
{u8"json-minimal-txpool_add", json_minimal_txpool}
|
|
}};
|
|
|
|
template<typename T, std::size_t N>
|
|
epee::span<const context<T>> get_range(const std::array<context<T>, N>& contexts, const boost::string_ref value)
|
|
{
|
|
const auto not_prefix = [](const boost::string_ref lhs, const context<T>& rhs)
|
|
{
|
|
return !(boost::string_ref{rhs.name}.starts_with(lhs));
|
|
};
|
|
|
|
const auto lower = std::lower_bound(contexts.begin(), contexts.end(), value);
|
|
const auto upper = std::upper_bound(lower, contexts.end(), value, not_prefix);
|
|
return {lower, std::size_t(upper - lower)};
|
|
}
|
|
|
|
template<std::size_t N, typename T>
|
|
void add_subscriptions(std::array<std::size_t, N>& subs, const epee::span<const context<T>> range, context<T> const* const first)
|
|
{
|
|
assert(range.size() <= N);
|
|
assert((unsigned long)(range.begin() - first) <= N - range.size());
|
|
|
|
for (const auto& ctx : range)
|
|
{
|
|
const std::size_t i = std::addressof(ctx) - first;
|
|
subs[i] = std::min(std::numeric_limits<std::size_t>::max() - 1, subs[i]) + 1;
|
|
}
|
|
}
|
|
|
|
template<std::size_t N, typename T>
|
|
void remove_subscriptions(std::array<std::size_t, N>& subs, const epee::span<const context<T>> range, context<T> const* const first)
|
|
{
|
|
assert(range.size() <= N);
|
|
assert((unsigned long)(range.begin() - first) <= N - range.size());
|
|
|
|
for (const auto& ctx : range)
|
|
{
|
|
const std::size_t i = std::addressof(ctx) - first;
|
|
subs[i] = std::max(std::size_t(1), subs[i]) - 1;
|
|
}
|
|
}
|
|
|
|
template<std::size_t N, typename T, typename... U>
|
|
std::array<epee::byte_slice, N> make_pubs(const std::array<std::size_t, N>& subs, const std::array<context<T>, N>& contexts, U&&... args)
|
|
{
|
|
epee::byte_stream buf{};
|
|
|
|
std::size_t last_offset = 0;
|
|
std::array<std::size_t, N> offsets{{}};
|
|
for (std::size_t i = 0; i < N; ++i)
|
|
{
|
|
if (subs[i])
|
|
{
|
|
write_header(buf, contexts[i].name);
|
|
contexts[i].generate_pub(buf, std::forward<U>(args)...);
|
|
offsets[i] = buf.size() - last_offset;
|
|
last_offset = buf.size();
|
|
}
|
|
}
|
|
|
|
epee::byte_slice bytes{std::move(buf)};
|
|
std::array<epee::byte_slice, N> out;
|
|
for (std::size_t i = 0; i < N; ++i)
|
|
out[i] = bytes.take_slice(offsets[i]);
|
|
|
|
return out;
|
|
}
|
|
|
|
template<std::size_t N>
|
|
std::size_t send_messages(void* const socket, std::array<epee::byte_slice, N>& messages)
|
|
{
|
|
std::size_t count = 0;
|
|
for (epee::byte_slice& message : messages)
|
|
{
|
|
if (!message.empty())
|
|
{
|
|
const expect<void> sent = net::zmq::send(std::move(message), socket, ZMQ_DONTWAIT);
|
|
if (!sent)
|
|
MERROR("Failed to send ZMQ/Pub message: " << sent.error().message());
|
|
else
|
|
++count;
|
|
}
|
|
}
|
|
return count;
|
|
}
|
|
|
|
expect<bool> relay_block_pub(void* const relay, void* const pub) noexcept
|
|
{
|
|
zmq_msg_t msg;
|
|
zmq_msg_init(std::addressof(msg));
|
|
MONERO_CHECK(net::zmq::retry_op(zmq_msg_recv, std::addressof(msg), relay, ZMQ_DONTWAIT));
|
|
|
|
const boost::string_ref payload{
|
|
reinterpret_cast<const char*>(zmq_msg_data(std::addressof(msg))),
|
|
zmq_msg_size(std::addressof(msg))
|
|
};
|
|
|
|
if (payload == txpool_signal)
|
|
{
|
|
zmq_msg_close(std::addressof(msg));
|
|
return false;
|
|
}
|
|
|
|
// forward block messages (serialized on P2P thread for now)
|
|
const expect<void> sent = net::zmq::retry_op(zmq_msg_send, std::addressof(msg), pub, ZMQ_DONTWAIT);
|
|
if (!sent)
|
|
{
|
|
zmq_msg_close(std::addressof(msg));
|
|
return sent.error();
|
|
}
|
|
return true;
|
|
}
|
|
} // anonymous
|
|
|
|
namespace cryptonote { namespace listener
|
|
{
|
|
|
|
zmq_pub::zmq_pub(void* context)
|
|
: relay_(),
|
|
chain_subs_{{0}},
|
|
miner_subs_{{0}},
|
|
txpool_subs_{{0}},
|
|
sync_()
|
|
{
|
|
if (!context)
|
|
throw std::logic_error{"ZMQ context cannot be NULL"};
|
|
|
|
verify_sorted(chain_contexts, "chain_contexts");
|
|
verify_sorted(miner_contexts, "miner_contexts");
|
|
verify_sorted(txpool_contexts, "txpool_contexts");
|
|
|
|
relay_.reset(zmq_socket(context, ZMQ_PAIR));
|
|
if (!relay_)
|
|
MONERO_ZMQ_THROW("Failed to create relay socket");
|
|
if (zmq_connect(relay_.get(), relay_endpoint()) != 0)
|
|
MONERO_ZMQ_THROW("Failed to connect relay socket");
|
|
}
|
|
|
|
zmq_pub::~zmq_pub()
|
|
{}
|
|
|
|
bool zmq_pub::sub_request(boost::string_ref message)
|
|
{
|
|
if (!message.empty())
|
|
{
|
|
const char tag = message[0];
|
|
message.remove_prefix(1);
|
|
|
|
const auto chain_range = get_range(chain_contexts, message);
|
|
const auto miner_range = get_range(miner_contexts, message);
|
|
const auto txpool_range = get_range(txpool_contexts, message);
|
|
|
|
if (!chain_range.empty() || !miner_range.empty() || !txpool_range.empty())
|
|
{
|
|
MDEBUG("Client " << (tag ? "subscribed" : "unsubscribed") << " to " <<
|
|
chain_range.size() << " chain topic(s), " << miner_range.size() << " miner topic(s) and " << txpool_range.size() << " txpool topic(s)");
|
|
|
|
const boost::lock_guard<boost::mutex> lock{sync_};
|
|
switch (tag)
|
|
{
|
|
case 0:
|
|
remove_subscriptions(chain_subs_, chain_range, chain_contexts.begin());
|
|
remove_subscriptions(miner_subs_, miner_range, miner_contexts.begin());
|
|
remove_subscriptions(txpool_subs_, txpool_range, txpool_contexts.begin());
|
|
return true;
|
|
case 1:
|
|
add_subscriptions(chain_subs_, chain_range, chain_contexts.begin());
|
|
add_subscriptions(miner_subs_, miner_range, miner_contexts.begin());
|
|
add_subscriptions(txpool_subs_, txpool_range, txpool_contexts.begin());
|
|
return true;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
MERROR("Invalid ZMQ/Sub message");
|
|
return false;
|
|
}
|
|
|
|
bool zmq_pub::relay_to_pub(void* const relay, void* const pub)
|
|
{
|
|
const expect<bool> relayed = relay_block_pub(relay, pub);
|
|
if (!relayed)
|
|
{
|
|
MERROR("Error relaying ZMQ/Pub: " << relayed.error().message());
|
|
return false;
|
|
}
|
|
|
|
if (!*relayed)
|
|
{
|
|
std::array<std::size_t, 2> subs;
|
|
std::vector<cryptonote::txpool_event> events;
|
|
{
|
|
const boost::lock_guard<boost::mutex> lock{sync_};
|
|
if (txes_.empty())
|
|
return false;
|
|
|
|
subs = txpool_subs_;
|
|
events = std::move(txes_.front());
|
|
txes_.pop_front();
|
|
}
|
|
auto messages = make_pubs(subs, txpool_contexts, epee::to_span(events));
|
|
send_messages(pub, messages);
|
|
MDEBUG("Sent txpool ZMQ/Pub");
|
|
}
|
|
else
|
|
MDEBUG("Sent chain_main ZMQ/Pub");
|
|
|
|
return true;
|
|
}
|
|
|
|
std::size_t zmq_pub::send_chain_main(const std::uint64_t height, const epee::span<const cryptonote::block> blocks)
|
|
{
|
|
if (blocks.empty())
|
|
return 0;
|
|
|
|
/* Block format only sends one block at a time - multiple block notifications
|
|
are less common and only occur on rollbacks. */
|
|
|
|
boost::unique_lock<boost::mutex> guard{sync_};
|
|
|
|
const auto subs_copy = chain_subs_;
|
|
guard.unlock();
|
|
|
|
for (const std::size_t sub : subs_copy)
|
|
{
|
|
if (sub)
|
|
{
|
|
/* cryptonote_core/blockchain.cpp cannot "give" us the block like core
|
|
does for txpool events. Since copying the block is expensive anyway,
|
|
serialization is done right here on the p2p thread (for now). */
|
|
|
|
auto messages = make_pubs(subs_copy, chain_contexts, height, blocks);
|
|
guard.lock();
|
|
return send_messages(relay_.get(), messages);
|
|
}
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
std::size_t zmq_pub::send_miner_data(uint8_t major_version, uint64_t height, const crypto::hash& prev_id, const crypto::hash& seed_hash, difficulty_type diff, uint64_t median_weight, uint64_t already_generated_coins, const std::vector<tx_block_template_backlog_entry>& tx_backlog)
|
|
{
|
|
boost::unique_lock<boost::mutex> guard{sync_};
|
|
|
|
const auto subs_copy = miner_subs_;
|
|
guard.unlock();
|
|
|
|
for (const std::size_t sub : subs_copy)
|
|
{
|
|
if (sub)
|
|
{
|
|
auto messages = make_pubs(subs_copy, miner_contexts, major_version, height, prev_id, seed_hash, diff, median_weight, already_generated_coins, tx_backlog);
|
|
guard.lock();
|
|
return send_messages(relay_.get(), messages);
|
|
}
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
std::size_t zmq_pub::send_txpool_add(std::vector<txpool_event> txes)
|
|
{
|
|
if (txes.empty())
|
|
return 0;
|
|
|
|
const boost::lock_guard<boost::mutex> lock{sync_};
|
|
for (const std::size_t sub : txpool_subs_)
|
|
{
|
|
if (sub)
|
|
{
|
|
const expect<void> sent = net::zmq::retry_op(zmq_send_const, relay_.get(), txpool_signal, sizeof(txpool_signal) - 1, ZMQ_DONTWAIT);
|
|
if (sent)
|
|
txes_.emplace_back(std::move(txes));
|
|
else
|
|
MERROR("ZMQ/Pub failure, relay queue error: " << sent.error().message());
|
|
return bool(sent);
|
|
}
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
void zmq_pub::chain_main::operator()(const std::uint64_t height, epee::span<const cryptonote::block> blocks) const
|
|
{
|
|
const std::shared_ptr<zmq_pub> self = self_.lock();
|
|
if (self)
|
|
self->send_chain_main(height, blocks);
|
|
else
|
|
MERROR("Unable to send ZMQ/Pub - ZMQ server destroyed");
|
|
}
|
|
|
|
void zmq_pub::miner_data::operator()(uint8_t major_version, uint64_t height, const crypto::hash& prev_id, const crypto::hash& seed_hash, difficulty_type diff, uint64_t median_weight, uint64_t already_generated_coins, const std::vector<tx_block_template_backlog_entry>& tx_backlog) const
|
|
{
|
|
const std::shared_ptr<zmq_pub> self = self_.lock();
|
|
if (self)
|
|
self->send_miner_data(major_version, height, prev_id, seed_hash, diff, median_weight, already_generated_coins, tx_backlog);
|
|
else
|
|
MERROR("Unable to send ZMQ/Pub - ZMQ server destroyed");
|
|
}
|
|
|
|
void zmq_pub::txpool_add::operator()(std::vector<cryptonote::txpool_event> txes) const
|
|
{
|
|
const std::shared_ptr<zmq_pub> self = self_.lock();
|
|
if (self)
|
|
self->send_txpool_add(std::move(txes));
|
|
else
|
|
MERROR("Unable to send ZMQ/Pub - ZMQ server destroyed");
|
|
}
|
|
|
|
}}
|