3 Commits
Author SHA1 Message Date
Dario Gabriel LipicarandClaude Opus 5 b505ee95eb fix(generator): widen BOTH rejection detectors to a CLOSED SET of codes
The two emitted detectors — logosDispatchRejection (QVariant, Qt surface) and
logosDispatchRejectionJson (nlohmann, lp surface) — each matched the single
literal "dispatch_failed". Providers have been answering a wrong argument COUNT
with "invalid_args" all along: this repo's own cdylib dispatch emits it
(experimental/lidl_gen_cdylib.cpp:805) and so does logos-rust-sdk's
args::invalid_args. Nothing detected it. The refusal therefore arrived as a
VALUE and the return table erased it — `_result.toList()` on that map is `[]`,
`.toString()` is "", `.toLongLong()` is 0 — so a caller could not tell "you sent
me the wrong number of arguments" from "the provider returned nothing".
Measured on the untyped surface, where the erasure is visible:

  logosctl call test_basic_module isPositive     (missing required argument)
  -> exit 0, status:"ok", result {"code":"invalid_args", ...}

The set is now {dispatch_failed, invalid_args, unknown_method}, in ONE
kRejectionCodes array. Both emitters build their condition text from it, so the
Qt and Qt-free twins cannot drift apart — which is what two hand-written copies
of the same literal were always going to do.

"unknown_method" is listed before any provider emits it, on purpose. An unknown
method is currently answered with a bare null, byte-identical to a legitimate
null return (logos-protocol logos_protocol.h says so outright), and closing that
is a provider-contract change across the SDKs. Detectors go first because
widening one is backwards-compatible on its own — nothing emits the code, so
nothing changes — whereas a new provider code shipped against narrow detectors
would arrive at consumers as DATA: the same silent-success bug, freshly minted.

The set stays CLOSED. NOT "any three-key object with a code": a method may
legitimately return a three-string map, and an `any` return certainly can, so a
shape-only match would let user data impersonate a refusal. Every guard above
the compare — exactly three keys, all three present, all three strings — is
untouched.

WHY FIVE COPIES AND NOT ONE. The other four are logos-qt-sdk's byte-identical
lidl_gen_qt_consumer.cpp, logos-rust-sdk's args::as_dispatch_rejection, and
logos-logoscore-cli's core_service/call_envelope.cpp. Two shared homes were
considered, both rejected here and both recorded at kRejectionCodes: a shared
EMITTER in share/lidl-frontend (the channel exists — it already ships
lidl_emit_common to logos-qt-generator) collapses 2 of 5 and turns four
independently landable fixes into an ordered stack; a runtime predicate in
logos-protocol is the principled end state, and is how the analogous CONVERSION
duplication was actually solved, but it trades a text-level duplication for a
build-level version coupling — the emitted body is self-contained today, so a
wrapper compiles against whatever protocol its module pins. Each repo instead
holds the vocabulary in one named place, so a drift is visible.

Tests: each code asserted present in the emitted condition on both surfaces,
plus the negatives that keep the match closed — exactly three comparisons and
no more, the shape guards still emitted, and the compare still ahead of
`return true`. 316/316 ctest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 18:06:13 -03:00
Dario LipicarandClaude Opus 5 9c05d4ad11 feat(lp): emit the AsyncResult twin, and fix the LpClient create race (#142)
* feat(lp): emit the AsyncResult twin, and fix the LpClient create race

Two related changes to the Qt-free (ApiStyle::Lp) consumer surface.

1. logos::LpClient::ensure() published its lazily-created lp_client through a
   plain pointer with no synchronization. Two threads reach a dep's FIRST call
   concurrently more often than the lazy-init shape suggests: a
   concurrency:"multi" module dispatches handlers on concurrent QThreads, and
   any module running a worker of its own (an HTTP handler, a chain-sync pump)
   races that worker against the dispatch thread. So this was a data race, and
   it leaked whichever client lost.

   A mutex around the body is the obvious fix and the wrong one: for a Qt-affine
   transport lp_client_create marshals construction onto the Qt main thread and
   BLOCKS there, so a worker holding the lock would wait for the main thread
   while the main thread, reaching the same ensure() from an inbound call, waits
   for the lock — trading a data race for a deadlock. Construct outside any lock
   and publish with a CAS instead; the loser destroys its own client, which
   lp_client_destroy permits from any thread. A failed create is not latched.

2. `<name>AsyncResult` is now emitted for the lp surface, matching the Qt one.

   It was withheld for a reason that belonged to the transport rather than the
   emitter: lp_invoke_async used to hard-code `cb(1, ...)`, so an AsyncResult
   over it would have reported ok() for a call to a module that was not even
   loaded — an error channel that lies is worse than none. logos-protocol#40
   fixed that, and the new logos::LpClient::invokeAsyncResult surfaces the
   failure in C++.

   The generated twin also folds a provider REJECTION: a provider that ran and
   refused answers {"code": "dispatch_failed", ...} as its RESULT, not as a
   transport error, so the decode would otherwise erase it into a default value.
   The lp SYNC path folds it too now, as the Qt sync path already did — with no
   qWarning fallback, since a Qt-free wrapper has no logger to fall back to.

   lp `<name>Async` is deliberately unchanged: its callback takes the value
   alone, exactly as on the Qt side.

Also unblocks logos-qt-sdk's LpBridge::invokeAsyncResult, which keeps a private
second lp_client only because logos::LpClient had no error-carrying async.

Verified: sdk tests 309/309 (incl. 9 new LpClient and 10 new generator tests),
generator-cli green, and the generated wrapper compiles under -Wall -Wextra
-Werror. The concurrency test was checked against the pre-fix header as a
control: 8 threads, 8 clients created, 7 leaked, threads disagreeing on which
client was the module's.

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

* fix(tests): stub lp_string_free, and keep the path that needs it live

The sdk_tests link failed on GCC/Linux with an undefined reference to
lp_string_free from logos::LpClient::getMethods(), while linking cleanly on
macOS/clang.

The stub set was genuinely missing lp_string_free. It went unnoticed because the
lp_get_methods stub returned NULL, which made getMethods()'s free call
unreachable: lp_get_methods is a non-inline extern "C" function DEFINED IN THE
SAME TU as the test, so clang may inline it, prove the pointer null and delete
the call — no reference, no link error. GCC kept the call, and the linker wanted
the symbol.

Adding the stub alone would fix the link and leave the trap: the free path would
still be dead, so the next compiler that keeps the call decides whether this
builds. So lp_get_methods now returns a real heap allocation, which is the ABI's
actual contract ("every char* RETURNED by this library is owned by the caller;
free it with lp_string_free"), and lp_string_free frees it and counts. The
single-threaded test asserts the count, so the ownership rule is pinned rather
than merely satisfied.

Verified by reproducing the failure on macOS with -O0 (which stops clang folding
the call away): the pre-fix file fails with exactly `"_lp_string_free",
referenced from: logos::LpClient::getMethods()`, and the fixed one links and
runs 9/9. Full check: 308/308.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 19:24:20 -03:00
Dario LipicarandClaude Opus 5 f3369faca4 feat(generator): async callers can see the error, sync callers can set a deadline (#132)
The two consumer surfaces had complementary holes:

  sync :  T    foo(params…, logos::CallError* err = nullptr)   error yes, timeout NO
  async:  void fooAsync(params…, cb, Timeout = Timeout())      timeout yes, error NO

so an async caller could not tell a failed remote call from a provider that
legitimately returned 0 / "" / false — the exact ambiguity the sync path's
CallError* was added to resolve — and a sync caller could not say how long it
was willing to wait, even though the transport overload the generator already
calls takes both.

Both fixes are additive:

  T    foo(params…, logos::CallError* err = nullptr, Timeout timeout = Timeout());
  void fooAsync(params…, std::function<void(T)> cb, Timeout timeout = Timeout());   // unchanged
  void fooAsyncResult(params…, std::function<void(logos::AsyncResult<T>)> cb,
                      Timeout timeout = Timeout());                                  // new

logos::AsyncResult<T> (new, Qt-free, cpp/logos_async_result.h) is {value, error}
plus ok(); AsyncResult<void> carries only the error so every fooAsyncResult has
the same callback shape. The name is distinct rather than an overload because
std::function<void(AsyncResult<T>)> next to std::function<void(T)> is ambiguous
for a generic lambda.

Applied to both emitters that produce this surface — legacy/generator_lib.cpp
(the module-builder path) and experimental/lidl_gen_client.cpp (`--lidl
--module-only`, from a published contract) — since a consumer can reach either
for the same contract.

The Qt-free (ApiStyle::Lp) surface gets the sync timeout (spelled `int
timeout_ms`; `Timeout` lives behind a Qt header) but NOT fooAsyncResult:
logos-protocol's lp_invoke_async hard-codes `cb(1, …)`, so an AsyncResult there
would report ok() on a failed call. Measured, not assumed. See the note in
makeHeaderLp.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 10:19:08 -03:00