mirror of
https://github.com/logos-co/logos-protocol.git
synced 2026-08-27 20:11:07 +00:00
master
12
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
42460e5b2a |
fix(tokens): separate INBOUND from OUTBOUND, without moving a single byte
A grant one way was a grant both ways. TokenManager was ONE flat map with no
direction tag, written from both sides of every relationship: LogosAPIClient
stored the token it will PRESENT to a callee under the CALLEE's name, and a
token RECEIVED from a caller was stored under the CALLER's name. Same key
namespace, last write wins.
Measured on the shipped fleet with two ordinary modules doing nothing unusual:
one grant A -> B leaves the SAME token value under both opposite-meaning keys,
and the never-granted B -> A call then succeeds. Silently.
A.callOther(B, ping) CALL_OK
T1 A holds token for B? val=7685c776-...
T1 B holds token for A? val=7685c776-... <-- one value, two meanings
B.callOther(A, ping) CALL_OK <-- never granted
WHY THE LAYOUT COULD NOT CHANGE. TokenManager's layout is a cross-package ABI:
the host ALLOCATES the object and module/UI-plugin images MUTATE it through
their own statically-linked accessors — and host and modules ship as separate
.lgx that mix versions at runtime by design. The header's ABI-safety note is
about ALLOCATION ("no consumer allocates one, none needs sizeof"); the hazard
is MUTATION.
Splitting into three members took sizeof 32 -> 64 and moved m_mutex 24 -> 56.
QMutex::fastTryLock() compare-exchanges at this+24, which in that layout is
m_inbound's QHash d-pointer. Empty, the old code silently borrows the hash's
pointer slot as a mutex and puts it back, so it LOOKS fine; non-empty, the
exchange fails and lockInternal() interprets the QHash Data* as a
QMutexPrivate* and futex-waits on it — hung forever, inside a token-store
write, on the module host's Qt main thread. No crash, no log line, no timeout
that recovers. Reproduced by calling the shipped 0.6 plugin's own saveToken on
a 0.7 object: exit=124.
So direction lives in the KEY NAMESPACE instead. Outbound is the bare peer name
(byte-identical to master); inbound is "\x01in\x01" + caller. m_tokens@16,
m_mutex@24, sizeof 32 — measured identical to master in every shipped image,
pinned by a static_assert against a reference struct that fires if a member is
added.
Two things a key namespace forces that separate members did not: every door
REFUSES a key carrying the namespace character, or a wire-supplied caller name
could forge across the direction boundary; and credential() is DERIVED from
bootstrapKeys() rather than cached, because a cached field reads empty on a
store another image wrote and then refuses every push.
AN ANCHOR KEY IS NO LONGER SPELLED AS A MODULE NAME. scanIssuedTokens' m_tokens
loop offered every matched key unconditionally while the m_store loop
deliberately never offers, so "an anchor must never name a caller" was enforced
on one side only. A module announcing itself as "core" — which logos-rust-sdk
did unprompted — therefore authorized as kind:module name:core. The rule
generalises: a store may only name a caller with a key it alone can write.
Implemented as a masked operand, so the comparison count is unchanged;
RefusingToNameAnAnchorKeyCostsNoComparison pins that via
logos::tokenComparisonCount().
lp_token_save / lp_token_save_for now return LP_ERR_INVALID_ARG on a reserved
key instead of LP_OK. Only the return code was wrong; saveToken already refused.
PROTOCOL 0.8: logos_module_accept_inbound_token joins the module-impl C ABI
(12 exports). onInit keeps logos_module_accept_token for the module's own
anchor — that one IS outbound, and merging the two paths is what reintroduces
the bug.
Supersedes the field-split approach; the semantics are unchanged from it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
b37a2e9f1c |
feat(tokens): a private store is created EMPTY, not seeded with the host anchor
TokenManager::forIdentity seeded every new private store by COPYING the
host's tokens for bootstrapKeys() = {core, capability_module}. Those values
are the HOST's, so an isolated in-process consumer presented basecamp's
anchor and ModuleProxy::resolveCaller answered HostAnchor: a sandboxed view
wearing the host's authority.
Half of that was LIVE, not latent. informModuleToken's trusted-channel gate
compares against the SAME two keys, so anything holding an isolated
LogosAPI* could read getToken("capability_module") and call
informModuleToken on capability_module — three public calls, no glue — and
write into the map that is both its known-caller gate and its moduleToken
source. Reading a caller needs generated glue; writing one did not.
The copy could not simply be deleted. Measured: removing it alone turns 5
of 495 protocol tests red, and two are behavioural — an isolated identity
cannot reach capability_module.requestModule (it dies at ModuleProxy's
`authToken.isEmpty()`), and an isolated PROVIDER can never be told about a
caller. Isolation without a credential is a lockout.
The credential already existed and was being thrown away. All five host
registration sites minted a per-spawn UUID, registered it with
capability_module, and then dropped it: the identity was registered under a
token nobody held, and it worked only because the store presented the
copied anchor. The anchor copy was masking that at every site, which is why
neither could be fixed alone.
So: a private store starts empty, and an identity's store carries THAT
IDENTITY's own host-issued credential under the bootstrap keys —
adoptCredentialFor, which refuses the host anchor by construction. This is
not a new rule. ui-host already does exactly it for the out-of-process half
(saveToken(core/capability_module, its own authToken)), and
LogosAPIProvider::seedHandshakeTrustAnchor does it for a module image. The
in-process private store was the only store in the system seeded with
somebody else's credential.
`core` is not part of it for a CONSUMER: every reader of a store's "core"
entry is provider-side, and in a real host instance() has no "core" key at
all — the host ring is written only under module names, and no module is
named core.
Closing the elevation also makes the consumer NAMEABLE in the same change:
it now resolves as {"kind":"module","name":<identity>} at capability_module
and at ordinary modules, instead of {"kind":"host"}.
NOTE FOR CONSUMERS OF THE C ABI: lp_token_reset_identity changed meaning on
an existing exported symbol — it no longer re-seeds, so an out-of-tree
caller that reset and kept going is now locked out. No in-workspace caller
exists; carried by the MINOR bump to 0.7.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
03842db5c1 |
feat(windows): named pipes, an explicit lp_* ABI, and a cross target (#58)
* feat(windows): port logos_socket_paths and add a cross target logos_socket_paths.cpp is the only POSIX-bound file in logos-protocol. All of it is unix-domain-socket machinery, and on Windows the local transport is named pipes (QLocalServer maps a name to \\.\pipe\<name>), where none of the assumptions hold: a pipe has no inode to lstat/chown/chmod -- access comes from a security descriptor set at CreateNamedPipe time -- and a pipe cannot outlive its last handle, so a hard-killed process leaves nothing behind. isSocketDead and reapStaleSockets are therefore not merely unimplemented on Windows, they are vacuous: the state they detect cannot arise. Both return the fail-closed answer (false / 0), matching the documented contract that an endpoint is never reported dead unless certain. applySocketPerms deliberately does NOT no-op. With no policy requested it returns true, as on POSIX. But when LOGOS_SOCKET_GROUP or LOGOS_SOCKET_MODE *are* set it fails with an explanatory error, because silently returning true would leave the endpoint more permissive than the operator asked for -- the one direction this file is careful never to go (cf. the chgrp-then-chmod ordering in the POSIX branch). Granting a pipe to a group needs a DACL plus a group->SID resolver; until that exists, refuse loudly. Also gates qt6.wrapQtAppsNoGuiHook behind !isWindows and sets dontWrapQtApps. Both halves are required: the hook does not even evaluate for a mingw host, it would be inert anyway (wrap-qt-apps-hook.sh skips anything that is not ELF or Mach-O), and qtbase's setup hook hard-errors in qtPreHook unless dontWrapQtApps is set. Header contract updated per function. POSIX branch unchanged and still compiles. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: make the Boost.System component optional, not required find_package(Boost REQUIRED COMPONENTS system) hard-fails on Boost 1.89: Could not find a package configuration file provided by "boost_system" Boost.System has been header-only for years, and 1.89 finally dropped the compiled boost_system library, so no boost_systemConfig.cmake is installed at all. The COMPONENTS request was not gratuitous though -- on 1.87 the Boost::system imported target is only exported when the component is asked for, which is what the previous comment recorded. So ask optionally and fall back to Boost::headers, which supplies the same header-only error_code either way. The choice is by BOOST VERSION, not by platform: this is not a Windows quirk, it simply surfaced first there because the Windows target pins a newer nixpkgs (Boost 1.89) than the native one (Boost 1.87). Verified both ways -- native aarch64-darwin still selects Boost::system: -- Boost.System target: Boost::system (Boost 1.87.0) and the build completes unchanged. Also adds QT_HOST_PATH / QT_ADDITIONAL_HOST_PACKAGES_PREFIX_PATH for the Windows target. Qt6RemoteObjectsDependencies.cmake declares set(__qt_RemoteObjects_tool_deps "Qt6RemoteObjectsTools;6.11.1") and Qt6RemoteObjectsTools holds repc, which must RUN on the build machine -- so under cross it lives in the build-platform Qt, not the mingw one. Without these, find_package reports the thoroughly misleading "Expected Config file at <qtbase>/lib/cmake/Qt6RemoteObjects ... does NOT exist": the TARGET config is found fine; it is the HOST tool package that is missing. Every Qt-consuming repo will need this, so it should be hoisted into logos-nix rather than repeated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor: declare the lp_* C ABI explicitly instead of relying on auto-export Adds LP_API (__declspec(dllexport) when building the shared library, default visibility elsewhere) to the 21 lp_* entry points, and defines LOGOS_PROTOCOL_BUILDING_SHARED for the shared target only, so the static archive leaves LP_API empty and its consumers need no import library. This is NOT a bug fix, contrary to what the concern in the Windows plan suggested. Measured on the cross-built DLL, before and after: before: export table 0x2ece (11982 symbols), lp_* present: 21 after: export table 0x15 ( 21 symbols), lp_* present: 21 GNU ld's PE auto-export was already exporting lp_* -- along with roughly twelve thousand other symbols. The worry was that logos_module_impl.h's __declspec(dllexport) would disable auto-export image-wide and silently drop lp_*; it does not, because no translation unit in logos_protocol includes that header (it is listed in PROTOCOL_SOURCES for IDE visibility only). What this does buy is worth having anyway: the exported surface is now the ABI we actually declare rather than whatever happens to have external linkage, it stops being contingent on auto-export staying enabled -- which the very next TU to gain a dllexport would silently end -- and it drops ~12k incidental symbols from the export table. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: relax the Boost.System requirement in the EXPORTED cmake config too The previous commit fixed cpp/CMakeLists.txt but left logos-protocolConfig.cmake.in still doing find_dependency(Boost REQUIRED COMPONENTS system) so logos-protocol itself built fine on Boost 1.89 while every CONSUMER of its installed CMake package failed at configure time -- caught by logos-qt-sdk, which is the first downstream repo to be cross-built. Worth noting as a general trap: a package can be internally consistent and still ship a broken contract, because the exported config is a separate artifact from the build. Anything changed in one has to be checked in the other. Verified both directions: the Windows cross builds of logos-cpp-sdk and logos-qt-sdk now succeed, and a native aarch64-darwin logos-qt-sdk build -- which consumes this same config against Boost 1.87 -- still succeeds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(windows): mark the types that must exist once per process PE has no symbol interposition. ELF and Mach-O interpose across the whole image set, so when liblogos_core exports TokenManager::instance() every other image binds to that one definition and the function-local `static TokenManager instance;` is genuinely a singleton. On Windows every image that links liblogos_protocol.a / liblogos_qt_sdk.a statically gets its own copy of the code and therefore its own statics -- measured: NINE images in the Basecamp payload each defined TokenManager::instance()::instance. The host saved a capability token into its copy, the UI plugin read its own empty copy, and every cross-module call was refused (29 "ModuleProxy: rejecting unauthorized call"). LOGOS_SHARED_API marks the affected types. It expands to __declspec(dllimport) only for a consumer that opts in with LOGOS_SHARED_USE_DLL, and to nothing everywhere else -- off Windows, and inside logos-protocol/logos-qt-sdk/liblogos_core themselves, so the static archives compile byte-identically to before. The dllimport is the load-bearing half, not the export: it rewrites the reference to go through __imp_, so the plain symbol is never undefined and GNU ld never pulls the archive member that would redefine it. Without it the link still succeeds, binds to the archive, and gives no diagnostic at all. logos_shared_api.h records both wrong answers -- export everything (collides with the static archive over LogosAPI) and export nothing (today's silent per-image statics) -- so neither gets reinvented. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(windows): let checks and devShells take the arg forAllSystems now passes The cross-target commit added `inherit system;` to forAllSystems so the Windows arm could tell which target it was building, but left `checks` and `devShells` on the strict `({ pkgs }: ...)` pattern. A Nix attrset pattern without `...` is exact, so both stopped evaluating: error: function 'anonymous lambda' called with unexpected argument 'system' on EVERY platform, not just Windows -- `nix flake check` and `ws develop logos-protocol` are dead on this branch while they work on master. `packages` was unaffected because it goes through forAllTargets, which is why nothing caught it. Measured, same worktree, before and after: before: checks.aarch64-darwin -> the error above at flake.nix:52 after: checks.aarch64-darwin -> [ "tests" ] devShells.aarch64-darwin.default.name -> "nix-shell" packages -> [ aarch64-darwin aarch64-linux x86_64-darwin x86_64-linux x86_64-windows ] * chore(deps): re-pin logos-nix to the merged Windows overlay The cross overlay landed in logos-nix#2. This branch was locked to a pre-merge rev, which has no `lib.forAllTargets` and no `lib.mkWindowsPkgs`, so it could not evaluate standalone -- only against the unmerged branch. Level 2 of the Windows chain; L1 (logos-nix) is merged. --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
0183e8c26a |
fix: make the caller's timeout bound the token handshake, not just the call (#55)
* test(token): pin that the caller's budget bounds the handshake, not just the call Written first and on its own commit so the red is on the record: against this parent it fails at ~20s, because the capability handshake ignores the caller's budget entirely. LogosAPIClient::invokeRemoteMethod takes a Timeout, but on an un-tokened target the handshake runs FIRST and LogosAPIConsumer::requestModule hardcodes 20000 twice -- once for the capability_module acquire, once for the requestModule call on it. A caller asking for 1500ms could therefore block on the order of 40s before the part it had actually bounded began. logos-view-module-runtime's callModule advertises a 1500ms bound on precisely this path. capability_module is deliberately NOT published, so the acquire runs its budget out rather than succeeding. Every other test in this file publishes it, which is how a hardcoded 20s survived alongside them: none of them ever entered the wait. The assertion is two-sided on purpose. An upper bound alone would pass if something made the acquire return instantly -- leaving the hardcoded 20s in place and the test green for the wrong reason, which is the exact shape of two earlier tests in this change set that passed in both directions. So: >= budget-200ms proves the timeout path actually ran; < 4x budget proves it was the CALLER's budget and not the 20s default. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: make the caller's timeout bound the token handshake, not just the call requestModule gains a timeoutMs parameter (defaulted to today's 20000, so every existing caller is source-compatible and unchanged), and LogosAPIClient threads its caller's Timeout through mintAndCacheToken into it. The budget bounds the WHOLE handshake -- the capability_module acquire plus the requestModule call on it share one deadline, rather than each getting a fresh copy. Halving it would be arbitrary; giving each the full amount would make the worst case twice what the caller asked for. What is left after the acquire is never allowed to reach 0, because some transports read 0 as "no timeout" and an exhausted budget must not silently become an unbounded wait. Also corrects a comment that argued the handshake-refusal fallthrough was safe because "capability_module passes 3000 ms". It does not: capability_module reaches informModuleToken_module through its FOUR-argument overload (capability_module_plugin.cpp:112), so timeoutMs takes the header's 20 s default. The bound is real, it is just not short -- and the code should say the true thing about why it is safe. Not covered here: the ASYNC first-call path still acquires capability_module through invokeRemoteMethodAsync without threading a budget. It does not block the caller, so it is a different defect with a different fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
dcc0f73d1c |
feat: hold a CALL until its module is reachable (whenObjectAvailable) (#53)
* feat: add whenObjectAvailable(), the call-path counterpart of onEventWhenAvailable() Event subscribers got a way to ask "tell me when this module is reachable" without blocking and without giving up on the first no. Callers had no such thing, so a caller facing the same startup race had only two bad options: fail fast, which strands a UI that will never retry, or call straight through and sit in the transport's acquire timeout on whatever thread it was called from — the GUI thread, in practice. whenObjectAvailable() reuses the pending-subscription registry that already exists, as a readiness-only entry: it attaches no subscription, fires its callback exactly once, and is then forgotten rather than being re-armed on reconnect, because a one-shot readiness answer that arrives twice is not an answer. It shares the registry's timer, backoff and diagnostics, so it costs nothing new on qt_remote and shows up in pendingSubscriptions() as "<object>::(readiness)" while it waits. Its first consumer is LogosQmlBridge::callModuleAsync, which can now hold a call issued before its module exists and dispatch it when the module appears. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(events): pin whenObjectAvailable on every transport It shares the pending registry with event subscriptions but answers a different question, and it had coverage only end-to-end through the QML bridge. Three cases x 6 params: fires true for a module that is already up; holds rather than answering "not reachable" for one that is merely not up YET, then fires exactly once when it appears; and is NOT resurrected by a reconnect. That last one is the asymmetry worth pinning. Event subscriptions ARE re-armed across a reconnect on purpose; a readiness answer already delivered is spent, and re-firing it would re-dispatch whatever call it was gating. Suite 356 -> 374. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
07b0fb1c64 |
fix: make event subscriptions survive a module that is not reachable yet (#47)
* fix: make isConnected() mean connected, and stop the log claiming it QRemoteObjectNode::connectToNode() returns false only when the URL SCHEME is unregistered -- it never contacts the peer. Our registry URLs are COMPUTED rather than discovered (logos_instance.h: local:logos_<module>_<instanceId>), so they are identical whether or not the module exists. Latching m_connected from that return therefore made isConnected() answer "yes" for modules that were never loaded, which made every `if (!client->isConnected()) return;` guard in the codebase DEAD CODE. Callers then paid a 20 s waitForSource per call, twice over, because the token handshake tries capability_module first. Measured in Basecamp with package_manager absent: ~417 s of blocked GUI thread on macOS and 361 s on Linux before the window appeared, and over 900 s under load. Not a Windows bug -- the Windows port merely exposed it. isConnected() now also requires a listener at the endpoint. For `local:` that is a direct socket / named-pipe probe, which costs microseconds precisely in the case that used to cost 20 seconds; any other scheme keeps its previous behaviour. Two logging changes, because the diagnostics cost more than the defect: "Successfully connected to registry" asserted a connection that often did not exist and sent three separate investigations to the wrong place -- it now says a connect attempt started and makes no claim about the peer. And requestObject warns BEFORE a doomed wait instead of going silent for 20 s and then reporting failure. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: let event subscriptions survive a module that is not reachable yet requestObject() answers "is the module there RIGHT NOW", and every subscriber in this codebase asks at the one moment the answer is no: a module's init(), a UI backend's onContextReady(), a QML view's Component.onCompleted. All of those run while the dependency's host process has been spawned but has not called listen() yet. The subscriber then gave up permanently -- lp_subscribe returned nullptr with no log at all, and callers turned that into a `false` the documented example discards. Method calls kept working through the same window because acquireCachedObject() reaches the replica by a path that never asks, so the symptom was "events are broken", not "the subscription never happened". |
||
|
|
c1b0a0f554 |
fix(startup): publish a token-only handshake surface before a module initializes (#42)
* fix(startup): publish a token-only handshake surface before a module initializes A module's initializer is synchronous and routinely calls out — a Qt module's initLogos, a cdylib's context-ready hook — including capability_module's requestModule, which capability answers by pushing a token back to that same module. The module's business object is published only once the initializer returns, so that push had nothing to reach: capability waited for a source that could not appear until the initializer returned, and the initializer could not return until capability answered. On Linux this wedged UI startup until the standalone app's 10s ui-host deadline expired and the view never rendered. Adds a second, deliberately tiny surface — ModuleHandshakeProxy, published under logos::handshakeObjectName(name) — carrying informModuleToken and nothing else. It forwards to the ModuleProxy that owns the token store, so a grant delivered early is the one the business object honours later, with the same authorization. The business object's publish timing is UNCHANGED, which is the point: a caller of a real method still blocks at acquire until the module is genuinely ready, exactly as it always has. An earlier attempt published the business object early and refused calls during init; that quietly turned a call that used to wait and succeed into one that returned empty, which old consumers cannot even detect. informModuleToken_module now tries the handshake surface first (short probe) and falls back to the business object, so modules built before this surface existed are reached exactly as they are today. It also reuses the cached handle instead of acquiring a fresh replica per grant, and takes a timeout (default unchanged). No wire change, no ABI change, no reply-shape change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(startup): do not treat a handshake refusal as the final answer The handshake surface is published before the target's initializer runs, so a target whose token store is seeded BY that initializer refuses a push that arrives first. Returning that refusal to the caller handed it an empty grant it could not distinguish from a real denial: measured on Linux, the first requestModule for wallet_backend_module came back empty in 29 of 34 runs, and never once in the pre-surface baseline. Fall through to the business object instead, which is what the caller got before this surface existed. The business object is published only once the initializer has returned, by which point the store is populated. The wait is bounded by the caller's own budget -- capability_module passes 3000 ms, not the 20 s default that made the original deadlock fatal -- so this cannot reintroduce the wedge. The companion change in logos-qt-sdk seeds the trust anchor before publishing, which removes the refusal at its source; this is the safety net for hosts and modules that do not. Also adds the regression test that would have caught this: the existing case seeds "core" before pushing, which is exactly the state that does NOT hold in the window the surface covers, so it asserted the surface works under a precondition production never met. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(startup): marshal the token push, and stop re-probing a missing handshake Two review findings from Copilot, both verified against the code before acting. 1. Thread affinity. informModuleToken_module was one of only two entry points in LogosAPIClient that did not wrap in logos::runOnOwnerThread -- requestObject, both invokeRemoteMethod forms and onEvent all do. The missing marshal is inherited, but THIS change is what made it reachable: the method used to take an uncached requestObject() + release() and touch no shared state, and routing it through acquireCachedObject put it on m_objectCache, which is declared single-threaded and holds thread-affine QtRO handles. Now marshalled, matching its four siblings. The 3-arg informModuleToken has the same gap but still uses an uncached handle and predates this work, so it is deliberately left alone rather than widened into this fix; noted at the call site. 2. No negative cache on the handshake probe. acquireCachedObject caches successes only, so a module built before the handshake surface existed failed the probe on EVERY grant -- and on QtRO that failure is a blocking waitForSource, i.e. 250 ms of dead time per token, forever. Remember the absence and go straight to the business object; cleared by clearObjectCache() so a reconnect, or a module reloaded from a build that has the surface, is re-probed rather than written off permanently. (The review attributed this cost to the Local/Plain adapters rejecting a non-ModuleProxy object. Checked per transport: plain is unaffected -- its token push is nameless fire-and-forget and it never had the acquire deadlock -- and on qt_local requestObject ignores timeoutMs entirely, so the cost there is a spurious warning, not 250 ms. The real cost is the missing negative cache, on QtRO.) The same review's ABI-break and name-collision findings were measured and do not apply: logos_protocol is a static archive with zero undefined imports of these symbols anywhere in the built stack, and object names are scoped to a per-module socket rather than a global registry. Both answered in-thread. 290/290 protocol tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(startup): exercise the handshake surface over a real transport The existing handshake cases call ModuleHandshakeProxy directly, with no transport underneath. That is what let a whole class of defect through: the surface is only useful if a transport will PUBLISH a token-only QObject and a consumer can ACQUIRE it by the derived name, and a direct-call test can see neither half. The adapter survey prompted by review found qt_local silently rejects a non-ModuleProxy on acquire while still reporting a successful publish -- invisible to every test in the suite. These run on the transport the production stack actually uses (QtRO, the LogosTransportConfig default), and model the startup window honestly: the handshake object is published and the business object deliberately is NOT, because it does not exist until the initializer returns. That window is the entire reason the surface exists and is the one state the direct-call tests could never represent. TokenReachesAModuleWhoseBusinessObjectIsNotPublishedYet the pre-init window end to end: publish -> probe by derived name -> acquire -> push lands on the provider. AnUnseededAnchorRefusesEvenThoughTheSurfaceIsReachable the transport-level twin of the gate test: proves the refusal measured in production (29 of 34 app runs) is the gate rejecting the push, not the transport failing to deliver it -- the provider is never reached. ALegacyModuleFallsBackAndIsNotReProbed a module with no handshake surface still gets its token, and the missing surface is probed ONCE. Timed rather than functional, so it was falsified before being trusted: with the negative cache removed the suite fails on exactly this case and no other. 293/293 protocol tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
ef24bd70d9 |
fix(protocol): re-exchange token on provider rejection (#26)
When a provider rejects a call for a stale/unrecognized token it now returns a structured "unauthorized" sentinel (logos_rpc_status.h) instead of a bare QVariant(). LogosAPIClient detects it below the typed wrapper, drops the cached token, re-runs capability_module.requestModule and retries the call once — closing the gap where a stale token was reused forever (the consumer-latched-dead failure mode) and lazily recovering the common provider-reload case. The return VALUE is the only provider->consumer channel available on every transport (qt_local/qt_remote/plain) without an ABI break, since the QtRO dispatch slot returns a single QVariant — hence a value sentinel. Backward compatible: - OLD consumers convert the sentinel identically to QVariant() for every scalar/string/LogosResult return, so they keep seeing today's empty/failed result. - OLD providers return bare QVariant(); a NEW consumer never matches the sentinel and so never re-exchanges against them. The retry is bounded to one attempt and fires ONLY on the explicit sentinel (never a legitimately-empty result), so no loops and no misfire. Downstream note: logos-qt-sdk's test_auth_token_enforcement.cpp asserts !isValid() on unauthorized calls; those become isUnauthorizedSentinel() when it re-pins (the security property — no provider dispatch — is unchanged). Tests: tests/protocol/test_token_reexchange.cpp covers provider-side emission, sync/async re-exchange+retry, bounded retry (no loop), the false-positive guard (a legit empty return must not re-exchange), and old-consumer decode. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
ee1c5192da | feat: plumb CallError through invokeRemoteMethodAsync | ||
|
|
4ea32a314a |
Per-module concurrent dispatch: async provider seam + transports (#5)
* feat: per-module concurrent dispatch (concurrency:"multi") — zero ABI change
A "multi" module serves calls concurrently behind the ORDINARY callMethod — no
new provider/host vtable method, so LogosProviderObject's ABI is byte-identical
to before and an old host/daemon loads and forwards a multi module unmodified.
Mechanism: a multi module's generated glue returns a pending sentinel
({"__logos_pending_call__": callId}) from callMethod and pushes the real result
back later as a __logos_call_complete__ event keyed by callId, over the existing
event channel. The consumer transport detects the sentinel and awaits the
completion transparently, so generated clients are unchanged.
- logos_async_dispatch.h: shared wire constants + the contract.
- remote_transport.cpp (QtRO) / plain_logos_object.{h,cpp} (plain): consumer
sentinel detection + await keyed by callId. The host is a pure forwarder.
- logos_protocol.h + nix/default.nix: protocol 0.2.0 (additive minor; same MAJOR
stays compatible, so an old host accepts a 0.2 "multi" module).
- rpc_server.cpp: fix a teardown self-deadlock (stop() held m_mu while invoking a
per-connection error handler that re-locks m_mu) that the new in-process
subscription path exposed.
- tests/protocol/test_concurrent_dispatch.cpp: proves a multi provider overlaps
two concurrent calls (peak 2) while single serializes (peak 1), over the plain
transport, with the host unchanged from master.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix: coalesce concurrent async requestModule handshakes (+ async fan-out test)
A driver that fans out N async calls to an un-tokened target before any
completes used to fire N separate requestModule handshakes. Each mints a
distinct capability token and informs the target, and the later inform
OVERWRITES the earlier token there (the target stores one token per caller),
so the already-dispatched calls carried a superseded token and the target
rejected them as unauthorized ("auth token not recognized"). The sync path
never hit this — it blocks per call, so handshakes never overlap.
Coalesce in LogosAPIClient::invokeRemoteMethodAsync: the first async call to
an un-tokened target starts ONE handshake; concurrent calls to the same
target queue behind it and all drain with the single minted token when it
resolves. m_pendingHandshakes is touched only on the owner thread, so no lock
(appended last per the class's ABI note). This is what lets a concurrency:
"multi" worker actually run a single-threaded driver's fan-out concurrently —
otherwise the fanned-out calls are rejected before reaching dispatch.
Also add MultiProviderOverlapsAsync / SingleProviderSerializesAsync to the
concurrent-dispatch gtest: they fire N concurrent callMethodAsync() calls (the
fan-out pattern over the async consumer path, which the sync tests don't
exercise) and assert peak overlap 4 for "multi", 1 for "single".
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
9de4165ab6 |
Qt split + module authoring groundwork: LogosProviderPlugin + the common module-impl C ABI (#3)
* Extract the Logos protocol layer from logos-cpp-sdk
Transports (plain TCP/TLS, qt_local, qt_remote/QRO, mock), token manager,
consumer core (LogosAPIClient/LogosAPIConsumer incl. the capability
auto-requestModule flow), ModuleProxy, the abstract LogosProviderObject
interface, and the canonical QVariant<->JSON conversion — now behind the
language-neutral lp_* C ABI (logos_protocol.h) carrying the protocol
semver (LOGOS_PROTOCOL_VERSION_*, lp_protocol_version()).
Bytes crossing the ABI use the lossless {"_bytes": base64url} tagging
(NUL-safe), matching the plain wire encoding.
Provider lp_* surface is compiled groundwork; serving lands with module
authoring.
* Move LogosProviderPlugin into logos_provider_interface.h
Plugin-loading tools (logos-cpp-generator's introspection mode, lm, the
hosts) need only qobject_cast<LogosProviderPlugin*>() + the abstract
LogosProviderObject — both framework-internal. Hosting the detection
interface here keeps those tools off the developer-facing logos-qt-sdk
layer. Same iid (org.logos.LogosProviderPlugin); header-only, ABI-neutral.
* Define the common module-impl C ABI (logos_module_impl.h)
ONE cdylib contract for module implementations in every language:
dispatch / get_methods / set_context / set_emit_callback / accept_token
/ get_protocol_version / string_free. The C++ and Rust SDKs emit these
exports around their respective impls; the uniform generated Qt glue
(and later a no-Qt host) talks to the cdylib only through this ABI.
JSON data model and tagged bytes form match the lp_* consumer ABI; the
protocol-version handshake complements the build-time metadata stamp.
* json convert: integers stay integers across the C ABI
QJsonValue::fromVariant degrades every numeric to double, so Int/UInt/
LongLong/ULongLong QVariants serialized as 5.0 — and a strict consumer on
the other side of the C ABI (a generated dispatch reading an int param)
rejects or zeroes them. Surfaced by the first cdylib-authored module
whose inbound args cross qvariantToNlohmann; the dlopen smoke harness
fed hand-written int JSON and never exercised this edge.
* call-error channel: surface {code,message,origin} for unacquirable targets
invokeRemoteMethod could not distinguish a failed call from a void/null
result — lp_invoke returned LP_OK with a null JSON result even when the
target module was never reached, and generated typed wrappers silently
defaulted (0 / empty string). Additive err-out overloads on
LogosAPIConsumer/LogosAPIClient fill a std-only logos::CallError
(logos_call_error.h, new LogosCallError exception for the generated
wrappers to throw); lp_invoke now honors its documented contract for
this class of failure: LP_ERR_UNAVAILABLE + canonical error JSON.
First detectable code: object_unavailable (requestObject failure) —
the struct is the extension point for transport-level statuses.
* call-error: drop the exception type — the error channel is the out-param
Per review, generated wrappers expose CallError as an optional trailing
out-parameter instead of throwing; the struct is the whole contract.
* ci: build + run the protocol test suite
On every pull request (unfiltered — stacked PRs included), master pushes,
and manual dispatch. The repo shipped without CI; its 111-test suite only
ran locally and through the workspace gate.
* consumer: typed requestModule for the capability flow
Port of logos-cpp-sdk master f5a127dd ('use updated capability module',
cpp-sdk#85, Iuri Matias) — the touched files (logos_api_client.cpp,
logos_api_consumer.{h,cpp}) moved into this repo in the P1 extraction.
The capability auto-requestModule path now calls a typed std::string
helper on the consumer (which acquires the capability object directly)
instead of a stringly invokeRemoteMethod round-trip. 111/111 tests.
* ci: DeterminateSystems nix installer (macOS runners)
cachix/install-nix-action fails on the macOS runners with
eDSRecordAlreadyExists (pre-existing nix build users); the org's
macOS-bearing workflows use the DeterminateSystems installer.
|
||
|
|
29afbac532 |
Extract the Logos protocol layer from logos-cpp-sdk (lp_* C ABI + protocol semver) (#2)
* Extract the Logos protocol layer from logos-cpp-sdk
Transports (plain TCP/TLS, qt_local, qt_remote/QRO, mock), token manager,
consumer core (LogosAPIClient/LogosAPIConsumer incl. the capability
auto-requestModule flow), ModuleProxy, the abstract LogosProviderObject
interface, and the canonical QVariant<->JSON conversion — now behind the
language-neutral lp_* C ABI (logos_protocol.h) carrying the protocol
semver (LOGOS_PROTOCOL_VERSION_*, lp_protocol_version()).
Bytes crossing the ABI use the lossless {"_bytes": base64url} tagging
(NUL-safe), matching the plain wire encoding.
Provider lp_* surface is compiled groundwork; serving lands with module
authoring.
* consumer: typed requestModule for the capability flow
Port of logos-cpp-sdk master f5a127dd ('use updated capability module',
cpp-sdk#85, Iuri Matias) — the touched files (logos_api_client.cpp,
logos_api_consumer.{h,cpp}) moved into this repo in the P1 extraction.
The capability auto-requestModule path now calls a typed std::string
helper on the consumer (which acquires the capability object directly)
instead of a stringly invokeRemoteMethod round-trip. 111/111 tests.
|