Files
logos-protocol/cpp/logos_async_dispatch.h
Dario LipicarandClaude Opus 5 c0df466172 fix: integer signedness in the codec, and a shape check on the pending-call sentinel (#31)
* fix(codec): signedness and range are part of the integer type

Codec<T>::from accepted any integral JSON number and handed it to .get<T>().
That is silent in both directions:

  .get<uint64_t>() on -1   -> 18446744073709551615   (a sign flip)
  .get<int32_t>()  on 2^40 -> truncated

Both now reject with the usual path-carrying CodecError instead. Rejecting is the
codec's existing contract — a value the declared type cannot represent must not
reach business logic wearing a different one — this just extends it to the half
of the integer domain it was skipping.

Note the check is on the JSON category, not the value: a negative literal parses
as number_integer and never as number_unsigned, so `is_number_unsigned()` is the
reliable discriminator rather than a comparison after conversion.

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

* fix(async): the pending-call sentinel is matched by shape, not by key presence

All four detection sites tested `m.contains(pendingCallKey())` and nothing else,
so ANY user map carrying that key was taken for a deferred call: the consumer
extracted a call id, found no completion, and waited out a nested event loop.
The measured outcome is a ~20s HANG, not a fast failure. An `any` slot is enough
to reach it — anything a user can put in a map.

logos::isPendingCallSentinel now requires the canonical shape: exactly one entry,
under the sentinel key, holding a non-empty string. Shape and signature are
mirrored from isUnauthorizedSentinel (logos_rpc_status.h), QJsonObject arm
included — the two are the same kind of in-band marker and there was no reason
for them to be guarded differently. That guard, and isTaggedBytes's, both already
existed in this repo; the difference was chronology, not principle.

Behaviour-preserving: the generated glue builds this map with exactly one entry
whose value is a QString call id, so no real sender changes. The concurrent
dispatch tests pass unchanged.

NARROWS, DOES NOT CLOSE — and the tests say so out loud. A one-key, string-valued
forgery IS the sentinel; no predicate can separate them. It still hangs, and
because call ids are a per-object counter from 0, a forged "lc-0" can collide
with a genuine in-flight completion and steal its result. Closing that needs an
out-of-band channel for "deferred", which the single-QVariant dispatch slot
cannot express without an ABI break — the constraint is stated at
logos_rpc_status.h:24-27 and is real.

tests: 10 new, including one asserting the forgery still matches, so a future
reader cannot mistake the green cells for "the sentinel is safe". 236/236.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 11:50:18 -03:00

84 lines
3.7 KiB
C++

#ifndef LOGOS_ASYNC_DISPATCH_H
#define LOGOS_ASYNC_DISPATCH_H
#include <QJsonObject>
#include <QMetaType>
#include <QString>
#include <QVariant>
#include <QVariantMap>
// Shared wire constants for "multi" (concurrent) dispatch. Concurrency is a
// MODULE-side concern handled entirely behind the ordinary callMethod entry
// point — no new provider/host vtable method, so the provider ABI is unchanged
// and an old host/daemon loads a "multi" module and forwards its traffic
// without even understanding these markers.
//
// A "multi" module's generated glue does NOT block in callMethod: it hands the
// handler to a worker and returns a PENDING SENTINEL immediately (a QVariantMap
// carrying the call id under pendingCallKey()). When the worker finishes, the
// module pushes the real result back as a COMPLETION event
// (callCompleteEvent(), data = [callId, result]) over the SAME event channel it
// already uses (setEventListener). The host (ModuleProxy / liblogos) is a pure
// forwarder — it returns whatever callMethod returned and forwards whatever
// events the module emits. The CONSUMER transport (RemoteLogosObject for QtRO,
// PlainLogosObject for the plain transport) detects the sentinel, waits for the
// matching completion keyed by callId, and returns the real result — so
// generated clients call transparently. Both transports use this path; the
// version that speaks it is logos-protocol 0.2 (additive minor — see
// logos_protocol.h).
namespace logos {
inline QString pendingCallKey() { return QStringLiteral("__logos_pending_call__"); }
inline QString callCompleteEvent() { return QStringLiteral("__logos_call_complete__"); }
// True only for the exact pending-call sentinel, writing the call id to `callId`.
//
// The four detection sites used to test `m.contains(pendingCallKey())` with no
// shape check at all, so ANY user map that happened to carry that key was taken
// for a deferred call: the consumer then waited in a nested event loop for a
// completion that never arrives, and the call hung for the full timeout. An
// `any` slot is enough to reach it, i.e. anything a user can put in a map.
//
// The generated glue builds this map with exactly one entry whose value is a
// QString call id, so the checks below are behaviour-preserving for every real
// sender. Shape is mirrored from isUnauthorizedSentinel (logos_rpc_status.h),
// including the QJsonObject arm — the two are the same kind of in-band marker
// and there is no reason for them to be guarded differently.
//
// NARROWS, DOES NOT CLOSE. A forgery of the canonical shape — one key, string
// value — is still indistinguishable from the real thing, because that IS the
// real thing. Closing it needs an out-of-band channel for "deferred", which the
// single-QVariant dispatch slot cannot express without an ABI break.
inline bool isPendingCallSentinel(const QVariant& v, QString* callId = nullptr)
{
const QString key = pendingCallKey();
QString id;
switch (v.userType()) {
case QMetaType::QVariantMap: {
const QVariantMap m = v.toMap();
if (m.size() != 1) return false;
const QVariant held = m.value(key);
if (held.userType() != QMetaType::QString) return false;
id = held.toString();
break;
}
case QMetaType::QJsonObject: {
const QJsonObject o = v.toJsonObject();
if (o.size() != 1) return false;
const QJsonValue held = o.value(key);
if (!held.isString()) return false;
id = held.toString();
break;
}
default:
return false;
}
if (id.isEmpty()) return false;
if (callId) *callId = id;
return true;
}
} // namespace logos
#endif // LOGOS_ASYNC_DISPATCH_H