CIRCT 24.0.0git
Loading...
Searching...
No Matches
RpcClient.cpp
Go to the documentation of this file.
1//===- RpcClient.cpp - ESI Cosim RPC client implementation ----------------===//
2//
3// Part of the LLVM Project, under the Apache License v2.0 with LLVM Exceptions.
4// See https://llvm.org/LICENSE.txt for license information.
5// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
6//
7//===----------------------------------------------------------------------===//
8//
9// Client implementation of the cosim WebSocket + JSON protocol. The wire
10// protocol is documented in cosim-protocol.md.
11//
12//===----------------------------------------------------------------------===//
13
15#include "esi/Logging.h"
16#include "esi/Utils.h"
17
18#include "RpcWire.h"
19
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>
25
26#include <atomic>
27#include <chrono>
28#include <condition_variable>
29#include <cstdint>
30#include <cstring>
31#include <future>
32#include <map>
33#include <memory>
34#include <mutex>
35#include <stdexcept>
36#include <string>
37#include <thread>
38#include <unordered_map>
39#include <vector>
40
41using namespace esi;
42using namespace esi::backends::cosim;
43using json = nlohmann::json;
44
45//===----------------------------------------------------------------------===//
46// RpcClient::Impl - hides IXWebSocket + JSON behind the public header
47//===----------------------------------------------------------------------===//
48
49namespace {
50class ReadChannelConnectionImpl;
51} // namespace
52
54public:
55 Impl(Logger &logger, const std::string &hostname, uint16_t port);
57
58 Logger &getLogger() { return logger; }
59
60 uint32_t getEsiVersion() const { return esiVersion; }
61 std::vector<uint8_t> getCompressedManifest() const { return manifest; }
62
63 bool getChannelDesc(const std::string &name,
64 RpcClient::ChannelDesc &desc) const;
65 std::vector<RpcClient::ChannelDesc> listChannels() const;
66
67 void writeToServer(const std::string &channelName, const MessageData &data);
68 bool tryWriteToServer(const std::string &channelName,
69 const std::vector<MessageData> &frames, bool force);
70 void setToServerMaxInFlight(const std::string &channelName,
71 unsigned maxInFlight);
72
73 std::unique_ptr<RpcClient::ReadChannelConnection>
74 connectClientReceiver(const std::string &channelName,
75 RpcClient::ReadCallback callback, unsigned maxInFlight);
76
77 // Helpers used by ReadChannelConnectionImpl.
78 void unsubscribe(uint64_t channelId);
79 void unregisterCallback(uint64_t channelId);
80
81private:
82 struct ChannelMeta {
83 uint64_t id;
84 std::string name;
85 std::string type;
87 };
88
89 // ---- websocket lifecycle ----
90 void onMessage(const ix::WebSocketMessagePtr &msg);
91 void handleControlFrame(const std::string &text);
92 void handleEvent(const json &frame);
93 void handleBinaryFrame(const std::string &data);
94 void failAllPending(const std::string &reason);
95 /// Wake every writer blocked on credits so it can observe the lost
96 /// connection and throw.
98
99 // ---- request/response plumbing ----
100
101 /// Issue a JSON-RPC style request and synchronously await the response.
102 /// Throws on error (transport or server-reported).
103 json call(const std::string &method, json params);
104
105 /// Look up a to-server channel by name, throwing if it isn't one.
106 const ChannelMeta &getToServerChannel(const std::string &channelName) const;
107 /// Send one data frame. Throws on a transport failure.
108 void sendFrame(const ChannelMeta &meta, const MessageData &data);
109
110 std::string host;
112
113 // Manifest + channel table cached from the `hello` response.
114 uint32_t esiVersion = 0;
115 std::vector<uint8_t> manifest;
116 std::unordered_map<std::string, ChannelMeta> channelsByName;
117 std::unordered_map<uint64_t, ChannelMeta> channelsById;
118
119 // Per to-server channel credit state. `outstanding` counts frames sent but
120 // not yet reported consumed by the server. Writers block on `cv` while
121 // `outstanding >= maxInFlight` (unless it is 0, i.e. unbounded). The map
122 // is populated once in the constructor and never mutated afterwards, so
123 // lookups need no lock.
124 struct WriteCredit {
125 std::mutex mutex;
126 std::condition_variable cv;
127 uint64_t outstanding = 0;
129 };
130 std::unordered_map<uint64_t, std::unique_ptr<WriteCredit>> writeCredits;
131 std::atomic<bool> connectionLost{false};
132 // Set once the constructor has populated the channel tables. Events from
133 // the server which arrive earlier (e.g. stale credits for a previous
134 // client's frames) are ignored.
135 std::atomic<bool> channelTablesReady{false};
136
137 // Pending control-plane requests, keyed by id.
138 std::mutex pendingMutex;
139 std::atomic<uint64_t> nextRequestId{1};
140 std::unordered_map<uint64_t, std::shared_ptr<std::promise<json>>> pending;
141
142 // Per-channel read state. Connection-time `subscribe` registers; the
143 // ReadChannelConnection destructor unregisters and sends `unsubscribe`.
144 //
145 // Each entry owns its own non-blocking TSQueue: `handleBinaryFrame` only
146 // pushes into the queue (on IX's single per-connection network thread),
147 // and a dedicated transport thread is what actually invokes the
148 // user-supplied callback. That decoupling is what makes the client
149 // cross-channel-deadlock-safe regardless of what the user's callback
150 // does: a callback that returns `false` (rejects the message) only
151 // delays its own channel -- the IX thread is free to keep delivering on
152 // other channels.
153 //
154 // The queue's `notifier` rings the server-wide doorbell
155 // (`Impl::markReady`) so the transport thread wakes up only when there's
156 // work for a specific channel.
157 //
158 // Held by `shared_ptr` so the transport thread can copy the entry under
159 // the lock and outlive a concurrent `unregisterCallback` that erases the
160 // map entry: the cancel flag flips, the transport thread sees it on its
161 // next iteration, and drops anything still in the queue.
164 std::atomic<bool> canceled{false};
165 uint64_t channelId;
167
168 ReadCallbackEntry(uint64_t channelId, std::function<void()> notifier)
169 : channelId(channelId), queue(std::move(notifier)) {}
170 };
172 std::unordered_map<uint64_t, std::shared_ptr<ReadCallbackEntry>>
174 // Orders sending acknowledgements against canceling a subscription:
175 // `consumed` events are sent under it and leave out canceled entries, and
176 // entries are canceled under it before `unsubscribe` is sent. So an
177 // acknowledgement never follows its subscription's `unsubscribe` (and
178 // perhaps a new `subscribe`) and gets counted against the next one.
179 std::mutex ackMutex;
180
181 // Dirty-set doorbell for the transport thread. Each entry's TSQueue
182 // notifier inserts the channel id into `readyIds` and wakes the consumer.
184 std::thread transportThread;
185
187 /// Report `acks` (subscription -> count) of client-bound frames as
188 /// consumed, leaving out canceled subscriptions.
190 const std::unordered_map<std::shared_ptr<ReadCallbackEntry>, uint64_t>
191 &acks);
192
193 // Connection-open synchronization. The WS callback fires on IX's background
194 // thread, so the constructor blocks here until either Open or Error fires.
195 std::mutex openMutex;
196 std::condition_variable openCV;
197 enum class OpenState { Pending, Open, Failed } openState{OpenState::Pending};
198 std::string openError;
199
200 // Most recent unsolicited server-initiated error (`type="error"`), if any.
201 // The server sends one of these before closing the WS to explain why (e.g.
202 // `server_busy`). Recorded so a subsequent Close/Error path can surface it
203 // instead of the much-less-actionable raw WebSocket close reason.
205 std::string lastServerError;
206
207 std::atomic<bool> disconnecting{false};
208
209 // Cross-thread fault propagation out of the IX network thread. See
210 // FaultStash docs in RpcWire.h.
212
213 // Declared last so it's destroyed first: its destructor stops IX's threads,
214 // whose callbacks use the members above. That matters when the constructor
215 // throws after starting it, since then `~Impl()` doesn't run.
216 ix::WebSocket ws;
217};
218
219//===----------------------------------------------------------------------===//
220// ReadChannelConnectionImpl
221//
222// Held by the consumer of `connectClientReceiver`. Destructor unregisters the
223// callback and sends `unsubscribe`. The actual incoming bytes are delivered
224// by the shared websocket callback in RpcClient::Impl.
225//===----------------------------------------------------------------------===//
226
227namespace {
228class ReadChannelConnectionImpl : public RpcClient::ReadChannelConnection {
229public:
230 ReadChannelConnectionImpl(RpcClient::Impl *impl, uint64_t channelId)
231 : impl(impl), channelId(channelId) {}
232 ~ReadChannelConnectionImpl() override { disconnect(); }
233
234 void disconnect() override {
235 if (disconnected.exchange(true))
236 return;
237 impl->unregisterCallback(channelId);
238 try {
239 impl->unsubscribe(channelId);
240 } catch (const std::exception &e) {
241 // We're tearing down a per-channel reader and can't propagate the
242 // exception (this runs in a destructor). Log it so the failure isn't
243 // silently lost.
244 impl->getLogger().warning(
245 "cosim",
246 std::string("unsubscribe during disconnect failed: ") + e.what());
247 }
248 }
249
250private:
252 uint64_t channelId;
253 std::atomic<bool> disconnected{false};
254};
255} // namespace
256
257//===----------------------------------------------------------------------===//
258// RpcClient::Impl - construction / `hello` handshake
259//===----------------------------------------------------------------------===//
260
261RpcClient::Impl::Impl(Logger &logger, const std::string &hostname,
262 uint16_t port)
263 : host(hostname), logger(logger) {
264 // On Windows, `ix::initNetSystem()` calls `WSAStartup`; bail out cleanly if
265 // it fails rather than letting the WS start path fail with a less
266 // actionable `WSANOTINITIALISED`. No-op on other platforms.
267 if (!ix::initNetSystem())
268 throw std::runtime_error(
269 "RpcClient: ix::initNetSystem() failed (WSAStartup)");
270
271 ws.setUrl("ws://" + host + ":" + std::to_string(port) + "/esi/cosim/v" +
272 std::to_string(::esi::cosim::kProtocolVersion));
273 ws.disablePerMessageDeflate();
274 ws.disableAutomaticReconnection();
275
276 ws.setOnMessageCallback(
277 [this](const ix::WebSocketMessagePtr &msg) { onMessage(msg); });
278
279 ws.start();
280
281 // Wait for the WS to reach Open (or fail).
282 {
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);
288 }
289
290 // Send `hello`; the server blocks the response until its manifest is ready.
291 json result = call(
292 "hello", {{"client_protocol_version", ::esi::cosim::kProtocolVersion}});
293 auto verIt = result.find("protocol_version");
294 if (verIt == result.end() || !verIt->is_number_unsigned() ||
295 verIt->get<uint64_t>() != ::esi::cosim::kProtocolVersion)
296 throw std::runtime_error(
297 "RpcClient: server speaks an unsupported cosim protocol version (" +
298 (verIt == result.end() ? std::string("none") : verIt->dump()) +
299 "); expected " + std::to_string(::esi::cosim::kProtocolVersion));
300
301 // Cache manifest blob.
302 if (auto it = result.find("esi_version"); it != result.end())
303 esiVersion = it->get<uint32_t>();
304 if (auto it = result.find("compressed_manifest_b64"); it != result.end()) {
305 // macaron::Base64::Decode returns an empty error string on success and
306 // writes raw bytes into the out string; reinterpret as a byte vector.
307 std::string raw;
308 std::string err = macaron::Base64::Decode(it->get<std::string>(), raw);
309 if (!err.empty())
310 throw std::runtime_error("RpcClient: invalid base64 in manifest blob: " +
311 err);
312 manifest.assign(raw.begin(), raw.end());
313 }
314
315 // Cache channel table.
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) {
320 ChannelMeta meta;
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));
331 }
332 // Publish the channel tables to the IX network thread (see handleEvent).
333 channelTablesReady.store(true, std::memory_order_release);
334
335 // Start the transport thread now that initialisation is complete. It
336 // sleeps on `readyIds`'s internal CV until a per-channel TSQueue push
337 // wakes it.
338 transportThread = std::thread([this] { transportLoop(); });
339}
340
342 disconnecting = true;
343 failAllPending("client shutdown");
344 markConnectionLost();
345 // Mark every per-channel entry canceled so the transport loop drops any
346 // remaining queued frames promptly instead of spinning on the user
347 // callback.
348 {
349 std::lock_guard<std::mutex> lock(readCallbacksMutex);
350 for (auto &[id, entry] : readCallbacks)
351 entry->canceled.store(true);
352 }
353 // Stop IX *first* so no more `handleBinaryFrame` calls can fire and push
354 // into the per-channel queues. ws.stop() joins IX's internal threads
355 // synchronously.
356 ws.stop();
357 // Now retire the transport thread; nothing else is producing for it.
358 if (transportThread.joinable()) {
359 readyIds.requestShutdown();
360 transportThread.join();
361 }
362}
363
364//===----------------------------------------------------------------------===//
365// RpcClient::Impl - WebSocket message dispatch
366//===----------------------------------------------------------------------===//
367
368void RpcClient::Impl::onMessage(const ix::WebSocketMessagePtr &msg) {
369 switch (msg->type) {
370 case ix::WebSocketMessageType::Open: {
371 std::lock_guard<std::mutex> lock(openMutex);
372 openState = OpenState::Open;
373 openCV.notify_all();
374 return;
375 }
376 case ix::WebSocketMessageType::Error: {
377 std::string reason = msg->errorInfo.reason;
378 {
379 std::lock_guard<std::mutex> lock(lastServerErrorMutex);
380 if (!lastServerError.empty())
381 reason = lastServerError;
382 }
383 {
384 std::lock_guard<std::mutex> lock(openMutex);
385 if (openState == OpenState::Pending) {
386 openState = OpenState::Failed;
387 openError = reason;
388 openCV.notify_all();
389 }
390 }
391 failAllPending("websocket error: " + reason);
392 markConnectionLost();
393 return;
394 }
395 case ix::WebSocketMessageType::Close: {
396 std::string reason = msg->closeInfo.reason;
397 {
398 std::lock_guard<std::mutex> lock(lastServerErrorMutex);
399 if (!lastServerError.empty())
400 reason = lastServerError;
401 }
402 // If the server closed us before `Open` settled (e.g. immediate
403 // `server_busy` reject), surface that as an Open failure so the
404 // constructor's wait unblocks with a useful error.
405 {
406 std::lock_guard<std::mutex> lock(openMutex);
407 if (openState == OpenState::Pending) {
408 openState = OpenState::Failed;
409 openError = reason;
410 openCV.notify_all();
411 }
412 }
413 failAllPending("websocket closed: " + reason);
414 markConnectionLost();
415 return;
416 }
417 case ix::WebSocketMessageType::Message:
418 // An exception escaping this callback would kill IX's network thread.
419 // Stash the first one so the next public RpcClient method rethrows it
420 // on the user's thread.
421 try {
422 if (msg->binary)
423 handleBinaryFrame(msg->str);
424 else
425 handleControlFrame(msg->str);
426 } catch (...) {
427 faultStash.record(std::current_exception());
428 }
429 return;
430 default:
431 return;
432 }
433}
434
435void RpcClient::Impl::handleControlFrame(const std::string &text) {
436 json resp;
437 try {
438 resp = json::parse(text);
439 } catch (const std::exception &e) {
440 // Unparseable text frame: nothing useful we can do.
441 return;
442 }
443 auto typeIt = resp.find("type");
444 if (typeIt == resp.end() || !typeIt->is_string())
445 return;
446 std::string type = typeIt->get<std::string>();
447
448 // Unsolicited server-initiated error. The server sends one of these before
449 // closing the WS (e.g. `server_busy`). Stash the message so the imminent
450 // Close event surfaces this human-readable reason instead of just the raw
451 // WebSocket close reason.
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;
458 }
459 return;
460 }
461
462 if (type == "event") {
463 handleEvent(resp);
464 return;
465 }
466
467 if (type != "response") {
468 // Unknown control-frame type. Per the protocol, receivers MUST ignore
469 // unrecognised fields/types so the wire format can evolve additively
470 // under the same `protocol_version`, but warn so genuine misbehavior
471 // doesn't go silently dropped.
472 logger.debug("cosim",
473 "Ignoring control frame with unknown type \"" + type + "\"");
474 return;
475 }
476 auto idIt = resp.find("request_id");
477 if (idIt == resp.end())
478 return;
479 uint64_t requestId = idIt->get<uint64_t>();
480
481 std::shared_ptr<std::promise<json>> promise;
482 {
483 std::lock_guard<std::mutex> lock(pendingMutex);
484 auto it = pending.find(requestId);
485 if (it == pending.end())
486 return;
487 promise = it->second;
488 // Erase under the lock so a concurrent `failAllPending` won't also
489 // try to fulfill the same promise.
490 pending.erase(it);
491 }
492
493 try {
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);
501 } else {
502 // Per the wire spec, a `type=response` frame MUST carry either
503 // `result` (success) or `error` (failure). Surface the protocol
504 // violation to the caller instead of pretending it succeeded.
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))));
509 }
510 } catch (const std::exception &e) {
511 // Promise might be already satisfied if we raced with a shutdown that
512 // already fulfilled it via failAllPending. Benign, but log so unexpected
513 // duplicate responses from the server don't go entirely unnoticed.
514 logger.debug("cosim",
515 std::string("failed to fulfill promise for request_id=") +
516 std::to_string(requestId) + ": " + e.what());
517 }
518}
519
520void RpcClient::Impl::handleEvent(const json &frame) {
521 std::string event = frame.value("event", std::string());
522 if (event != "consumed") {
523 logger.debug("cosim", "Ignoring unknown server event \"" + event + "\"");
524 return;
525 }
526 if (!channelTablesReady.load(std::memory_order_acquire))
527 return;
528 auto paramsIt = frame.find("params");
529 if (paramsIt == frame.end() || !paramsIt->is_object())
530 return;
531 auto chIt = paramsIt->find("channels");
532 if (chIt == paramsIt->end() || !chIt->is_array())
533 return;
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());
540 continue;
541 }
542 auto credIt = writeCredits.find(idIt->get<uint64_t>());
543 if (credIt == writeCredits.end())
544 continue;
545 WriteCredit &cred = *credIt->second;
546 uint64_t count = countIt->get<uint64_t>();
547 {
548 std::lock_guard<std::mutex> lock(cred.mutex);
549 // Clamp: credits for frames sent by a previous client are possible.
550 cred.outstanding -= (std::min)(cred.outstanding, count);
551 }
552 cred.cv.notify_all();
553 }
554}
555
557 connectionLost.store(true);
558 if (!channelTablesReady.load(std::memory_order_acquire))
559 return;
560 for (auto &[id, cred] : writeCredits) {
561 // Take the lock so a writer can't miss the wakeup between checking its
562 // predicate and blocking.
563 { std::lock_guard<std::mutex> lock(cred->mutex); }
564 cred->cv.notify_all();
565 }
566}
567
568void RpcClient::Impl::handleBinaryFrame(const std::string &data) {
569 uint64_t channelId;
570 const uint8_t *payloadBytes;
571 size_t payloadSize;
572 if (!::esi::cosim::parseDataFrame(data, channelId, payloadBytes, payloadSize))
573 return;
574
575 std::shared_ptr<ReadCallbackEntry> entry;
576 {
577 std::lock_guard<std::mutex> lock(readCallbacksMutex);
578 auto it = readCallbacks.find(channelId);
579 if (it == readCallbacks.end()) {
580 // Per the protocol, the server MUST stop sending frames for a channel
581 // after the unsubscribe-ack. A frame for an unknown channel is
582 // therefore either a server bug or a misrouted message: warn rather
583 // than silently dropping so the issue is visible.
584 logger.warning("cosim", "Dropping inbound frame for channel id " +
585 std::to_string(channelId) +
586 ": no active subscriber");
587 return;
588 }
589 entry = it->second;
590 }
591
592 // Push the payload into the per-channel queue and return immediately;
593 // never block IX's network thread on the user-supplied callback. The
594 // queue's notifier rings the transport doorbell, and the dedicated
595 // transport thread is what actually invokes the callback.
596 entry->queue.push(MessageData(payloadBytes, payloadSize));
597}
598
599void RpcClient::Impl::failAllPending(const std::string &reason) {
600 std::lock_guard<std::mutex> lock(pendingMutex);
601 for (auto &[id, pr] : pending) {
602 try {
603 pr->set_exception(std::make_exception_ptr(std::runtime_error(reason)));
604 } catch (const std::exception &e) {
605 // Already-satisfied promise; benign but log it in case a real bug is
606 // racing with shutdown.
607 logger.debug("cosim", std::string("failAllPending: request_id=") +
608 std::to_string(id) +
609 " already satisfied: " + e.what());
610 }
611 }
612 pending.clear();
613}
614
615//===----------------------------------------------------------------------===//
616// RpcClient::Impl - request/response, sends
617//===----------------------------------------------------------------------===//
618
619json RpcClient::Impl::call(const std::string &method, json params) {
620 faultStash.check();
621 uint64_t requestId = nextRequestId.fetch_add(1);
622 std::future<json> future;
623 {
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));
628 }
629
630 json req;
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();
636
637 // No send-side mutex needed: IXWebSocket's `sendUtf8Text` is internally
638 // serialized with `sendBinary`, and every cosim frame is a single send
639 // call (no header+body splits).
640 auto info = ws.sendUtf8Text(text);
641 if (!info.success) {
642 std::lock_guard<std::mutex> plock(pendingMutex);
643 pending.erase(requestId);
644 throw std::runtime_error("RpcClient: failed to send " + method);
645 }
646
647 // Bounded wait so callers cannot hang forever on a missing/lost response.
648 // On WS close or error, `failAllPending()` fires and the future becomes
649 // ready immediately. The timeout only matters if the server stays
650 // connected but never replies (server bug, lost control frame).
651 constexpr auto kCallTimeout = std::chrono::seconds(30);
652 if (future.wait_for(kCallTimeout) != std::future_status::ready) {
653 // Drop the pending entry so a late response doesn't fulfill a
654 // destroyed promise (with shared_ptr it's still safe, but it would
655 // otherwise leak the slot forever).
656 std::lock_guard<std::mutex> plock(pendingMutex);
657 pending.erase(requestId);
658 throw std::runtime_error("RpcClient: timed out waiting for response to " +
659 method);
660 }
661 return future.get();
662}
663
664const RpcClient::Impl::ChannelMeta &
665RpcClient::Impl::getToServerChannel(const std::string &channelName) const {
666 auto it = channelsByName.find(channelName);
667 if (it == channelsByName.end())
668 throw std::runtime_error("Unknown channel '" + channelName + "'");
669 if (it->second.direction != RpcClient::ChannelDirection::ToServer)
670 throw std::runtime_error("Channel '" + channelName +
671 "' is not a to-server channel");
672 return it->second;
673}
674
675void RpcClient::Impl::sendFrame(const ChannelMeta &meta,
676 const MessageData &data) {
677 std::string frame =
678 esi::cosim::buildDataFrame(meta.id, data.getBytes(), data.getSize());
679 // IXWebSocket serializes sends internally; no extra mutex needed.
680 auto info = ws.sendBinary(frame);
681 if (!info.success)
682 throw std::runtime_error("Failed to send data on channel '" + meta.name +
683 "'");
684}
685
686void RpcClient::Impl::writeToServer(const std::string &channelName,
687 const MessageData &data) {
688 faultStash.check();
689 const ChannelMeta &meta = getToServerChannel(channelName);
690 WriteCredit &cred = *writeCredits.at(meta.id);
691 {
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;
696 });
697 if (connectionLost.load())
698 throw std::runtime_error("Connection to cosim server lost while "
699 "writing to channel '" +
700 channelName + "'");
701 ++cred.outstanding;
702 }
703 sendFrame(meta, data);
704}
705
706bool RpcClient::Impl::tryWriteToServer(const std::string &channelName,
707 const std::vector<MessageData> &frames,
708 bool force) {
709 faultStash.check();
710 const ChannelMeta &meta = getToServerChannel(channelName);
711 WriteCredit &cred = *writeCredits.at(meta.id);
712 {
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 '" +
717 channelName + "'");
718 if (!force && cred.maxInFlight != 0 && cred.outstanding >= cred.maxInFlight)
719 return false;
720 cred.outstanding += frames.size();
721 }
722 for (const MessageData &frame : frames)
723 sendFrame(meta, frame);
724 return true;
725}
726
727void RpcClient::Impl::setToServerMaxInFlight(const std::string &channelName,
728 unsigned maxInFlight) {
729 const ChannelMeta &meta = getToServerChannel(channelName);
730 WriteCredit &cred = *writeCredits.at(meta.id);
731 {
732 std::lock_guard<std::mutex> lock(cred.mutex);
733 cred.maxInFlight = maxInFlight;
734 }
735 cred.cv.notify_all();
736}
737
738//===----------------------------------------------------------------------===//
739// RpcClient::Impl - channel descriptor lookup
740//===----------------------------------------------------------------------===//
741
742bool RpcClient::Impl::getChannelDesc(const std::string &name,
743 RpcClient::ChannelDesc &desc) const {
744 auto it = channelsByName.find(name);
745 if (it == channelsByName.end())
746 return false;
747 desc.name = it->second.name;
748 desc.type = it->second.type;
749 desc.dir = it->second.direction;
750 return true;
751}
752
753std::vector<RpcClient::ChannelDesc> RpcClient::Impl::listChannels() const {
754 std::vector<RpcClient::ChannelDesc> out;
755 out.reserve(channelsByName.size());
756 for (const auto &[name, meta] : channelsByName) {
757 RpcClient::ChannelDesc d;
758 d.name = meta.name;
759 d.type = meta.type;
760 d.dir = meta.direction;
761 out.push_back(std::move(d));
762 }
763 return out;
764}
765
766//===----------------------------------------------------------------------===//
767// RpcClient::Impl - subscribe / unsubscribe
768//===----------------------------------------------------------------------===//
769
770std::unique_ptr<RpcClient::ReadChannelConnection>
771RpcClient::Impl::connectClientReceiver(const std::string &channelName,
773 unsigned maxInFlight) {
774 faultStash.check();
775 auto it = channelsByName.find(channelName);
776 if (it == channelsByName.end())
777 throw std::runtime_error("Unknown channel '" + channelName + "'");
778 if (it->second.direction != RpcClient::ChannelDirection::ToClient)
779 throw std::runtime_error("Channel '" + channelName +
780 "' is not a to-client channel");
781 uint64_t channelId = it->second.id;
782
783 // Register the callback first so any racing inbound binary frame after the
784 // server's subscribe-ack has somewhere to land. Refuse a second receiver
785 // here rather than letting it replace the first one's callback.
786 auto entry = std::make_shared<ReadCallbackEntry>(
787 channelId, [this, channelId] { readyIds.markReady(channelId); });
788 entry->callback = std::move(callback);
789 {
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");
794 }
795
796 try {
797 call("subscribe",
798 {{"channel_id", channelId}, {"max_in_flight", maxInFlight}});
799 } catch (...) {
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);
804 throw;
805 }
806
807 return std::make_unique<ReadChannelConnectionImpl>(this, channelId);
808}
809
810void RpcClient::Impl::unsubscribe(uint64_t channelId) {
811 if (disconnecting)
812 return;
813 // Skip the RPC if the WS is already closed (server tore down first, network
814 // drop, etc.). The unsubscribe is moot anyway, and otherwise every
815 // per-channel cleanup during teardown would generate a noisy "failed to
816 // send unsubscribe" warning from ReadChannelConnectionImpl::disconnect.
817 if (ws.getReadyState() != ix::ReadyState::Open)
818 return;
819 call("unsubscribe", {{"channel_id", channelId}});
820}
821
822void RpcClient::Impl::unregisterCallback(uint64_t channelId) {
823 std::shared_ptr<ReadCallbackEntry> entry;
824 {
825 std::lock_guard<std::mutex> lock(readCallbacksMutex);
826 auto it = readCallbacks.find(channelId);
827 if (it == readCallbacks.end())
828 return;
829 entry = it->second;
830 readCallbacks.erase(it);
831 }
832 // Flip the cancel flag on the entry that any in-flight transport-loop
833 // iteration already copied out, so it bails out promptly and drops any
834 // remaining queued frames (and their acknowledgements; see `ackMutex`).
835 {
836 std::lock_guard<std::mutex> lock(ackMutex);
837 entry->canceled.store(true);
838 }
839 // Nudge the transport thread so it observes the cancelation promptly and
840 // drains+drops whatever's still queued.
841 readyIds.markReady(channelId);
842}
843
844//===----------------------------------------------------------------------===//
845// RpcClient::Impl - transport thread (per-channel deadlock isolation)
846//===----------------------------------------------------------------------===//
847
849 // Channels whose last delivery attempt left a message at the head of the
850 // queue (user callback returned `false`). We retry them after a short
851 // backoff so other channels still get a chance every wake, and so a
852 // permanently-busy channel doesn't pin the CPU.
853 std::unordered_set<uint64_t> retry;
854
855 while (true) {
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)))
860 return;
861 ids.insert(retry.begin(), retry.end());
862 retry.clear();
863
864 // Frames consumed this pass, per subscription. Reported to the server in
865 // one batched event at the end of the pass to return their credits.
866 std::unordered_map<std::shared_ptr<ReadCallbackEntry>, uint64_t> acks;
867 for (uint64_t id : ids) {
868 std::shared_ptr<ReadCallbackEntry> entry;
869 {
870 std::lock_guard<std::mutex> lock(readCallbacksMutex);
871 auto it = readCallbacks.find(id);
872 if (it == readCallbacks.end())
873 continue;
874 entry = it->second;
875 }
876
877 if (entry->canceled.load() || disconnecting)
878 continue;
879
880 // Best-effort drain. `TSQueue::pop(callback)` peeks at the front and
881 // only pops if our callback returns true; on `false` we leave the
882 // message at the head and mark the channel for retry.
883 bool stuck = false;
884 while (!stuck && !entry->canceled.load() && !disconnecting) {
885 bool gotOne = false;
886 entry->queue.pop([&](const MessageData &data) {
887 gotOne = true;
888 std::unique_ptr<SegmentedMessageData> msg =
889 std::make_unique<MessageData>(data);
890 if (entry->callback(msg)) {
891 ++acks[entry];
892 return true;
893 }
894 stuck = true;
895 return false;
896 });
897 if (!gotOne)
898 break; // queue empty
899 if (stuck)
900 retry.insert(id);
901 }
902 }
903 if (!acks.empty())
904 sendConsumedEvent(acks);
905 }
906}
907
909 const std::unordered_map<std::shared_ptr<ReadCallbackEntry>, uint64_t>
910 &acks) {
911 // Held while sending; see `ackMutex`.
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())
918 return;
919 json event;
920 event["type"] = "event";
921 event["event"] = "consumed";
922 event["params"] = {{"channels", std::move(channels)}};
923 // A failed send means the connection is going away, and with it the
924 // server's flow-control state; nothing to recover.
925 ws.sendUtf8Text(event.dump());
926}
927
928//===----------------------------------------------------------------------===//
929// RpcClient - public pass-throughs (unchanged API)
930//===----------------------------------------------------------------------===//
931
932RpcClient::RpcClient(Logger &logger, const std::string &hostname, uint16_t port)
933 : impl(std::make_unique<Impl>(logger, hostname, port)) {}
934
935RpcClient::~RpcClient() = default;
936
937uint32_t RpcClient::getEsiVersion() const { return impl->getEsiVersion(); }
938
939std::vector<uint8_t> RpcClient::getCompressedManifest() const {
940 return impl->getCompressedManifest();
941}
942
943bool RpcClient::getChannelDesc(const std::string &channelName,
944 ChannelDesc &desc) const {
945 return impl->getChannelDesc(channelName, desc);
946}
947
948std::vector<RpcClient::ChannelDesc> RpcClient::listChannels() const {
949 return impl->listChannels();
950}
951
952void RpcClient::writeToServer(const std::string &channelName,
953 const MessageData &data) {
954 impl->writeToServer(channelName, data);
955}
956
957bool RpcClient::tryWriteToServer(const std::string &channelName,
958 const std::vector<MessageData> &frames,
959 bool force) {
960 return impl->tryWriteToServer(channelName, frames, force);
961}
962
963void RpcClient::setToServerMaxInFlight(const std::string &channelName,
964 unsigned maxInFlight) {
965 impl->setToServerMaxInFlight(channelName, maxInFlight);
966}
967
968std::unique_ptr<RpcClient::ReadChannelConnection>
969RpcClient::connectClientReceiver(const std::string &channelName,
970 ReadCallback callback, unsigned maxInFlight) {
971 return impl->connectClientReceiver(channelName, std::move(callback),
972 maxInFlight);
973}
nlohmann::json json
Get a JSON representation of a type.
Definition RpcClient.cpp:43
std::vector< uint8_t > getCompressedManifest() const
Definition RpcClient.cpp:61
void markConnectionLost()
Wake every writer blocked on credits so it can observe the lost connection and throw.
void handleControlFrame(const std::string &text)
void unsubscribe(uint64_t channelId)
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::unordered_map< uint64_t, std::unique_ptr< WriteCredit > > writeCredits
std::atomic< uint64_t > nextRequestId
std::unordered_map< std::string, ChannelMeta > channelsByName
Impl(Logger &logger, const std::string &hostname, uint16_t port)
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
void writeToServer(const std::string &channelName, const MessageData &data)
std::vector< RpcClient::ChannelDesc > listChannels() const
::esi::cosim::FaultStash faultStash
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)
virtual void warning(const std::string &subsystem, const std::string &msg, const std::map< std::string, std::any > *details=nullptr)
Report a warning.
Definition Logging.h:70
void debug(const std::string &subsystem, const std::string &msg, const std::map< std::string, std::any > *details=nullptr)
Report a debug message.
Definition Logging.h:83
A concrete flat message backed by a single vector of bytes.
Definition Common.h:197
Abstract handle for a read channel connection.
Definition RpcClient.h:102
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.
Definition RpcClient.h:50
std::function< bool(std::unique_ptr< SegmentedMessageData > &)> ReadCallback
Callback type for receiving messages from a client-bound channel.
Definition RpcClient.h:97
static constexpr unsigned DefaultToServerMaxInFlight
Default maximum number of frames sent to a server-bound channel which the simulation has not yet cons...
Definition RpcClient.h:68
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
Definition RpcClient.h:121
Cross-thread error channel for the IXWebSocket network thread.
Definition RpcWire.h:73
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)
Multi-producer / single-consumer dirty-set of channel ids, with CV-style blocking drain semantics.
Definition Utils.h:177
Thread safe queue.
Definition Utils.h:48
void info(Twine message)
Definition LSPUtils.cpp:20
std::string buildDataFrame(uint64_t channelId, const uint8_t *bytes, size_t size)
Pack a cosim binary data frame: [u64 LE channel_id][payload].
Definition RpcWire.h:33
constexpr uint64_t kProtocolVersion
Wire protocol version.
Definition RpcWire.h:30
bool parseDataFrame(const std::string &data, uint64_t &channelId, const uint8_t *&payload, size_t &payloadSize)
Parse a cosim binary data frame.
Definition RpcWire.h:52
Definition esi.py:1
ChannelDirection dir
Definition RpcClient.h:56
ReadCallbackEntry(uint64_t channelId, std::function< void()> notifier)
Description of a channel from the server.
Definition RpcClient.h:53