Commit Graph
7 Commits
Author SHA1 Message Date
Dario LipicarandClaude Opus 5 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>
2026-08-07 23:58:41 -03:00
Dario LipicarandClaude Opus 4.8 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>
2026-07-21 18:18:43 -03:00
Khushboo Mehta ee1c5192da feat: plumb CallError through invokeRemoteMethodAsync 2026-07-15 21:45:48 +02:00
Khushboo Mehta 84be236552 fix(logos_api_client): cache minted capability token on the client side
Without a client-side cache, every sync invokeRemoteMethod re-mints a
fresh capability token. On Linux, QtRO's waitForFinished() spins a nested
QEventLoop that dispatches queued slots mid-wait, so back-to-back calls
reenter the function and each fires its own requestModule. The target
stores ONE token per caller (TokenManager::saveToken replaces) — last
inform wins and earlier in-flight calls arrive with a superseded token,
rejected by ModuleProxy::isAuthorized as "auth token not recognized".

Fix: save the minted token into the client's TokenManager after a
successful requestModule on both the sync path and the async drain
callback. Subsequent calls short-circuit the handshake — one mint per
(client, target), no rotation.
2026-06-26 17:21:47 +02:00
Dario LipicarandClaude Opus 4.8 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>
2026-06-19 15:54:42 -03:00
Dario Lipicar 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.
2026-06-12 19:39:57 -03:00
Dario Lipicar 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.
2026-06-12 18:59:01 -03:00