Commit Graph
9 Commits
Author SHA1 Message Date
Dario LipicarandClaude Opus 5 2e3344accc feat(shared-api): make liblogos_protocol.dll a usable single provider (#65)
PR-1+PR-2 of the shared-runtime migration, squashed: they were raised
separately and the second replaced the first's mechanism, so the split was
history rather than review value.

WHY. The runtime types that must exist EXACTLY ONCE per process (TokenManager,
LogosAPIClient, the per-identity StoreRegistry) are moving from "absorbed into
liblogos_core by whole-archive and re-exported through a generated .def" to
"owned by the shared library that defines them". Every image that links a static
archive gets its own copy of every function-local static inside it, so the host
writes a capability token into one store and another in-process image reads an
empty one -- with no build diagnostic.

Three things, and the order they were discovered in is the order they matter:

1. THE CMAKE PACKAGE. logos_protocol_shared was built and installed but
   deliberately kept OUT of the export set: it existed only for FFI callers that
   dlopen the lp_* C ABI, and those never link it. In-process C++ consumers do,
   and a consumer cannot link what find_package() does not hand it. Now exported
   as logos-protocol::logos_protocol_shared, with the INSTALL_INTERFACE include
   dirs the static target already had, and with ARCHIVE DESTINATION -- on Windows
   a shared library's import library (.dll.a) is the ARCHIVE artifact, so
   omitting it installs no import library at all and the failure is invisible on
   ELF and Mach-O, which have none.

2. THE EXPORT TABLE IS GENERATED, NOT HAND-MARKED. The first attempt annotated
   the classes with __declspec(dllexport). That exported 116 symbols and the Qt
   host runtime STILL failed to link against it, with ELEVEN undefined
   references across five classes -- LogosProviderObject and its vtable,
   ModuleProxy, ModuleHandshakeProxy, LogosTransportFactory -- plus free
   functions such as logos::qvariantToNlohmann. A curated list is correct only
   until the next consumer touches a symbol nobody marked, and the failure lands
   in a downstream repo far from the cause.

   cmake/gen-shared-exports.sh is adapted from logos-liblogos, which generated
   the same table one layer up. The mechanism is unchanged because the reasons
   for it are unchanged; this moves it down to the library that owns the
   symbols. It cannot be shared as a file: logos-liblogos depends on
   logos-protocol, not the other way round.

   logos_shared_api.h therefore resolves its "building the shared library"
   branch to NOTHING on Windows, so the .def and the annotations never compete.
   The macro keeps its import half, which is what stops a consumer pulling the
   archive member that would redefine the symbol.

   30 exports on master -> 116 hand-marked -> 360 generated.

3. VTABLES AND TYPEINFO ARE CARVED OUT OF THE COMDAT FILTER. The last undefined
   symbol was the vtable for LogosProviderObject. PE HAS NO WEAK SYMBOLS --
   COMDAT is the mechanism for weak and inline linkage -- so GCC emits a vtable
   into .rdata$_ZTV... even when the class has a key function and the vtable is a
   single strong definition. The section name cannot tell "one definition nobody
   duplicates" from "every TU emits its own", so the filter dropped it. The
   filter's reasoning does not apply to vtables: a consumer of a class WITH a key
   function emits a .refptr and needs ours; a class WITHOUT one emits its own
   copy and never references ours, so exporting is inert. This never mattered
   while liblogos_core absorbed both archives -- definition and consumer landed
   in one image and the reference never crossed a boundary.

WHY PROTOCOL NEEDS A .def WHEN THE QT HOST DOES NOT. The shared qt-host DLL
exports 2799 symbols with no .def at all, because it carries no dllexport marks
and GNU ld auto-exports everything. Protocol cannot rely on that: LP_API's
dllexport on the lp_* C ABI disables auto-export for the whole target. ANY single
dllexport turns the automatic path off -- which is also why CMake's
WINDOWS_EXPORT_ALL_SYMBOLS was measured as completely inert here.

TWO MACROS, NOT ONE. LOGOS_QT_HOST_API is added for logos-plugin-qt's LogosAPI,
which lives in a different library. While building the Qt host shared library
LogosAPI must NOT be dllimport while TokenManager must be, and one macro cannot
say both in the same translation unit. Off Windows the distinction is moot --
both resolve to default visibility -- which is exactly why getting it wrong would
go unnoticed until a Windows build.

Also corrects this file's own premise, the origin of the false claim that "ELF
and Mach-O give this for free. Both formats interpose symbols across the whole
process image set." True of ELF, false of Mach-O, whose two-level namespace gives
no interposition -- measured in logos-basecamp, where one reference to
LogosAPI::forIdentity dragged logos_api.cpp.o into the executable and produced 31
refused calls against a baseline of 0.

VERIFIED.

  aarch64-darwin   static archive symbol tables IDENTICAL (14257 lines), exactly
                   ONE byte differing in 5.4MB -- 351 -> 352 in __.SYMDEF's ar
                   header, build metadata, not content
                   logos-protocolTargets.cmake names both targets
                   checks.tests PASS, .#default PASS
  x86_64-linux     checks.tests PASS
  x86_64-mingw     export table 30 -> 360, all 30 lp_* preserved, 0 removed
                   import library liblogos_protocol.dll.a now installed
                   logos-plugin-qt#22 links against it, and its PE layering is
                   correct: defines LogosAPI 25, defines TokenManager 0 and
                   LogosAPIClient 0, imports from liblogos_protocol.dll

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 22:18:42 -03:00
Dario LipicarandClaude Opus 5 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>
2026-08-11 09:44:25 -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 362b03fb1e feat(codec): one canonical LIDL ↔ JSON codec, generic over composition (#29)
* feat(codec): one canonical LIDL <-> JSON codec, generic over composition

The tagged-bytes encoding {"_bytes": "<base64url, unpadded>"} was implemented
SIX times — the Qt conversion here, the plain wire's json_mapping, the lp helper
in logos-cpp-sdk, a copy emitted into every generated cdylib module, the Rust
SDK and the Python client — and they disagreed on which inputs they accept:

  - {"_bytes":"AA","x":1} decoded as BYTES on the lp path (no size()==1 check)
    but as a MAP on the plain wire and in the glue.
  - Padded "AH-A_w==" gave correct bytes in one copy, empty in another, None in
    Rust.
  - A plain string / number / number-array argument was accepted by C++
    providers (Qt and CLI parity) and rejected by Rust ones.

logos_codec.h is the single implementation. Leaves: tstr, bstr, every signed and
unsigned integral spelling, every floating spelling, bool, any (recursion stops).
Composition is GENERIC — std::vector<T> and std::map/unordered_map<std::string,T>
for any supported T, at any depth — so [bstr], [[bstr]], {tstr: [bstr]} and bytes
nested in a map all encode canonically without anything enumerating combinations.

Codec<T> is a trait, so an unsupported T is an incomplete type: a compile error
naming the type, never a silent fallback. Decode throws CodecError carrying the
path ("[0][1]", ".k") instead of substituting a default — a mangled value must
not reach business logic. bstr keeps a documented lenient form for provider-side
arguments, because the Qt consumer path and the logoscore CLI both produce plain
strings and number arrays for byte parameters.

JsonArg exists for generated dispatch: it converts itself into whatever the
callee's parameter type is. Naming the type instead is a trap — spelling [uint]
as std::vector<uint64_t> (the LIDL mapping) does not bind to an author's
std::vector<uint32_t>, since distinct vector instantiations do not convert.

logos_codec.h joins the installed header set; nix/include.nix already globs
cpp/*.h.

Tests: 198/198. 15 new ones pin the contract rather than the happy path —
[[bstr]] tagged at depth, map-of-bytes, empty elements surviving as elements,
uint64 past 2^63, an integral JSON number decoding as float64, padded base64,
the multi-key {"_bytes":...} case being a map, and path-carrying failures.

Not yet converged onto this header (follow-ups): the Qt conversion in
logos_json_convert.cpp, and the plain wire's copy in json_mapping.cpp — the
latter needs a strict variant first, because it THROWS on malformed base64
(via its own logos::plain::CodecError) where every other copy is tolerant.

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

* refactor(codec): fold the Qt and plain-wire copies into the shared codec

The two remaining in-repo implementations now delegate:

  - logos_json_convert.cpp (the Qt CONSUMER path — argument encoding and return
    decoding) dropped Qt's toBase64/fromBase64 and its own tagged-bytes
    predicate. Only the QByteArray <-> std::vector<uint8_t> hop stays local, so
    the Qt path cannot drift from the wire or from providers: same alphabet, same
    padding rule, same single-key shape.
  - implementations/plain/json_mapping.cpp dropped its anonymous-namespace
    b64url_encode/decode.

The wire needed something the tolerant decode does not give it: it REJECTS a
corrupt frame rather than silently decoding fewer bytes. Hence
b64UrlDecodeChecked — strict about the alphabet and the length, tolerant of '='
padding — which json_mapping uses to keep throwing its own
logos::plain::CodecError. Consumer-facing decodes stay tolerant. Both behaviours
now come from one implementation instead of four that disagreed.

Also removed the local isTaggedBytes wrapper, which shadowed the shared one and
made unqualified calls ambiguous.

Tests: 199/199, with the strict decode's accept/reject set pinned (padding
tolerated, stray character rejected, impossible length rejected).

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 15:01:49 -03:00
Dario LipicarandClaude Opus 4.8 6401e30ae1 feat: group-shareable local sockets, stale-socket reaper, bind-failure detection (#20)
* feat: group-shareable local sockets, stale-socket reaper, bind-failure detection

The QtRO local transport binds each module's unix socket at 0777 & ~umask
(0755) with no way for a second OS user to reach it, discards the listen
result so a failed bind surfaces only as clients hanging, and never cleans up
the socket file — a hard-killed logos_host leaks it forever.

Add a Qt-free helper (logos_socket_paths.{h,cpp}) usable from both the qt_remote
and plain transport paths:

  - applySocketPerms(path): chgrp + chmod a bound socket per LOGOS_SOCKET_GROUP /
    LOGOS_SOCKET_MODE (chgrp-then-chmod so a half-applied policy is only ever
    too strict). No-op when unset, so default behaviour is unchanged. Connecting
    to an AF_UNIX socket needs write permission, so 0660 is what lets a group
    member in.
  - isSocketDead(path): S_ISSOCK && owned-by-us && non-blocking connect returns
    ECONNREFUSED/ENOENT. Fails closed on any other outcome, so it never reports
    a live socket or a regular file dead.
  - reapStaleSockets(dir, prefix): unlink only the dead sockets, never a regular
    file that shares the prefix (e.g. a *.lgx build artefact).

Wire it into RemoteTransportHost::publishObject and QtRemoteRegistry:
  - construct QRemoteObjectRegistryHost empty and listen via setRegistryUrl() so
    a bind failure is observed and logged (with lastError() + the socket path)
    instead of leaving a silently-broken host;
  - apply the socket-access policy to the freshly-bound local: socket.

The env-driven policy means every process in a node's tree (daemon, logos_host
subprocesses, their children) applies the same rule to every socket it binds
without threading config through each layer — the daemon exports the vars once.

Adds test_socket_paths.cpp (8 gtests): mode/group application, no-op default,
bad-mode rejection, live/dead/regular-file classification, and the reaper
keeping live sockets and regular files while removing only dead ones.

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

* review: harden socket helpers (gid overflow, socket-owner check, empty-prefix guard, dedup path)

Addressing automated review feedback on the socket helpers:

- resolveGid(): validate strtoul() errno/range so an out-of-range numeric
  LOGOS_SOCKET_GROUP is rejected instead of silently truncating to a wrong gid.
- applySocketPerms(): when a policy is requested, stat the path first and refuse
  unless it's a socket we own (S_ISSOCK + st_uid == geteuid()), so a malformed
  URL can never chmod/chown a stray file. No-op fast path when the env is unset.
- reapStaleSockets(): refuse an empty prefix (would make every dead socket the
  process owns a deletion candidate).
- Extract the duplicated `localSocketFilePath()` (Qt QLocalServer name->path
  rule) into a shared qt_remote/qt_socket_path.h so RemoteTransportHost and
  QtRemoteRegistry can't drift.

Adds tests: non-socket path refused (mode unchanged), empty-prefix reaper no-op.

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

* feat: transport-aware token validator hook on ModuleProxy (#22)

* feat: transport-aware token validator hook on ModuleProxy

Adds an injectable authorizer so a host (the logoscore daemon) can accept tokens
the built-in issued-token scan doesn't know — specifically operator-issued named
tokens validated against a persistent store — with per-token expiry and
local_only enforced against the transport the call arrived on.

- ModuleProxy::setTokenValidator(std::function<bool(token, transportProtocol)>).
  isAuthorized() consults it ONLY after the existing m_tokens + TokenManager
  scan fails, so installing a validator is purely additive: it can grant, never
  revoke, access the built-in path already allows. Empty (default) = today's
  behaviour exactly.
- callRemoteMethod() gains a defaulted `transportProtocol` ("local"). The QtRO
  local path (RemoteTransportHost) uses the default; PlainTransportHost::onCall
  passes the real wire ("tcp" | "tcp_ssl", fail-closed to non-local on an
  unexpected protocol) so a local_only token presented over the network is
  rejected. One ModuleProxy is shared across a provider's transports, so the
  transport can't be inferred — it must be threaded per call, which the defaulted
  arg does without changing the QtRO replica's 3-arg call.

The daemon backs the validator with TokenStore::lookupByToken; other modules
keep the default (no validator) and are unaffected.

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

* review: split callRemoteMethod into explicit 3-arg + 4-arg overloads; include <utility>

Addressing review feedback:

- Replace the defaulted transportProtocol argument with two explicit Q_INVOKABLE
  overloads. The Qt meta-object system matches methods by their full parameter
  list and doesn't apply C++ default arguments, so the QtRO/local 3-arg call
  must remain a real 3-arg method rather than relying on moc's reduced-arity
  generation. The 3-arg form forwards to the transport-aware 4-arg form with
  "local"; PlainTransportHost keeps calling the 4-arg form with the real wire.
- Include <utility> explicitly in module_proxy.h for std::move rather than
  relying on an indirect include.

Full protocol suite green (160/160).

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 23:19:12 -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
Dario LipicarandClaude Opus 4.8 d7ad26d369 feat: ship liblogos_protocol shared library exporting lp_* (#4)
Add a `logos_protocol_shared` target that builds the same sources as a
shared library (liblogos_protocol.{so,dylib}), exporting the
language-neutral lp_* C ABI for out-of-plugin callers that bind it at
runtime via dlopen/FFI (logos-js-sdk's koffi.load, logos-rust-sdk's
callerBuildSupport) — the role liblogos_module_client previously filled.

The static `logos_protocol` archive and its EXPORT set are untouched, so
in-plugin code and find_package(logos-protocol) are unaffected; the shared
target is deliberately not exported into the CMake package.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 12:44:36 -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