9 Commits
Author SHA1 Message Date
Dario Gabriel LipicarandClaude Opus 5 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>
2026-08-24 11:16:33 -03:00
Dario Gabriel LipicarandClaude Opus 5 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>
2026-08-24 10:17:00 -03:00
Dario LipicarandClaude Opus 5 988e0ba906 feat: per-client token store, the host-services C ABI, and a container shape-check (#59)
* feat: the host-services C ABI a trust-root module needs

capability_module is the last legacy Qt Q_INVOKABLE provider, and it cannot
become an ordinary `interface: universal` module while the two things it does
have no C entry point: reading the token store, and pushing a token to an
ARBITRARY target. This adds both, plus the grant that gates them. Purely
additive — no existing symbol changes behaviour.

  lp_token_keys()               the module names THIS image's TokenManager
                                holds. NULL means REFUSED, never "empty" — a
                                granted call with no tokens answers "[]", and a
                                known-caller gate needs to tell those apart.
  lp_inform_module_token_to()   routes to LogosAPIClient::informModuleToken_module,
                                the 5-arg form. Note the existing
                                lp_inform_module_token is the WRONG DIRECTION
                                for this: it reaches a consumer path that
                                hardcodes requestObject("capability_module"),
                                i.e. core -> capability, not capability ->
                                target. That 5-arg method had no C entry point.
  lp_grant_host_services()      sets the in-image grant over the closed set
                                {token_registry, token_delivery}. Replaces
                                rather than merges; NULL/""/"[]" clears. An
                                unknown name is rejected wholesale and leaves
                                the existing grant untouched, so a typo can
                                never silently drop a service.

Why the gate is per-IMAGE, which looks like an odd choice until it doesn't:
the host binary and a module's cdylib each link their own copy of this library,
so they have separate process-global state. A gate "simplified" into the host
would be checked against state the calling image can never set, and would read
as ungranted forever. The grant therefore crosses the module-impl C ABI the
same way the auth token already does — hence the logos_module_grant_host_services
declaration added to logos_module_impl.h, whose generated body and host-side
call land in logos-cpp-sdk and logos-module-loader-qt respectively.

MINOR 2 -> 3; MAJOR unchanged, so the equal-MAJOR compatibility rule is
unaffected. 387/387 tests pass, including 6 new ones covering both gates
closed, both opened, clearing re-closing them, and the unknown-name rejection.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(tokens): a per-CLIENT token store, selected by origin

TokenManager::instance() is the IMAGE's store, and in a host that loads
plugins in-process it is also an ambient ring: the host writes
`name -> that module's root auth token` for EVERY module it loads. On the
hot path a client asserts no identity at all — invokeRemoteMethod reads
the store first and only mints on a miss — so a plugin asking for target X
finds X's own root token sitting there and presents it. The provider
accepts any token in its image's store, so the call authorizes and no
requestModule is ever logged. Every plugin in that image holds every other
module's authority, and giving a plugin its own ORIGIN STRING changes none
of it, because origin was never consulted on the path taken.

This makes origin SELECT THE STORE rather than merely label the caller.

  TokenManager::forIdentity(x)      the store to present tokens from when I am x
  TokenManager::isolateIdentity(x)  give x a private store (host opt-in)
  isIsolated / isolatedIdentities / bootstrapKeys / seedBootstrapTokens /
  resetIdentity

ADDITIVE BY CONSTRUCTION, not by promise: forIdentity() returns the SAME
OBJECT instance() returns — pointer-identical — for every name until
someone isolates that exact name, so a host that knows nothing about this
is byte-for-byte unchanged. All seven are static member FUNCTIONS: no data
member, no virtual, nothing moc sees. Measured, not asserted: the exported
symbol table of liblogos_protocol.dylib gains exactly 12 names (7 statics +
5 lp_*) and LOSES NONE (736 -> 748). Neither ABI-sensitive private layout
(LogosAPIClient, LogosAPIConsumer) was touched at all.

Construction paths in this repo:
  * LogosAPIClient / LogosAPIConsumer: an explicit store still wins; a NULL
    store now resolves to forIdentity(origin) instead of being a guaranteed
    crash on the first getToken().
  * lp_client_create: &TokenManager::forIdentity(origin), not instance().
    This is the whole answer to that function's frozen signature — the store
    cannot be handed to it, so the origin it already takes must select it.

Bootstrap (constraint 4) survives because a private store is created seeded
with "core" and "capability_module" copied from instance(), and with
NOTHING else — the two keys the first requestModule authenticates with, not
a copy of the ring. resetIdentity() is the unload hook: it clears the
contents and re-seeds, while the store OBJECT stays immortal because a
client holds it by raw pointer from continuations that outlive their caller.

The trust root (constraint 3) is unaffected, and it is checked rather than
argued: lp_token_keys() still reads instance(), isolation only ADDS stores,
and the one thing that moves — an isolated identity's consumer-side CACHE
write — is keyed by TARGET while the known-caller gate consults ORIGIN
names, which the HOST writes and this change never touches.

C ABI grows five additive symbols, each carrying LP_API:
lp_token_isolate_identity, lp_token_identity_is_isolated, lp_token_get_for,
lp_token_save_for, lp_token_reset_identity. Protocol version 0.3.0 -> 0.4.0
(MINOR: additive).

Tests: 439/439 before, 469/469 after. The 30 new cases were validated as
DETECTORS the way this suite requires — against a throwaway build with
forIdentity()'s isolation branch neutered to `if (true)`, i.e. origin as a
label again. 15 go RED there (the walled identity holds the target's root
token; the handshake count is 0 instead of 2; lp_token_keys() lists the
identity's private mint), and the other 15 are pins of behaviour that must
be identical either way. Every escalation case carries an ambient CONTROL
asserting the token IS reachable without isolation.

Hosts are deliberately NOT changed here.

* feat(codec): shape-check the untyped containers

`[any]` and `{tstr:any}` both spell `nlohmann::json` in C++ — LogosList and
LogosMap are aliases of it — so no Codec<T> specialization can tell them apart
and fromJson<T> has nothing to dispatch on. Their SHAPE is still declared,
though, and array-ness / object-ness is the whole of the declared type at that
layer.

jsonRequireArray / jsonRequireObject check exactly that and hand the value on
UNCHANGED, throwing through the codec's own detail::typeError so the message is
the one every other surface already produces ("expected array at arg0, got
string"). The value is not rebuilt from JSON: that would retype nested elements
for no validation gain, which is the same reasoning logos_qt_arg_decode.h gives
for the Qt surface.

This is what logos_codec.h:36 already promised and these two types quietly did
not honour — "shape mismatches throw CodecError … rather than silently
substituting a default, silent defaults are how a mangled value reaches business
logic."

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 22:58:56 -03:00
Dario LipicarandClaude Opus 5 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".

1238316 (isConnected() means connected) is what made this deterministic
rather than lucky, and it must not be reverted -- it removed ~417 s
(macOS) / 361 s (Linux) of blocked GUI thread at Basecamp startup. So
the subscription becomes deferrable instead.

  - LogosTransportAsyncAcquire: a sibling interface (dynamic_cast, like
    LogosObjectErrorChannel) so LogosTransportConnection's installed
    vtable is unchanged. requestObjectWhenAvailable() registers interest
    and returns; it never blocks and never spins a nested event loop.
  - qt_remote implements it by acquiring a dynamic replica before the
    peer exists -- legal, free, and armed by the node's existing 250 ms
    reconnect loop, so it adds no polling. Delivery is deferred one
    event-loop turn because stateChanged fires from inside onClientRead
    (the refresh_balances re-entrancy SIGSEGV).
  - LogosAPIConsumer::onEventWhenAvailable() holds the pending
    subscriptions, arms them when the object appears, shares ONE handle
    per object (separate from the call cache, so a call re-acquiring a
    stale handle cannot silently kill a live subscription), and re-arms
    them after reconnect(). Unbounded in time on purpose -- a module can
    be installed mid-session -- but bounded in noise: one warning at 3 s,
    one at 60 s, a log line when it arms, and a loud abandon when the
    transport proves it impossible.
  - lp_subscribe routes through it, which fixes the same defect for
    every C++/Nim/Rust module and UI backend without touching qt-sdk or
    any generated code.

tests/protocol/test_deferred_subscription.cpp pins all three layers,
each with a published-first control so a red case cannot be a mis-wired
fixture.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: close the remaining silent-failure holes in deferred event subscriptions

The deferred-subscription registry from the previous commit fixed the reported
defect, but review found six ways it could still lose a subscription without
saying so — five in the registry itself, one in the plain transport's host — and
every one of them lived in a cell with no test. All of its tests ran in Remote
mode; three of the four transports had none at all.

Registry (cpp/logos_api_consumer.cpp):

* An already-present module was deferred to the first 250 ms tick on every
  transport without a deferred acquire, and every event emitted in that window
  was dropped. lp_subscribe used to attach synchronously and deliver them, so
  this relocated the silent event loss rather than removing it. startAcquire()
  now reports which of three answers the transport gave, and only an
  Unsupported answer takes the one synchronous requestObject() — which is also
  what keeps that call structurally away from qt_remote, whose requestObject()
  enters waitForSource()'s nested event loop even at timeout 0. Previously that
  invariant lived in a comment, and tick() could reach it whenever
  acquireDynamic() returned null.

* reconnected() put every armed subscription back in the pending set but never
  restarted the timer, which takeMatching() had stopped when they armed. Since
  tick() is the sole driver of both the retry and the watchdog, a reconnect left
  the subscription dead AND silent — quieter than the "not connected" warning it
  replaced.

* armAgainst() released a stale handle while entries were still attached to its
  event helper. Those entries stayed in m_armed, never fired again, and reported
  as healthy. They are now revived and re-armed against the new handle.

* The retry timer ran forever at the 5 s cap with nothing to do. It now stops
  once every pending entry has an acquire in flight and has said everything it
  will say, and restarts when that changes.

* A cancelled subscription had no way to leave the registry, so lp_unsubscribe
  left it holding the timer up and warning about a subscription nobody wanted.
  onEventWhenAvailable() now returns an id; cancelEventSubscription() and
  eventSubscriptionState() are its counterparts, and lp_unsubscribe uses them.

Plain transport (cpp/implementations/plain/plain_transport_host.cpp):

* onSubscribe() dropped a Subscribe for an object that was not published YET —
  which is exactly when consumers subscribe — and the consumer could not know,
  because requestObject() had already succeeded. Publishing also overwrote the
  sink table wholesale, so a republish took every subscriber down with it. The
  sinks now live in a table keyed independently of publication.

Also adds lp_pending_subscriptions() to the C ABI. The Qt consumer has had this
visibility all along and the C ABI had none, which is why a subscription that
silently never armed was undetectable from Rust, Nim or a universal C++ module.

tests/protocol/test_event_delivery_matrix.cpp pins the product rather than a
sample of it: 3 transports x 2 provider kinds (Qt-native and universal/std, which
reach the wire by different conversions) x 2 consumer paths (onEventWhenAvailable
and lp_subscribe) x 6 timings, plus mock and the non-blocking guard. Every
delivery case has a control that is green independently of these fixes.

One thing that is NOT fixed and is now stated in the contract: arming is not
retroactive and no transport buffers, so a module that emits a one-shot "ready"
event synchronously inside its own init() can still be missed. That window is
inherent to the transport — the blocking requestObject() this replaced had it
too — but "subscriptions survive a late module" is not "no event can be missed".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: name the QtRO invariant the stale-handle revive rests on

* test(events): state what the non-blocking guard can and cannot catch

The acquireCount assertion catches a retry that polls qt_remote's blocking
requestObject() in the ordinary case. It cannot reach the narrow one -- the
poll is only reachable when the transport declines a deferred acquire while
still reporting connected, which needs acquireDynamic() to return null and is
not forcible from outside. That case is held shut by control flow instead, and
saying so is better than leaving a reader to assume the test covers it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: make the async-acquire contract and lp_subscribe's return honest

Both from review on #47, both real.

The LogosTransportAsyncAcquire contract promised that a true return means
onReady "WILL be invoked exactly once". It will not: RemoteTransportConnection
parents every in-flight PendingAcquire to m_pendingAcquires, which is reset at
the top of the destructor and rebuilt on reconnect, so an accepted request is
cancelled silently with no callback whenever the connection it belongs to goes
away. The contract now says AT MOST once, names both cancellation triggers, and
states what a caller has to do about them — re-issue after a reconnect, or carry
its own deadline. It also records that the layer above already does the first,
which is why a subscription made through onEventWhenAvailable() survives
something the raw transport call does not. That asymmetry is the reason to
prefer the consumer API, and it was previously implicit.

lp_subscribe returned a non-null lp_subscription even when onEventWhenAvailable
refused and returned 0, leaving the caller with a handle that can never fire
while the ABI documents NULL as the one signal that the arguments were refused.
It now checks sub->id and returns nullptr.

That second one is defensive rather than a live bug, and the code says so: the
guard at the top of lp_subscribe already rejects an empty event name and a null
callback, and lp_client_create rejects an empty target, so the three inputs that
make onEventWhenAvailable() return 0 cannot all arrive there today. No test
drives it. The two contracts simply have to agree, and one of them changing is
how they would stop agreeing.

374/374 green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: stop lp_unsubscribe deadlocking, without dereferencing a freed client

lp_unsubscribe took ownerGuard->mutex and, while holding it, called
cancelEventSubscription(), which marshals to the owner thread with a BLOCKING
queued connection. The delivery callback lp_subscribe installs runs ON that
thread and takes subGuard->mutex then clientGuard->mutex — and clientGuard IS
ownerGuard, both assigned from client->guard. Lock-order inversion. It also hung
outright once the owner's event loop had stopped, which is exactly when a
language binding drops its subscription handle.

The first attempt at this dropped the guard entirely and checked `alive` inside
the posted lambda. That was a use-after-free: QMetaObject::invokeMethod
dereferences the target (it reads object->thread()) before the lambda can run,
and lp_client_destroy sets alive=false and deletes the client synchronously —
so the check was unreachable on the exact ordering lp_subscription's own comment
documents as supported. Proven rather than argued: with MallocScribble=1, a test
that destroys the client before unsubscribing segfaulted 6/6 with the guard
removed and passed 6/6 with it restored.

So the guard is held across the POST and not across the cancel. Both halves are
load-bearing, and the distinction is the whole fix: posting never waits on the
owner thread, so holding the mutex across it cannot invert; only the blocking
marshal ever had to move.

Consequence, now stated in the ABI header: un-registration is EVENTUAL. The
callback-will-not-fire guarantee stays synchronous and unconditional, but
lp_pending_subscriptions() may still list a just-cancelled subscription until the
owner thread runs, and if the client is destroyed first the cancellation never
runs at all — correct, since the registry died with it. The matrix test now
pumps for the drain instead of asserting it happened synchronously.

374/374 green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: arm a subscription immediately when the module is already reachable

Deferral introduced a narrower version of the loss it removed. The common
consumer shape is a call followed by a subscription in the same function --
wallet-ui's backend calls get_chains() and subscribes on the next line, the
tutorial's C++ UI backend does the same. Before deferral the generated Qt
wrapper acquired synchronously, so the subscription was live before on()
returned and an event emitted straight after was delivered. Holding it until the
next event-loop turn silently drops that event.

Measured on the generated-wrapper harness: 1/1 delivered pre-migration, 0/1
after, over 3 runs.

LogosTransportAsyncAcquire gains tryAcquireNow(): hand back a handle ONLY if
that costs nothing -- for qt_remote, a replica that is already Valid, which is
exactly the state a prior call leaves behind since QtRO shares one replica
implementation per object name on a node. It must never block, never spin a
nested event loop and never wait on a peer; "not immediately available" is an
answer and the caller falls back to the deferred path. Default returns nullptr,
so a transport that cannot answer cheaply simply does not.

Delivering inline here is safe for the reason the never-synchronous rule exists:
that rule protects against re-entering the transport's READ stack from a
stateChanged callback. tryAcquireNow runs on the subscriber's own stack.

The new matrix case fires ONCE, synchronously, with no pumping in between --
re-firing would hide the exact gap under test -- and states the transport
difference rather than papering over it. Subscription registration is local on
qt_remote (attach to a held replica) and qt_local (connect an in-process
signal), so delivery there must be instant. On plain it is a wire frame to the
host, so instant delivery was never on offer and never was before this change
either; that leg asserts it still arms and delivers.

Also de-flaked EventDeliveryNonBlocking: its heartbeat COUNT over a fixed
wall-clock window measures the machine, not the code. The gap assertion is the
one that means something; the count is now only a floor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: stop tryAcquireNow leaving a dangling facade in QtRO's connect list

e9f82ac introduced a use-after-free. tryAcquireNow() acquired a dynamic replica
and, when it was not already Valid, deleted it. That is not safe: QtRO shares one
replica IMPLEMENTATION per object name per node, and while that implementation is
still waiting for the source's metaobject it records every facade built on it as a
RAW pointer in QConnectedReplicaImplementation::m_parentsNeedingConnect.
~QRemoteObjectReplica is an empty body, so destroying a facade never deregisters
it, and the implementation dereferences the whole list when the class definition
arrives.

So each probe of an unreachable module left one dangling pointer behind.

WHY IT HID. The first probe owns the only implementation and takes it down with
itself, so a single subscription is harmless. It needs a second subscription whose
implementation is pinned by an in-flight PendingAcquire before a freed facade can
outlive its implementation. A consumer subscribing once sees nothing; the QML
plugin shape -- a view registering every event it cares about up front -- dies.

REPRODUCED, 4 runs of 4, serially as well as in parallel, in
logos-view-module-runtime's existing suite (unchanged from master, and green there
against this same protocol checkout):

  LogosQmlBridge: subscription accepted for "echo_module" :: "ev13"
  Received signal 10 (SIGBUS), code 1, for address 0x5a

SIGBUS code 1 is BUS_ADRALN -- a misaligned atomic access on a garbage base read
out of a recycled heap block, in the event loop rather than at the call site,
which is why it reads as a mystery crash rather than as a subscription bug.

PROVEN, before writing this fix, by commenting out that single `delete replica`:
the same suite went 4 failures -> 6/6 with no other change. With this fix: 6/6.

THE FIX IS TO PARK, NOT TO FREE. One probe per object name, parented to
m_pendingAcquires -- which both the destructor and reconnect() already destroy
BEFORE the node, so the implementations die in the same breath and freeing them
there is safe. Ownership transfers out only when the replica reaches Valid, by
which point the implementation is configured and is no longer holding the facade.
It costs one idle replica per name until it goes Valid or the connection dies.

AND REMOVE THE MULTIPLIER: beginAcquire() probed on EVERY add(), ahead of
startAcquire() and therefore ahead of the m_acquiring one-acquire-per-object
guard. tick() already applies that filter; beginAcquire() was the one caller that
did not, which is what turned one probe per module into one per subscription.
While an acquire is in flight its PendingAcquire already holds a replica and will
arm every waiting entry at once, so the probe buys nothing there.

Not QML-specific: lp_subscribe reaches the same entry point.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 11:42:50 -03:00
Dario LipicarandClaude Opus 5 d0523c1486 fix(lp): lp_invoke_async can finally report a failure (#40)
lp_result_cb has always been documented as carrying an outcome —
"ok != 0 -> `json` is the result JSON value; ok == 0 -> `json` is the
canonical error object" — and the synchronous twin lp_invoke has always
honoured it (LP_ERR_UNAVAILABLE + out_error_json). lp_invoke_async did
not: it subscribed with the VALUE-ONLY invokeRemoteMethodAsync overload
and called back `cb(1, json, user_data)` with ok hard-coded to 1, so a
call to a module that cannot be acquired reached the callback as a
SUCCESS carrying a default-constructed value.

The fix is to pass a TWO-argument lambda, which is invocable only as
LogosAPIClient::AsyncResultErrorCallback and so binds to the
CallError-aware overload that already exists next to the value-only one.
The failure is then rendered with the same makeErrorJson the sync path
uses, so both entry points report the same event in the same shape.

The ABI is unchanged. lp_result_cb's (ok, json, user_data) signature
already reserves ok == 0 for exactly this; nothing had to grow a new
entry point, and every in-tree consumer already branches on `ok`
(logos-rust-sdk's async_call_trampoline even parses `message` out of the
canonical error object — code written against a contract the
implementation never kept).

Regression test: a matched pair over a REAL transport (plain TCP), not
the mock.

  FAILING async call    -> ok=0 {"code":"object_unavailable", ...}
  SUCCEEDING async call -> ok=1 7

The first fails on the unfixed tree (ok=1, json "null"); the second
passes on both, so an over-eager "report failure everywhere" fix cannot
sneak through.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 17:51:44 -03:00
Dario LipicarandClaude Opus 5 ae2f7e1b58 fix(lp): create Qt-affine clients on the Qt main thread (#28)
lp_client_create() made the CALLING thread the client's owner thread. Callers
reach it through a lazily-created wrapper (the generated bind_<iface>() ->
LpClient::ensure()), so the first thread to make an outbound call captured the
whole transport for the life of the process.

For the qt_remote transport that thread also ends up owning the
QRemoteObjectNode and its QLocalSocket, which are only serviced by a thread
running a Qt event loop. A module whose first call came from a worker — an HTTP
handler, a timer thread — bound its transport to a thread that only pumps
events while it is already blocked inside a call. Replica acquisition then
never completed: every requestObject() burned its full 20s timeout and returned
nullptr, and since a failed acquire yields an empty result the data loss was
silent. openmetrics-module hit exactly this: one GET /metrics took 40s (2 x 20s)
and came back missing a module, /health went unanswered behind the wedged
libmicrohttpd thread, and the follow-up stop RPC failed.

Construct the client on the Qt main thread when the transport needs a Qt event
loop, so the per-call marshal that already exists (logos::runOnOwnerThread)
lands somewhere that can actually service it. This is the anchor the Qt path
always had — LogosAPI::getClient marshals construction to the LogosAPI's thread
— given to the lp path.

Plain (tcp/tcp_ssl) and mock transports are Qt-free and thread-agnostic, so
they keep the calling thread: a worker-thread consumer stays off the main
thread's back. LogosTransportFactory::needsQtEventLoop() carries that rule next
to the createConnection resolution it mirrors. When there is nothing to anchor
to (a Qt-affine transport with no QCoreApplication) we now warn instead of
letting it surface as a mute timeout.

Tests: a worker thread creates an lp client over qt_remote and calls a provider
published on the main thread; passes in ~0.15s, and with the construction hop
reverted fails after 24.8s / 49.9s — the acquire timeouts themselves. Plus a
truth table for needsQtEventLoop. 183/183 protocol tests pass.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 08:31:00 -03:00
Dario LipicarandClaude Opus 4.8 8ede8ece08 fix(lp): destroy clients on their owner thread (#27)
lp_client_destroy() called `delete` on the LogosAPIClient directly, on
whatever thread happened to release the last handle share. That thread is
not always the owner: any binding that parks a client share in a worker —
a Rust EventSubscription moved into a bridge thread, for one — runs the
destroy there when the worker exits.

Deleting the client there destroys its consumers' transport objects off
their owner thread. With Qt Remote Objects that tears down the node's
QLocalSocket and its socket notifiers cross-thread; Qt warns ("socket
notifiers cannot be enabled or disabled from another thread"), the fd
closes under the owner's event dispatcher ("Invalid socket N with type
Read, disabling..."), and the process takes SIGSEGV. Observed as
chat_module crashing on shutdown, when joining its bridge worker dropped
the last delivery_module share on that worker.

Defer the teardown to the owner thread via deleteLater() when the caller
is elsewhere, mirroring the marshaling every call path already does with
logos::runOnOwnerThread. A blocking marshal is not usable here: the owner
is typically the dispatch thread and may be blocked joining the very
worker running the destroy. Deferring is invisible to callers because the
callback guard, not the delete, enforces the ABI's "no callbacks after
this returns" contract.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 17:44:52 -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