Commit Graph
4 Commits
Author SHA1 Message Date
Dario LipicarandClaude Opus 5 480f40ff63 feat(abi): publish the module-impl export list as data, for backends to check against (#66)
* feat(abi): publish the module-impl export list as data, for backends to check against

logos-protocol DECLARES the module-impl C ABI; every language backend
(logos-cpp-sdk, logos-rust-sdk, and the Nim path now in flight) must
DEFINE every entry. Those are independent facts, and the gap between them
has shipped twice — grant_host_services at 0.3, the teardown pair at 0.5.
Each time it surfaced three repos downstream as an "undefined symbol" at
dlopen, on Linux only, and each time the runtime still reported the module
as LOADED, so what anyone actually saw was other modules timing out on a
replica that never appeared.

Both breakages happened at PERFECT version agreement between the caller
and the module. Version agreement is necessary and not sufficient: it says
nothing about which symbols a given backend's emitter happens to write.

So derive the list once, here, in the repo that owns the ABI, and ship it
as a build output:

  packages.<sys>.module-impl-abi
    exports.txt  — the declared names
    version      — the protocol version that header belongs to
    bin/logos-module-impl-diff — the assertion, and the explanation

Two properties follow from putting it here rather than in each backend.
There is ONE parser to keep working, rather than one regex per language
that can each silently stop matching. And the list is version-correct with
no version arithmetic anywhere: the header is itself versioned — at 0.4 it
declared eight exports, at 0.5 it declares ten — so "what this protocol
requires" is just "what this header declares". A backend pinning 0.4 reads
eight and is right to define eight. No @since tags, no MINOR comparisons,
nothing for a backend to get wrong.

The extractor parses LOGICAL declarations rather than lines (a reflowed
header must not silently drop one) and refuses to emit a list it is unsure
of: under-reporting is the dangerous direction, because a short list makes
every consumer's diff pass over an ABI nobody checked. The floor it checks
against is asserted rather than derived, so a broken parse cannot satisfy
it. logos-protocol failing to build is the right consequence of
logos-protocol being unable to state its own ABI.

checks.<sys>.module-impl-abi-tests proves all of that can still fail: empty
header, renamed macro, a founding export removed, an empty defined-set, and
a reflowed declaration — eleven cases, each a way this could have decayed
into a green check over nothing.

Also corrects the compatibility note above logos_module_about_to_unload.
It argued the pair was safe because "the glue is generated alongside the
module". That does not follow, and is the reasoning the 0.5 break rested on.

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

* ci: run the ABI manifest check — `nix build .#tests` never reaches `checks`

The step added here is not incidental. `nix build '.#tests'` builds the
PACKAGE; nothing in this workflow evaluated the `checks` attrset at all, so
the manifest self-test added in the previous commit would have sat there
green-by-absence — which is precisely the failure mode it exists to catch.

`ws test` is not a substitute either: it evaluates exactly one check per
repo (scripts/ws truncates the checks JSON at the first comma), so a green
`ws test logos-protocol` says nothing about whether this ran.

builtins.currentSystem rather than a literal, so one line is correct on both
matrix runners.

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

* fix(abi): the helper must not depend on the consumer's PATH — and the suite must notice

Two bugs, and the second is the interesting one.

1. The installed helper carried `#!/usr/bin/env bash`. Consumers execute it
   from inside their own nix builds, whose PATH is whatever THEIR
   nativeBuildInputs provide. It resolved on macOS and not in the Linux
   sandbox, so the helper simply did not run there. patchShebangs pins an
   absolute interpreter.

2. The self-test did not notice, and the reason is worth keeping. expect_fail
   accepted ANY non-zero exit as a correct refusal — but a script that cannot
   be executed exits 126/127, so all five refusal cases reported PASS while
   proving nothing at all. Only the two POSITIVE cases failed, which is the
   only reason this surfaced.

   That is precisely the failure this whole change exists to prevent, one
   level up: a check that reports green over something it never examined. So
   expect_fail now asserts a deliberate refusal and rejects 126/127 by name.

Caught by CI on ubuntu-latest while macOS was green — the same
platform-asymmetry that let the original ABI break through.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 12:06:48 -03:00
Dario LipicarandClaude Opus 5 f4407ff485 ci: use logos-co/setup-nix-cache-action for Nix setup and caching (#60)
Replaces the per-repo installer + cachix pair with the shared action, which
installs Nix with the Logos Attic cache (cache.nix.logos.co) preconfigured and
publishes what the job builds — master to the public cache, every other ref to
ci.

Each converted job also gains

    environment: ${{ github.ref == 'refs/heads/master' && 'public-cache' || '' }}

because ATTIC_TOKEN_PUBLIC only exists inside that environment. Without it the
secret resolves empty on master and publishing is silently skipped — the job
still passes, so the omission would not show up as a failure.

The action installs Nix itself on every runner, macOS included. That is a
deliberate reversal of the workaround these files carried: the comments here
said cachix/install-nix-action collides with the runner's pre-existing _nixbld
users (eDSRecordAlreadyExists), so DeterminateSystems' installer was used
instead. It no longer reproduces — logos-delivery-module has already been
converted the plain way and its `build-and-test (macos-latest)` leg passes.
Keeping the workaround would have meant a second installer plus a duplicated
substituter/key block in ten files, guarding against something two green runs
say does not happen. If it ever recurs it fails loudly at install, which is
recoverable; the silent-skip above is the failure mode worth engineering
against.

One property is deliberately NOT carried over: the old cachix step ran with
`continue-on-error: true` so a failed cache push could not fail a job whose
tests passed. The action exposes no equivalent, and adding one here would also
swallow genuine setup failures now that the same step installs Nix rather than
only publishing at the end.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 23:41:49 -03:00
Dario LipicarandClaude Opus 5 0fef299362 fix(plain): deliver async callbacks in a Qt-free host, and make release()-racing-a-call diagnosable (#50)
* fix(plain): deliver async callbacks in a Qt-free host, and make release()-racing-a-call diagnosable

Two pre-existing defects in the plain transport's async surface. Both are
older than #45/#46 and neither is caused by the io_context fold; the fold is
just what this is stacked on.

DEFECT 5 — the async surface promised exactly-once and delivered ZERO in a
Qt-free host. Every completion went through one hop, and the hop was:

    QCoreApplication* app = QCoreApplication::instance();
    if (!app) return;                       // <- the callback, dropped

In a Qt host that branch only fires at shutdown, which is why it read as a
reasonable guard. In a process that never had a QCoreApplication — the
deployment the plain transport exists for — it fires for EVERY call, forever,
on all four resolvers (reply, deferred completion, deadline, cancellation).
Not an error, not a timeout: silence, which turns a bounded call into an
unbounded wait in every caller that awaits it, including lp_invoke_async and
every generated async wrapper.

Fixed with a dedicated DELIVERY THREAD, used only when the process has no Qt
loop. NOT inline on the completing stack: inline delivery on an Asio read
handler is the re-entrancy class that already cost this codebase a SIGSEGV
(deferred-multi completion on the QtRO read stack), so a fix that delivers by
removing the hop is not a fix. NOT the deadline thread either — user callbacks
there would make every deadline in the process hostage to user code, which is
exactly the coupling DeadlineService was extracted to prevent.

The Qt-loop check LATCHES, so Qt hosts see no behavioural difference at all:
instance() also goes null inside ~QCoreApplication, and module teardown after
the application is gone is what static-destruction ordering produces — with
stopAndCancelCalls() handing every in-flight call a cancellation callback at
exactly that moment. Running user code on a side thread into half-destroyed
module state would be a NEW failure mode introduced by a bug-fix change, so a
process that has ever been seen with an event loop keeps the old shutdown
behaviour. logos_object.h now states that residue instead of glossing it.

DEFECT 3 — release() racing a call on another thread. NOT FIXED, because it
cannot be, and the honest answer is a contract plus a detector.

release() ends in `delete this`, so a synchronous call parked in its future
wait dereferences freed memory when it comes back. Reproduced deterministically
on master (exit 139 under Guard Malloc, 3/3) and on cf1b9b0 (exit 139 with AND
without Guard Malloc, 3/3), faulting in callMethodWithError one line after the
wait.

It is not fixable from inside the object: every mechanism that could make the
racing call safe — a refcount, a flag, a lock, an epoch — is a MEMBER, so the
racing thread's first act would be to read it out of storage that has just been
freed. There is no synchronising with a destruction you can only learn about by
reading the destroyed object. Three alternatives were considered and rejected,
each for a stated reason (an atomic alive-flag is check-then-use on freed
memory; a blocking release() breaks the fast-teardown guarantee and deadlocks
in the shipped reentrant shape; an immortal forwarding handle works but trades
the crash for permanent retention proportional to requestObject count, in a
transport whose two preceding changes were spent proving retention does not
grow with call count — and would fix one of four transports). The reasoning is
in the note over PlainLogosObject::release().

So: the contract is stated (logos_object.h, plain_logos_object.h), and the
object counts entries into its public methods and REPORTS when release() or
the destructor finds the count non-zero — aborting in debug builds. The misuse
becomes a named diagnostic at the line that committed it instead of a SIGSEGV
somewhere else. It is a diagnostic, not a rescue, and it is deliberately biased
to under-report rather than ever accuse a correct program.

EVIDENCE, all by running:

  * Defect 5: six detectors in a NEW binary (protocol_noqt_tests) that never
    constructs a QCoreApplication — the state protocol_tests can never reach,
    since its main() constructs one first. All six red on cf1b9b0 (0/300
    replies, 0/40 deferred, 0/20 deadlines, 0/20 cancellations delivered),
    all six green after, including under Guard Malloc.
  * Defect 3: a death test red on BOTH pre-fix trees, 3/3 each, with and
    without Guard Malloc ("died but not with expected error"), green after.
    Its three companion tests prove the detector never fires on a correct
    program, and were themselves validated by deleting the decrement from
    EntryGuard's destructor in a throwaway build: all three then abort.
  * Exactly-once still holds via the release-race shape — the only one that
    detects a broken gate — on both delivery vehicles: 20 rounds x 500 calls
    released mid-burst, 0 double deliveries, 0 dropped, with both resolvers
    live, under Guard Malloc too.
  * nix build '.#tests': 312/312 ctest cases pass. Both installed binaries run
    clean through the exact CI commands.

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

* test(plain): bound the concurrent-callers wait, and fail the harness loudly

Two ways this file could have reported something other than what it measures.

An unbounded `while (ok < N) processEvents()` does not fail when it goes
wrong — it hangs the CI job until the job timeout, and a hang says nothing
about what broke. Bounded at 60s; the assertion below it then reports the
actual count.

And the death test's harness setup checked the host and the connection but
not the handle, so a failed acquire would have crashed on a null pointer and
been reported as "died but not with expected error" — indistinguishable from
the defect the test is looking for. It now exits 9 with a message, like the
other two harness paths.

Re-validated after the change: still red on cf1b9b0 (3/3), green here.

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

* fix(plain): the detector must not touch the object after dropping its count

CI caught this, on Linux, in the shape this whole change is about — the
detector inventing the use-after-free it exists to report.

EntryGuard's destructor restored m_lastEntryPoint AFTER decrementing
m_callsInFlight. That opens a window exactly one store wide: the count reaches
zero, a release() racing on another thread reads zero, concludes nothing is in
flight and runs `delete this`, and the store lands in freed memory. Exit 139
in IoFoldTest.ReleaseFromInsideAnIoThreadEventCallbackDoesNotWedge on
ubuntu-latest; macOS was green in the same run, and the retry was green too,
which is exactly how a one-store window behaves.

The count is now the FIRST and LAST thing either the constructor or the
destructor touches. Between them the object is covered — a concurrent release()
sees a non-zero count and reports. Outside them the guard touches nothing. The
cost is a vaguer message across threads (the restore now happens before the
decrement, so a reader can see the outer frame's name); a diagnostic string is
worth less than not storing into freed memory.

AND THE REASON IT WAS REACHABLE AT ALL: that test really does violate the
contract this PR documents. It issued its triggering `fire` call on the same
handle its io-thread event callback releases, so release() ran while the main
thread was still inside that handle's callMethodAsyncWithError. The violation
was always UB and always silent — the pre-existing code touches no member after
sendCallAsync() returns, so losing the race cost nothing observable — which is
why it stayed green for seven runs on #46. Adding bookkeeping to the epilogue
made it visible.

Both tests with that shape now fire the event through a SECOND handle, which
changes nothing about what they pin: the event still arrives on the io thread,
the handler still releases the handle it was delivered through, and that handle
still has an outstanding call for teardown to cancel.

Verified by running:

  * With a 300ms sleep injected into callMethodAsyncWithError's epilogue — a
    window the old code lost every time — both tests reported
    "LOGOS FATAL: ... callMethodAsyncWithError()" before the fix and are clean
    after it. That is the violation demonstrated and then removed, not narrowed.
  * The same injection at 5ms across the WHOLE suite produces zero LOGOS FATAL
    reports: no other test has this shape. (The one failure it causes,
    IoFoldTest.ReleaseRacingRepliesInFlightDeliversEachCallOnce, is that test's
    own "the race did not run" guard firing because a 5ms-per-call sleep lets
    every reply land before the release — 10000 answered-by-reply, 0
    by-teardown, 0 doubles, 0 drops. Correct behaviour from the test.)
  * Full suite green again: 306/306 Qt, 6/6 no-Qt, and the UAF-sensitive subset
    green under Guard Malloc.
  * Detectors re-validated on cf1b9b0 after the edits: death test still red 3/3.

Also fixes a fragility this found in the new no-Qt race test. In that binary
the provider shares the process's single io thread with the consumer, so under
the nix sandbox the issuing thread enqueued all 500 calls and released before
one reply came back: answered-by-reply=0, cancelled-by-teardown=10000. Zero
doubles and zero drops — but only ONE resolver ran, so the exactly-once
assertion was proving nothing, which is precisely why the "both resolvers were
live" guards are in the test. It now waits for the first reply before
releasing; both resolvers are live every run (byReply 496-744, byTeardown
9256-9504 over six runs).

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

* fix(plain): make release() safe against a call already inside the object

The defect this PR reported as unfixable is fixable, and the argument that
said otherwise conflated two different races.

That argument ran: every mechanism that could save the racing call is a
member of the object, so the racing thread's first act would be to read
freed storage. That is true of a call that ENTERS after destruction. It is
false of a call ALREADY INSIDE the object, which is the defect actually
reproduced — a synchronous callMethod parked in its future wait, released
from a second thread, faulting on the next line it executes. That call took
its bookkeeping on the way in, while the object was provably alive, so
release() cannot fail to see it.

So PlainLogosObject carries a live-reference count next to the counter the
detector already added: 1 for the owner plus one per caller inside a public
entry point. release() tears down and then drops THE OWNER'S reference
instead of `delete this`; whoever drops the count to zero destroys the
object, which for a racing call is that call's own thread on its way out.
EntryGuard takes the reference before it touches anything else and drops it
after everything else, because the drop may BE the delete.

  SAFE now: release() concurrent with any call that entered first, sync or
  async, any number of threads; and release() re-entered from inside a call
  or an event callback on the same thread (shipped behaviour, io thread).

  STILL a caller error, and still diagnosed: STARTING a call at or after
  release() — its first act is to increment a counter that may already be
  freed, so nothing in the object can save it — and `delete obj` in place of
  release() with a call in flight, where there is no destruction left to
  defer. Both report and abort in debug builds whenever the object still
  exists to notice; when the storage is already freed there is nothing left
  to look at, and that residue is the documented contract.

Two consequences worth naming. m_conn is no longer reset by release(): the
parked caller's next act is `m_conn->cancelPending(...)`, and resetting a
shared_ptr while another thread reads it is a data race on the shared_ptr
itself. And the object — with its share of the connection — now outlives
release() by however long the slowest call still inside it takes, which is
bounded by that call's own timeout. release() itself still blocks on
nothing: 0ms with an 8000ms call in flight, unchanged.

release() and the destructor call an unguarded disconnectEventsImpl(),
because taking a reference during destruction would drop it again and
recurse into the delete.

VERIFIED by running, on macOS arm64, debug:

  * The reproduction now exits 0 through the real host stack; on cf1b9b0 the
    child dies by signal, 3 runs of 3.
  * The deterministic twin (a connection double that never answers, so the
    park needs no timing assumption): release() returns in 0ms with the call
    parked, destroyed=0 at that moment, destroyed=1 after the caller leaves,
    and the caller reaches its post-wait cancelPending. On cf1b9b0: exit 139,
    with and without Guard Malloc.
  * The tight version — the double answers with a pending sentinel so
    release()'s notify wakes the parked caller inside the window — 400 rounds,
    one destruction each, 0 double deletes. On cf1b9b0 that one is SILENT
    without Guard Malloc and 139 with it, which is noted in the test.
  * Both remaining misuses die with their named diagnostic; both fail on
    cf1b9b0, where no diagnostic exists to match.
  * No false alarms: 310/310 protocol_tests, and with the detector's
    decrement removed by hand all four "not accused" tests abort on a
    correct program (rc=134), which is what makes them detectors.
  * Guard Malloc clean over SyncCallReleaseRace, IoFold, PlainObjectTeardown,
    PlainCompletionSubLifetime, PlainCancelPendingRace, PlainWaiterReaping.

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

* fix(plain): keep the Qt-free delivery vehicle alive as long as its callers

The delivery thread this PR added fixed the drop and introduced a new
use-after-free one moment later in the process's life.

DeliveryService was an ordinary function-local static, so it was constructed
on the FIRST async delivery — which means every object with static storage
constructed before that (i.e. everything constructed during dynamic
initialisation) is destroyed AFTER it. A delivery issued from such a
destructor posted into an io_context that had already run its own destructor,
on a thread that had already been joined. Reproduced with nothing but the
null-connection early-return path: SIGSEGV under Guard Malloc inside
scheduler::post_immediate_completion, reached from __cxa_finalize, 3 runs of
3; and without Guard Malloc, silently, as delivered=0 — the exact drop this
class exists to prevent, moved to a later moment. So "exactly once holds for
the whole life of a Qt-free process" was still untrue.

FIX: the service is never destroyed and registers no destructor — a
`new`-ed pointer behind the function-local static, with the destructor
DELETED so no future edit can reintroduce one — and its thread is detached.
There is now no state in which the vehicle is gone but callers remain. The
old destructor's own comment worried about a user callback blocking the join
at static-destruction time; with no join there is no such hang, and exit()
does not wait for a detached thread. Costs: one io_context and one thread in
a process that is ending, and a callback that is RUNNING at process exit can
be cut off — the same exposure a Qt slot has when the loop's thread goes.

The alternative (detect the destroyed service and deliver inline) was
rejected: inline delivery is the re-entrancy class this hop exists to
prevent, and "we are at static destruction, so no io thread is running" is
not knowable from inside postDelivery — the completing thread there can be
IoContextPool's.

ALSO IN THIS COMMIT, because it is the same file and the same claim:

  * THE INLINE CHECK NOW MEASURES NESTING. Test 1 read d.total after
    callMethodAsyncWithError returned and asserted it was zero, which is a
    race against the delivery thread and not an inline check: 7 failures in
    200 runs here (the review reported 3/200 plain, 2/40 under Guard Malloc),
    every one of them with on-caller-thread=0 — i.e. nothing had actually run
    inline. This file already says as much about its own tests 2 and 5. The
    replacement is a thread-local depth marker raised around the issuing call
    and read BY THE DELIVERING THREAD at delivery time: a callback that runs
    inline is nested on the issuing thread and says so from inside itself,
    with no shared state and no timing. Applied to tests 1, 2 and 5, where it
    also strengthens 5 — "did a cancellation run from inside release()" is now
    nesting rather than a thread comparison.
  * A HARNESS LIFETIME BUG in the same file: QtFreeHost held its
    IncomingCallHandler as a member, RpcServer keeps a raw pointer to it and
    nothing joins the io thread, so a frame already read from the socket could
    be dispatched into freed storage. SIGBUS on the io thread inside
    dispatchIncoming, 1 run in 25 (1 in 5 under Guard Malloc) once the run got
    long enough for the io thread to reach the queued frames. The handler is
    now deliberately leaked, which is the shape that cannot lose that race.

CONTRACT WORDING. logos_object.h promised exactly-once unconditionally. It
now promises AT MOST once always, EXACTLY once whenever the callback has
somewhere to run, and enumerates the three process-level cases where it does
not: after ~QCoreApplication in a Qt process; in a process that constructs a
QCoreApplication and never RUNS its loop (queued onto a loop that never
turns — unfixable here, and it was covered by the old unconditional promise);
and in a process whose QCoreApplication was TRANSIENT, where the latch keeps
dropping for the rest of that process's life. That last one is the price of
the first: from inside postDelivery "the app is gone because we are shutting
down" and "a helper's app object went out of scope" are the same observation,
and guessing the other way would run user callbacks on a side thread during
every Qt host's teardown. A process with no QCoreApplication in its life is
NOT on the list — there delivery now holds through static destruction, with
the only residue being the process exiting before the delivery thread runs.

VERIFIED by running, on macOS arm64, debug:

  * The after-main window is now a TEST: a static destructor issues a delivery
    and reports through the process exit code, because no test case runs
    there. On the pre-fix delivery service it fails 3/3 (exit 70,
    delivered=0) and 3/3 under Guard Malloc (139). On this commit:
    delivered=1, off the issuing thread, exit 0.
  * The de-flaked test: 0 failures in 250 runs plain, 0 in 60 under Guard
    Malloc (was 7/200 before).
  * Whole no-Qt binary: 40/40 clean plain, 10/10 clean under Guard Malloc
    (was 1/25 and 1/5 with the SIGBUS above). Process exit adds ~50ms and does
    not hang.
  * All 7 no-Qt tests still fail on cf1b9b0 (0 deliveries), so defect 5 is
    still what it was.
  * 310/310 protocol_tests, 3 runs of 3.

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

* test(noqt): record that the exactly-once gate is TWO gates, not one

Re-validating the release-race exactly-once test against the no-Qt delivery
vehicle turned up a correction to what this suite says about its own
mechanism. AsyncCall guards a duplicate delivery twice — claim()'s
compare-exchange, and the swap in takeCallback() that leaves a second caller
holding an empty std::function — and the note in tests/protocol/CMakeLists.txt
describes only the first.

Measured, on the no-Qt twin (20 rounds x 500 calls released mid-burst):

  * CAS removed, swap intact:  0 doubled deliveries. This test, its Qt twin
    and PlainCancelPendingRaceTest all stay GREEN. So a validation that
    removes only the CAS proves nothing about the gate.
  * both removed:              22 doubled deliveries, this test FAILS — while
    the three per-path exactly-once tests stay green, which is the difference
    between a detector and a pin.

Neither half is redundant: the CAS is what stops a second caller from also
erasing registries and cancelling timers, and the swap is what protects the
callback itself. Comment-only; no behaviour change.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 17:13:09 -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