Skip to content

fix(generator): the Qt consumer must not flatten a rejection into an empty value - #129

Open
dlipicar wants to merge 2 commits into
masterfrom
fix/qt-consumer-surfaces-rejection
Open

fix(generator): the Qt consumer must not flatten a rejection into an empty value#129
dlipicar wants to merge 2 commits into
masterfrom
fix/qt-consumer-surfaces-rejection

Conversation

@dlipicar

@dlipicar dlipicar commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

What

A Qt consumer flattened a provider rejection into an empty value, so the caller
never saw the error.

A provider that REJECTS a call answers the canonical
{"code":"dispatch_failed", "message":…, "origin":…} object as its RESULT, not as a
transport error. Every provider flavour produces the same object — logos-qt-sdk's
dispatchFailedVariant, the generated cdylib dispatch, the Rust provider's
args::dispatch_failed.

The generated Qt consumer wrapper (legacy/main.cppgenerateInterfaceWrappers
generator_lib.cpp makeSource, i.e. the path every real module builds through:
logos-plugin-qt buildPlugin.nix runs
logos-cpp-generator --metadata … --general-only --api-style qt) converted it like any
other value, which erases it:

QVariant _result = m_client->invokeRemoteMethod(…, &_err);
if (err) *err = _err;                    // only ever a TRANSPORT error
return _result.toList();                 // the rejection map -> []

So echoUintList([1, -1, 3]), answered dispatch_failed by the provider, reached the
caller as [] — the whole list, not the bad element, and indistinguishable from "the
provider returned nothing". That is registry entry Q1's consumer half
(logos-test-modules conformance/known.json).

The fix

Report it through the channel this surface already uses for a failed call — the
logos::CallError* out-parameter every generated sync method carries (today
object_unavailable when the target module cannot be acquired). The envelope maps 1:1
onto logos::CallError's {code, message, origin}.

QVariant _result = m_client->invokeRemoteMethod(…, &_err);
if (_err.ok()) logosDispatchRejection(_result, _err);   // <- added
if (err) *err = _err;
else if (!_err.ok()) qWarning() << "FullApi::echoUintList: remote call failed:" << …;
return _result.toList();
  • No signature changes and no return-value changes. A caller that passes err now
    sees code="dispatch_failed" with the provider's diagnostic and origin; a caller that
    does not gets exactly the value it always got, plus the qWarning the wrapper already
    emits for a failed call. The generated header is byte-identical.
  • The detector is emitted once per wrapper in an anonymous namespace and matches
    exactly — three string fields and that code — for the same reason
    logos_rpc_status.h's isUnauthorizedSentinel matches exactly: an any/map return
    carrying user data must never false-match.
  • The result is now captured for void returns too: a void method can be rejected, and
    the rejection object is the only place that says so.
  • Emitted only when the contract has ≥1 invokable method (otherwise it is an unused
    function in an anonymous namespace, and such a contract's wrapper stays byte-identical).

Scope

Consumer side only. Q1's provider half — a Qt-typed provider cannot validate the
ELEMENT of a typed numeric array, because a C++ signature spells [uint] and [any]
alike as QVariantList — is explicitly out of scope and untouched; the project decided
Qt-typed PROVIDER hardening is no longer a goal.

The lp wrapper is untouched: makeSourceLp is unchanged and its generated output is
byte-identical before and after (verified below).

Verified by running

1. Real contract + real generated consumer + real provider. Generated
full_api_api.{h,cpp} from test-fullapi-qtproxy-module/interfaces/full_api.lidl with
the pre-fix and post-fix generators (--general-only --api-style qt), compiled each
verbatim into the same harness, and drove the prebuilt test_fullapi_cpp cdylib
through its own C ABI (logos_module_dispatch) — only the transport hop is stubbed. The
provider's real answer:

{"code":"dispatch_failed","message":"expected unsigned integer at arg0[1], got number","origin":"test_fullapi_cpp"}
check pre-fix post-fix
echoUintList([1,-1,3], &err)err.code '' dispatch_failed
err.message '' expected unsigned integer at arg0[1], got number
err.origin '' test_fullapi_cpp
returned list [] (indistinguishable) [] plus a non-ok err
sync call without err: logged no yes
async callback: logged no yes
control accepted call [1,2,3] clean + round-trips pass pass
control 3-field map with a different code is not a rejection, value survives pass pass
control echoInt(3) → 3, no error pass pass

Pre-fix: 7 failing checks. Post-fix: 0. The controls pass on both arms, so the
harness is not simply "red before, green after".

2. Unit tests. Six new MakeSourceTest cases. The four positive ones fail on the
unfixed generator
(88 QtEmitsRejectionDetector, 89 QtSyncFoldsRejectionIntoCallError,
90 QtVoidReturnStillCapturesResult, 91 QtAsyncLogsRejection) and pass after;
nix build .#tests is 224/224 green with the fix.

3. Negative control. Regenerating the same contract with --api-style lp gives
byte-identical output before and after. The comparison is not vacuous: qt vs lp
output differs on the same generator. (Note for anyone repeating this: --lidl mode
routes to experimental/lidl_gen_client.cpp and ignores --api-style — a qt-vs-lp
diff run that way is byte-identical for the wrong reason and cannot fail.)

4. Built through the real build path. nix build .#test_fullapi_qtproxy in
logos-test-modules with --override-input logos-module-builder/logos-cpp-sdk <this branch>. This caught a defect none of the checks above could: logos_sdk.cpp
textually #includes every generated <dep>_api.cpp, so a module with more than
one dependency compiles several copies of the detector into ONE translation unit —
error: redefinition of 'logosDispatchRejection'. Internal linkage does not help there;
the second commit adds a preprocessor guard, with a unit test that concatenates two
generated wrappers the way the umbrella does. A single-wrapper contract cannot reach
this, which is why generating from one contract and compiling it was not enough.

5. Real daemon, real transport, real modules. test_fullapi_qtproxy,
test_fullapi_cpp and test_fullapi_rust built from logos-test-modules with
--override-input logos-module-builder/logos-cpp-sdk <this branch>, loaded into a
logoscore daemon over LocalSocket/QtRO:

call test_fullapi_qtproxy echoUintList json:[1,2,3]    -> {"result":[1,2,3],"status":"ok"}   (no warning)
call test_fullapi_qtproxy echoUintList json:[1,-1,3]   -> {"result":[],"status":"ok"}
daemon.log: [test_fullapi_qtproxy] FullApi::echoUintList: remote call failed:
            "expected unsigned integer at arg0[1], got number"

The same three modules built without the override (pristine master generator), same
daemon, same call: the log shows the identical wrapper call sequence
(LogosAPIConsumer: Calling invokeRemoteMethod … "echoUintList"
RemoteLogosObject::callMethod) and then nothing — 0 occurrences of
remote call failed. So the new line is the fix, not the harness.

The CLI still sees [] in both arms because the proxy's forwarding method calls
p.echoUintList(v) without an err out-param and has nowhere to put one in its
QVariantList return — see the Q1 follow-up below.

Also checked: the built plugin binary carries the new code — 99 …Async: remote call failed strings post-fix vs 0 pre-fix, and 231 vs 132 warning strings overall.

Both providers produce the same envelope and both are picked up: the Rust provider
answers {"code":"dispatch_failed","message":"expected integer at arg0[1], got number","origin":"test_fullapi_rust"} and the fixed consumer reports it identically.

Follow-ups (not in this PR)

  • logos-test-modules registry entry Q1 should be updated: the consumer now reports
    the rejection, but the matrix cells hostile/[uint]/negative-element,
    hostile/[uint]/fractional-element, hostile/[int]/fractional-element still read []
    through test_fullapi_qtproxy, because the proxy's forwarding methods call
    p.echoUintList(v) without an err out-param and have nowhere to put it in their
    own return type. Making those cells answer dispatch_failed is a test-modules change
    (propagate the CallError), not a generator one.
  • logos-qt-sdk qt-generator --backend consumer emits the same shape of bug:
    return logos::qt::fromWire<QVariantList>(_r); with _err carrying only transport
    failures. Same fix, different repo.
  • The async surface still cannot deliver an error to its callback (documented in
    cpp-generator/docs/project.md); that needs an API change, not a codegen fix.

…empty value

A provider that REJECTS a call answers the canonical
{"code":"dispatch_failed", "message":..., "origin":...} object as its RESULT,
not as a transport error — the same object from every provider flavour
(logos-qt-sdk dispatchFailedVariant, the generated cdylib dispatch, the Rust
provider's args::dispatch_failed).

The generated Qt consumer wrapper converted it like any other value, which
ERASES it. `_result.toList()` on that map is `[]`, `.toString()` is "",
`.toLongLong()` is 0 — so `echoUintList([1, -1, 3])`, answered `dispatch_failed`
by the provider, reached the caller as `[]`: the whole list, not the bad
element, and indistinguishable from "the provider returned nothing". Losing the
error is a consumer bug whatever the provider did.

Reported through the channel this surface already uses for a failed call: the
`logos::CallError*` out-parameter every generated sync method carries (today
"object_unavailable" when the target cannot be acquired). No signature changes,
no return value changes — a caller that passes `err` now sees
code="dispatch_failed" with the provider's diagnostic and origin; a caller that
does not gets the same default it always got, plus the qWarning the wrapper
already emits for a failed call.

  * the detector is emitted once per wrapper, in an anonymous namespace, and
    matches EXACTLY (three string fields, that code) for the same reason
    logos_rpc_status.h's isUnauthorizedSentinel matches exactly: an `any` or map
    return carrying user data must never false-match.
  * the result is now captured for `void` returns too — a void method can be
    rejected, and the rejection object is the only place that says so.
  * the async overload's callback takes the value alone and has no error
    parameter; giving it one would change the generated public surface (which
    logos-qt-sdk's veneer mirrors 1:1), so an async rejection is logged rather
    than delivered. Recorded in cpp-generator/docs/project.md.

Scope: CONSUMER side only. The provider half of registry entry Q1 (a Qt-typed
provider cannot validate the ELEMENT of a typed numeric array, because a C++
signature spells [uint] and [any] alike as QVariantList) is explicitly out of
scope and unchanged. The lp wrapper is untouched — its generated output is
byte-identical before and after.
Copilot AI review requested due to automatic review settings July 31, 2026 21:24

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

…slation unit

`logos_sdk.cpp` textually `#include`s every generated `<dep>_api.cpp`, so a
module with more than one dependency compiles several copies of the detector
into ONE translation unit:

    test_fullapi_rust_api.cpp:15:6: error: redefinition of 'logosDispatchRejection'

Internal linkage covers the separate-TU case; only the preprocessor covers this
one. Found by building test_fullapi_qtproxy (3 wrappers: two concrete deps plus
the bound `full_api` interface) against this generator — a single-wrapper
contract cannot reach it.
@github-actions

github-actions Bot commented Jul 31, 2026

Copy link
Copy Markdown

📊 cpp-sdk doc-test report

The real accounts module, run through a logoscore daemon with the whole stack built against this commit of the C++ SDK — rendered alongside the commands actually run and their output (updated each run, commit 5b88769):

Pages can take a minute to update after the run finishes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants