* fix(plain): close the RPC socket on the connection's strand RpcConnection<Stream>::fail() closed the socket on whatever thread called it. Every other access to m_stream is serialized on m_strand — start() and writeFrame() post onto it, doRead()/doWrite() complete through bind_executor(m_strand, ...) — but a strand serializes handlers, not a raw call made from outside it, and asio sockets are documented as unsafe for concurrent use. Consumer teardown (~RpcClient -> ~PlainTransportConnection -> stop() -> fail()) therefore ran close() -> cleanup_descriptor_data(), nulling impl.reactor_data_, while the io worker thread was inside reactive_socket_service_base::start_op() for a doWrite() that had just been posted. start_op()'s 'descriptor_data' is a reference to that member: the null check passes before the store lands, then the shutdown_ read after it dereferences null. SIGSEGV at +0x98 on the IoContextPool thread. fail() now hands the close to the strand via boost::asio::dispatch, which runs it inline when fail() is already on the strand (the io-thread error path, unchanged behaviour) and queues it otherwise. dispatch never blocks, so teardown cannot deadlock or hang; the lambda holds a shared_ptr so a close queued from a destructor still finds a live object. writeFrame()'s m_stopped check is also repeated inside the posted lambda and in doWrite(): the outer load is only a hint, and fail() can land between it and the handler. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(plain): a teardown-race regression that also guards against leaks and hangs Hammers the shape that crashed: a consumer connection with frames still queued is destroyed from its own thread, 400 times over, while the io worker is initiating the async_write for a just-posted frame. Pre-fix this takes the whole test binary down inside asio's reactor; post-fix the close runs on the strand and can never overlap a write initiation. The same loop is the guard for the two things the fix could plausibly break: the descriptor count must come back (an async close that never runs would strand fds) and the loop must finish promptly (a close that blocked on the io thread would show up as a stall). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(plain): stop dispatching inbound frames once the connection failed Moving the close onto the strand left the socket open between stop() returning and the strand getting to it. A frame that arrived in that gap still ran through handleFrame -> dispatchIncoming and into the IncomingCallHandler — which, on the host side, the caller may already be in the middle of destroying (RpcServer::stop() runs from ~PlainTransportHost). Before the close moved, the immediate close aborted the read and that frame never landed. The connection is torn down either way: every pending promise has already been failed and every event callback cleared, so there is nothing a late frame could usefully resolve. Drop it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
logos-protocol
The Logos protocol layer: transports, token exchange, and the
language-neutral lp_* C ABI (cpp/logos_protocol.h) that every Logos
SDK builds on.
Extracted from logos-cpp-sdk so that non-C++ SDKs (Rust, …) consume the
same transports, capability/token flow and wire behavior through one stable,
versioned boundary instead of re-wrapping the C++/Qt SDK.
What lives here
- Public C ABI —
cpp/logos_protocol.h: consumer surface (lp_client_*,lp_invoke[_async],lp_subscribe, tokens,lp_get_methods), provider groundwork (lp_provider_*), and the protocol version (LOGOS_PROTOCOL_VERSION_*,lp_protocol_version(),lp_protocol_abi_major()). JSON-in-strings data model; bytes cross the boundary as{"_bytes":"<base64url>"}(lossless, NUL-safe). - Transports — plain TCP / TCP+TLS (Boost.Asio + OpenSSL + nlohmann,
Qt-free),
qt_local, in-memory mock, and Qt Remote Objects (qt_remote— the only Qt-bearing transport). - Consumer core —
LogosAPIClient/LogosAPIConsumerincluding the automaticcapability_module.requestModuletoken-fetch flow (behind the protocol boundary: every language gets it for free). - Provider-side plumbing —
ModuleProxy(auth gate the transports publish) and the abstractLogosProviderObjectinterface (logos_provider_interface.h). - Token manager, transport/registry factories, mode config (remote/local/mock), and the canonical QVariant↔JSON conversion used at the QRO boundary.
logos-cpp-sdk layers the typed C++ developer API (LogosAPI, module
context, code generator, provider base classes) on top of this repo.
Versioning
This repo carries the logos-protocol semver — the single number that governs Logos load/call compatibility. Two participants (modules, hosts, SDKs in any language) interoperate iff they share the same MAJOR. MINOR is additive/back-compatible; PATCH never affects compatibility. SDKs must re-expose the version of the protocol they linked (never mint their own).
Building
# Via workspace
ws build logos-protocol
# Standalone
nix build
# Tests
nix build .#tests
Layering invariant
logos-protocol depends only on Qt / Boost / OpenSSL / nlohmann_json — it
must NEVER depend on logos-cpp-sdk, logos-qt-sdk, logos-rust-sdk, liblogos
or logos-lidl. Everything points inward.