20#include <ixwebsocket/IXBase64.h>
21#include <ixwebsocket/IXNetSystem.h>
22#include <ixwebsocket/IXWebSocket.h>
23#include <ixwebsocket/IXWebSocketCloseConstants.h>
24#include <nlohmann/json.hpp>
28#include <condition_variable>
38#include <unordered_map>
43using json = nlohmann::json;
50class ReadChannelConnectionImpl;
69 const std::vector<MessageData> &frames,
bool force);
71 unsigned maxInFlight);
73 std::unique_ptr<RpcClient::ReadChannelConnection>
126 std::condition_variable
cv;
130 std::unordered_map<uint64_t, std::unique_ptr<WriteCredit>>
writeCredits;
140 std::unordered_map<uint64_t, std::shared_ptr<std::promise<json>>>
pending;
172 std::unordered_map<uint64_t, std::shared_ptr<ReadCallbackEntry>>
190 const std::unordered_map<std::shared_ptr<ReadCallbackEntry>, uint64_t>
231 :
impl(
impl), channelId(channelId) {}
232 ~ReadChannelConnectionImpl()
override { disconnect(); }
234 void disconnect()
override {
235 if (disconnected.exchange(
true))
237 impl->unregisterCallback(channelId);
239 impl->unsubscribe(channelId);
240 }
catch (
const std::exception &e) {
244 impl->getLogger().warning(
246 std::string(
"unsubscribe during disconnect failed: ") + e.what());
253 std::atomic<bool> disconnected{
false};
263 : host(hostname), logger(logger) {
267 if (!ix::initNetSystem())
268 throw std::runtime_error(
269 "RpcClient: ix::initNetSystem() failed (WSAStartup)");
271 ws.setUrl(
"ws://" + host +
":" + std::to_string(port) +
"/esi/cosim/v" +
273 ws.disablePerMessageDeflate();
274 ws.disableAutomaticReconnection();
276 ws.setOnMessageCallback(
277 [
this](
const ix::WebSocketMessagePtr &msg) { onMessage(msg); });
283 std::unique_lock<std::mutex> lock(openMutex);
284 openCV.wait(lock, [&] {
return openState != OpenState::Pending; });
285 if (openState == OpenState::Failed)
286 throw std::runtime_error(
"RpcClient: failed to connect to " +
287 ws.getUrl() +
": " + openError);
293 auto verIt = result.find(
"protocol_version");
294 if (verIt == result.end() || !verIt->is_number_unsigned() ||
296 throw std::runtime_error(
297 "RpcClient: server speaks an unsupported cosim protocol version (" +
298 (verIt == result.end() ? std::string(
"none") : verIt->dump()) +
302 if (
auto it = result.find(
"esi_version"); it != result.end())
304 if (
auto it = result.find(
"compressed_manifest_b64"); it != result.end()) {
308 std::string err = macaron::Base64::Decode(it->get<std::string>(), raw);
310 throw std::runtime_error(
"RpcClient: invalid base64 in manifest blob: " +
312 manifest.assign(raw.begin(), raw.end());
316 auto channelsIt = result.find(
"channels");
317 if (channelsIt == result.end() || !channelsIt->is_array())
318 throw std::runtime_error(
"RpcClient: hello response missing channels");
319 for (
const json &c : *channelsIt) {
321 meta.id = c.at(
"channel_id").get<uint64_t>();
322 meta.name = c.at(
"name").get<std::string>();
323 meta.type = c.at(
"type").get<std::string>();
324 std::string dir = c.at(
"direction").get<std::string>();
325 meta.direction = dir ==
"to_server" ? RpcClient::ChannelDirection::ToServer
326 : RpcClient::ChannelDirection::ToClient;
327 if (meta.direction == RpcClient::ChannelDirection::ToServer)
328 writeCredits.emplace(meta.id, std::make_unique<WriteCredit>());
329 channelsByName.emplace(meta.name, meta);
330 channelsById.emplace(meta.id, std::move(meta));
333 channelTablesReady.store(
true, std::memory_order_release);
342 disconnecting =
true;
343 failAllPending(
"client shutdown");
344 markConnectionLost();
349 std::lock_guard<std::mutex> lock(readCallbacksMutex);
350 for (
auto &[
id, entry] : readCallbacks)
351 entry->canceled.store(
true);
358 if (transportThread.joinable()) {
359 readyIds.requestShutdown();
360 transportThread.join();
370 case ix::WebSocketMessageType::Open: {
371 std::lock_guard<std::mutex> lock(openMutex);
372 openState = OpenState::Open;
376 case ix::WebSocketMessageType::Error: {
377 std::string reason = msg->errorInfo.reason;
379 std::lock_guard<std::mutex> lock(lastServerErrorMutex);
380 if (!lastServerError.empty())
381 reason = lastServerError;
384 std::lock_guard<std::mutex> lock(openMutex);
385 if (openState == OpenState::Pending) {
386 openState = OpenState::Failed;
391 failAllPending(
"websocket error: " + reason);
392 markConnectionLost();
395 case ix::WebSocketMessageType::Close: {
396 std::string reason = msg->closeInfo.reason;
398 std::lock_guard<std::mutex> lock(lastServerErrorMutex);
399 if (!lastServerError.empty())
400 reason = lastServerError;
406 std::lock_guard<std::mutex> lock(openMutex);
407 if (openState == OpenState::Pending) {
408 openState = OpenState::Failed;
413 failAllPending(
"websocket closed: " + reason);
414 markConnectionLost();
417 case ix::WebSocketMessageType::Message:
423 handleBinaryFrame(msg->str);
425 handleControlFrame(msg->str);
427 faultStash.record(std::current_exception());
438 resp = json::parse(text);
439 }
catch (
const std::exception &e) {
443 auto typeIt = resp.find(
"type");
444 if (typeIt == resp.end() || !typeIt->is_string())
446 std::string type = typeIt->get<std::string>();
452 if (type ==
"error") {
453 if (
auto errIt = resp.find(
"error"); errIt != resp.end()) {
454 std::string code = errIt->value(
"code", std::string(
"internal"));
455 std::string message = errIt->value(
"message", std::string());
456 std::lock_guard<std::mutex> lock(lastServerErrorMutex);
457 lastServerError =
"server error [" + code +
"]: " + message;
462 if (type ==
"event") {
467 if (type !=
"response") {
472 logger.
debug(
"cosim",
473 "Ignoring control frame with unknown type \"" + type +
"\"");
476 auto idIt = resp.find(
"request_id");
477 if (idIt == resp.end())
479 uint64_t requestId = idIt->get<uint64_t>();
481 std::shared_ptr<std::promise<json>> promise;
483 std::lock_guard<std::mutex> lock(pendingMutex);
484 auto it = pending.find(requestId);
485 if (it == pending.end())
487 promise = it->second;
494 if (
auto errIt = resp.find(
"error"); errIt != resp.end()) {
495 std::string code = errIt->value(
"code", std::string(
"internal"));
496 std::string message = errIt->value(
"message", std::string());
497 promise->set_exception(std::make_exception_ptr(
498 std::runtime_error(
"Server error [" + code +
"]: " + message)));
499 }
else if (
auto resultIt = resp.find(
"result"); resultIt != resp.end()) {
500 promise->set_value(*resultIt);
505 promise->set_exception(std::make_exception_ptr(std::runtime_error(
506 "Server response missing both \"result\" and \"error\" fields "
507 "(protocol violation) for request_id=" +
508 std::to_string(requestId))));
510 }
catch (
const std::exception &e) {
514 logger.
debug(
"cosim",
515 std::string(
"failed to fulfill promise for request_id=") +
516 std::to_string(requestId) +
": " + e.what());
521 std::string
event = frame.value(
"event", std::string());
522 if (event !=
"consumed") {
523 logger.
debug(
"cosim",
"Ignoring unknown server event \"" + event +
"\"");
526 if (!channelTablesReady.load(std::memory_order_acquire))
528 auto paramsIt = frame.find(
"params");
529 if (paramsIt == frame.end() || !paramsIt->is_object())
531 auto chIt = paramsIt->find(
"channels");
532 if (chIt == paramsIt->end() || !chIt->is_array())
534 for (
const json &c : *chIt) {
535 auto idIt = c.find(
"channel_id");
536 auto countIt = c.find(
"count");
537 if (idIt == c.end() || !idIt->is_number_unsigned() || countIt == c.end() ||
538 !countIt->is_number_unsigned()) {
539 logger.
warning(
"cosim",
"Malformed consumed event entry: " + c.dump());
542 auto credIt = writeCredits.find(idIt->get<uint64_t>());
543 if (credIt == writeCredits.end())
545 WriteCredit &cred = *credIt->second;
546 uint64_t count = countIt->get<uint64_t>();
548 std::lock_guard<std::mutex> lock(cred.mutex);
550 cred.outstanding -= (std::min)(cred.outstanding, count);
552 cred.cv.notify_all();
557 connectionLost.store(
true);
558 if (!channelTablesReady.load(std::memory_order_acquire))
560 for (
auto &[
id, cred] : writeCredits) {
563 { std::lock_guard<std::mutex> lock(cred->mutex); }
564 cred->cv.notify_all();
570 const uint8_t *payloadBytes;
575 std::shared_ptr<ReadCallbackEntry> entry;
577 std::lock_guard<std::mutex> lock(readCallbacksMutex);
578 auto it = readCallbacks.find(channelId);
579 if (it == readCallbacks.end()) {
584 logger.
warning(
"cosim",
"Dropping inbound frame for channel id " +
585 std::to_string(channelId) +
586 ": no active subscriber");
596 entry->queue.push(MessageData(payloadBytes, payloadSize));
600 std::lock_guard<std::mutex> lock(pendingMutex);
601 for (
auto &[
id, pr] : pending) {
603 pr->set_exception(std::make_exception_ptr(std::runtime_error(reason)));
604 }
catch (
const std::exception &e) {
607 logger.
debug(
"cosim", std::string(
"failAllPending: request_id=") +
609 " already satisfied: " + e.what());
621 uint64_t requestId = nextRequestId.fetch_add(1);
622 std::future<json> future;
624 std::lock_guard<std::mutex> lock(pendingMutex);
625 auto promise = std::make_shared<std::promise<json>>();
626 future = promise->get_future();
627 pending.emplace(requestId, std::move(promise));
631 req[
"type"] =
"request";
632 req[
"request_id"] = requestId;
633 req[
"method"] = method;
634 req[
"params"] = std::move(params);
635 std::string text = req.dump();
640 auto info = ws.sendUtf8Text(text);
642 std::lock_guard<std::mutex> plock(pendingMutex);
643 pending.erase(requestId);
644 throw std::runtime_error(
"RpcClient: failed to send " + method);
651 constexpr auto kCallTimeout = std::chrono::seconds(30);
652 if (future.wait_for(kCallTimeout) != std::future_status::ready) {
656 std::lock_guard<std::mutex> plock(pendingMutex);
657 pending.erase(requestId);
658 throw std::runtime_error(
"RpcClient: timed out waiting for response to " +
664const RpcClient::Impl::ChannelMeta &
666 auto it = channelsByName.find(channelName);
667 if (it == channelsByName.end())
668 throw std::runtime_error(
"Unknown channel '" + channelName +
"'");
670 throw std::runtime_error(
"Channel '" + channelName +
671 "' is not a to-server channel");
676 const MessageData &data) {
680 auto info = ws.sendBinary(frame);
682 throw std::runtime_error(
"Failed to send data on channel '" + meta.name +
687 const MessageData &data) {
689 const ChannelMeta &meta = getToServerChannel(channelName);
690 WriteCredit &cred = *writeCredits.at(meta.id);
692 std::unique_lock<std::mutex> lock(cred.mutex);
693 cred.cv.wait(lock, [&] {
694 return connectionLost.load() || cred.maxInFlight == 0 ||
695 cred.outstanding < cred.maxInFlight;
697 if (connectionLost.load())
698 throw std::runtime_error(
"Connection to cosim server lost while "
699 "writing to channel '" +
703 sendFrame(meta, data);
707 const std::vector<MessageData> &frames,
710 const ChannelMeta &meta = getToServerChannel(channelName);
711 WriteCredit &cred = *writeCredits.at(meta.id);
713 std::lock_guard<std::mutex> lock(cred.mutex);
714 if (connectionLost.load())
715 throw std::runtime_error(
"Connection to cosim server lost while "
716 "writing to channel '" +
718 if (!force && cred.maxInFlight != 0 && cred.outstanding >= cred.maxInFlight)
720 cred.outstanding += frames.size();
722 for (
const MessageData &frame : frames)
723 sendFrame(meta, frame);
728 unsigned maxInFlight) {
729 const ChannelMeta &meta = getToServerChannel(channelName);
730 WriteCredit &cred = *writeCredits.at(meta.id);
732 std::lock_guard<std::mutex> lock(cred.mutex);
733 cred.maxInFlight = maxInFlight;
735 cred.cv.notify_all();
743 RpcClient::ChannelDesc &desc)
const {
744 auto it = channelsByName.find(name);
745 if (it == channelsByName.end())
747 desc.
name = it->second.name;
748 desc.
type = it->second.type;
749 desc.
dir = it->second.direction;
754 std::vector<RpcClient::ChannelDesc> out;
755 out.reserve(channelsByName.size());
756 for (
const auto &[name, meta] : channelsByName) {
757 RpcClient::ChannelDesc
d;
760 d.dir = meta.direction;
761 out.push_back(std::move(d));
770std::unique_ptr<RpcClient::ReadChannelConnection>
773 unsigned maxInFlight) {
775 auto it = channelsByName.find(channelName);
776 if (it == channelsByName.end())
777 throw std::runtime_error(
"Unknown channel '" + channelName +
"'");
779 throw std::runtime_error(
"Channel '" + channelName +
780 "' is not a to-client channel");
781 uint64_t channelId = it->second.id;
786 auto entry = std::make_shared<ReadCallbackEntry>(
787 channelId, [
this, channelId] { readyIds.markReady(channelId); });
788 entry->callback = std::move(callback);
790 std::lock_guard<std::mutex> lock(readCallbacksMutex);
791 if (!readCallbacks.emplace(channelId, entry).second)
792 throw std::runtime_error(
"Channel '" + channelName +
793 "' already has a receiver");
798 {{
"channel_id", channelId}, {
"max_in_flight", maxInFlight}});
800 std::lock_guard<std::mutex> lock(readCallbacksMutex);
801 auto it = readCallbacks.find(channelId);
802 if (it != readCallbacks.end() && it->second == entry)
803 readCallbacks.erase(it);
807 return std::make_unique<ReadChannelConnectionImpl>(
this, channelId);
817 if (ws.getReadyState() != ix::ReadyState::Open)
819 call(
"unsubscribe", {{
"channel_id", channelId}});
823 std::shared_ptr<ReadCallbackEntry> entry;
825 std::lock_guard<std::mutex> lock(readCallbacksMutex);
826 auto it = readCallbacks.find(channelId);
827 if (it == readCallbacks.end())
830 readCallbacks.erase(it);
836 std::lock_guard<std::mutex> lock(ackMutex);
837 entry->canceled.store(
true);
841 readyIds.markReady(channelId);
853 std::unordered_set<uint64_t> retry;
856 std::unordered_set<uint64_t> ids;
857 if (!readyIds.waitDrain(
858 ids, retry.empty() ? std::optional<std::chrono::milliseconds>{}
859 : std::chrono::milliseconds(1)))
861 ids.insert(retry.begin(), retry.end());
866 std::unordered_map<std::shared_ptr<ReadCallbackEntry>, uint64_t> acks;
867 for (uint64_t
id : ids) {
868 std::shared_ptr<ReadCallbackEntry> entry;
870 std::lock_guard<std::mutex> lock(readCallbacksMutex);
871 auto it = readCallbacks.find(
id);
872 if (it == readCallbacks.end())
877 if (entry->canceled.load() || disconnecting)
884 while (!stuck && !entry->canceled.load() && !disconnecting) {
886 entry->queue.pop([&](
const MessageData &data) {
888 std::unique_ptr<SegmentedMessageData> msg =
889 std::make_unique<MessageData>(data);
890 if (entry->callback(msg)) {
904 sendConsumedEvent(acks);
909 const std::unordered_map<std::shared_ptr<ReadCallbackEntry>, uint64_t>
912 std::lock_guard<std::mutex> lock(ackMutex);
913 json channels = json::array();
914 for (
const auto &[entry, count] : acks)
915 if (!entry->canceled.load())
916 channels.push_back({{
"channel_id", entry->channelId}, {
"count", count}});
917 if (channels.empty())
920 event[
"type"] =
"event";
921 event[
"event"] =
"consumed";
922 event[
"params"] = {{
"channels", std::move(channels)}};
925 ws.sendUtf8Text(event.dump());
933 :
impl(std::make_unique<
Impl>(logger, hostname, port)) {}
940 return impl->getCompressedManifest();
945 return impl->getChannelDesc(channelName, desc);
949 return impl->listChannels();
954 impl->writeToServer(channelName, data);
958 const std::vector<MessageData> &frames,
960 return impl->tryWriteToServer(channelName, frames, force);
964 unsigned maxInFlight) {
965 impl->setToServerMaxInFlight(channelName, maxInFlight);
968std::unique_ptr<RpcClient::ReadChannelConnection>
971 return impl->connectClientReceiver(channelName, std::move(callback),
nlohmann::json json
Get a JSON representation of a type.
std::vector< uint8_t > getCompressedManifest() const
void markConnectionLost()
Wake every writer blocked on credits so it can observe the lost connection and throw.
void handleControlFrame(const std::string &text)
std::atomic< bool > disconnecting
void unsubscribe(uint64_t channelId)
uint32_t getEsiVersion() const
bool tryWriteToServer(const std::string &channelName, const std::vector< MessageData > &frames, bool force)
void failAllPending(const std::string &reason)
enum RpcClient::Impl::OpenState Pending
void unregisterCallback(uint64_t channelId)
std::unordered_map< uint64_t, std::shared_ptr< std::promise< json > > > pending
std::unordered_map< uint64_t, std::shared_ptr< ReadCallbackEntry > > readCallbacks
void onMessage(const ix::WebSocketMessagePtr &msg)
std::unordered_map< uint64_t, ChannelMeta > channelsById
void handleBinaryFrame(const std::string &data)
std::string lastServerError
std::unordered_map< uint64_t, std::unique_ptr< WriteCredit > > writeCredits
std::condition_variable openCV
std::atomic< uint64_t > nextRequestId
std::unordered_map< std::string, ChannelMeta > channelsByName
Impl(Logger &logger, const std::string &hostname, uint16_t port)
std::thread transportThread
std::unique_ptr< RpcClient::ReadChannelConnection > connectClientReceiver(const std::string &channelName, RpcClient::ReadCallback callback, unsigned maxInFlight)
void setToServerMaxInFlight(const std::string &channelName, unsigned maxInFlight)
void sendConsumedEvent(const std::unordered_map< std::shared_ptr< ReadCallbackEntry >, uint64_t > &acks)
Report acks (subscription -> count) of client-bound frames as consumed, leaving out canceled subscrip...
bool getChannelDesc(const std::string &name, RpcClient::ChannelDesc &desc) const
const ChannelMeta & getToServerChannel(const std::string &channelName) const
Look up a to-server channel by name, throwing if it isn't one.
utils::ReadyIdSet< uint64_t > readyIds
std::mutex readCallbacksMutex
void writeToServer(const std::string &channelName, const MessageData &data)
std::vector< RpcClient::ChannelDesc > listChannels() const
std::atomic< bool > channelTablesReady
::esi::cosim::FaultStash faultStash
std::mutex lastServerErrorMutex
std::atomic< bool > connectionLost
json call(const std::string &method, json params)
Issue a JSON-RPC style request and synchronously await the response.
void sendFrame(const ChannelMeta &meta, const MessageData &data)
Send one data frame. Throws on a transport failure.
void handleEvent(const json &frame)
std::vector< uint8_t > manifest
virtual void warning(const std::string &subsystem, const std::string &msg, const std::map< std::string, std::any > *details=nullptr)
Report a warning.
void debug(const std::string &subsystem, const std::string &msg, const std::map< std::string, std::any > *details=nullptr)
Report a debug message.
A concrete flat message backed by a single vector of bytes.
Abstract handle for a read channel connection.
void setToServerMaxInFlight(const std::string &channelName, unsigned maxInFlight)
Set the maximum number of unconsumed frames on a server-bound channel.
ChannelDirection
Channel direction as reported by the server.
std::function< bool(std::unique_ptr< SegmentedMessageData > &)> ReadCallback
Callback type for receiving messages from a client-bound channel.
static constexpr unsigned DefaultToServerMaxInFlight
Default maximum number of frames sent to a server-bound channel which the simulation has not yet cons...
std::unique_ptr< ReadChannelConnection > connectClientReceiver(const std::string &channelName, ReadCallback callback, unsigned maxInFlight=DefaultToClientMaxInFlight)
Connect to a client-bound channel and receive messages via callback.
RpcClient(Logger &logger, const std::string &hostname, uint16_t port)
bool tryWriteToServer(const std::string &channelName, const std::vector< MessageData > &frames, bool force=false)
Non-blocking send of one message, split into frames, to a server-bound channel.
std::vector< uint8_t > getCompressedManifest() const
Get the compressed manifest from the server.
uint32_t getEsiVersion() const
Get the ESI version from the manifest.
void writeToServer(const std::string &channelName, const MessageData &data)
Send a message to a server-bound channel.
bool getChannelDesc(const std::string &channelName, ChannelDesc &desc) const
Get the channel description for a channel name.
std::vector< ChannelDesc > listChannels() const
List all channels available on the server.
std::unique_ptr< Impl > impl
Cross-thread error channel for the IXWebSocket network thread.
void handleBinaryFrame(uint64_t generation, const std::string &data)
void sendConsumedEvent(const std::unordered_set< uint64_t > &ids)
Send a batched consumed event for any to-server messages the simulation has consumed since the last c...
void handleControlFrame(ix::WebSocket &ws, const std::string &text)
Impl(Context &ctxt, int port)
void handleEvent(uint64_t generation, const json &frame)
std::thread transportThread
Multi-producer / single-consumer dirty-set of channel ids, with CV-style blocking drain semantics.
std::string buildDataFrame(uint64_t channelId, const uint8_t *bytes, size_t size)
Pack a cosim binary data frame: [u64 LE channel_id][payload].
constexpr uint64_t kProtocolVersion
Wire protocol version.
bool parseDataFrame(const std::string &data, uint64_t &channelId, const uint8_t *&payload, size_t &payloadSize)
Parse a cosim binary data frame.
ReadCallbackEntry(uint64_t channelId, std::function< void()> notifier)
RpcClient::ReadCallback callback
std::atomic< bool > canceled
utils::TSQueue< MessageData > queue
std::condition_variable cv
Description of a channel from the server.