Files
logos-protocol/tests/protocol/CMakeLists.txt
Dario LipicarandClaude Opus 5 43cd059608 feat(proxy): answer name()/version() for a provider that does not (#61)
Module identity should be total: every module answers name() and version(),
whatever built it. A module generated through the LIDL frontend now has both in
its own dispatch, but that leaves the rest -- already-built .lgx packages, ui /
ui_qml plugins, any provider whose dispatch does not answer -- reporting
nothing.

Every provider already knows both, through the providerName() /
providerVersion() vtable slots LogosProviderObject has always had. ModuleProxy
answers from those, so those modules gain identity with no edit to any of them.

Two placement decisions do the work:

  * the dispatch fallback runs AFTER m_provider->callMethod. An invalid
    QVariant is that slot's "unknown method" answer, so a provider that DOES
    implement name() keeps its own result -- nothing existing changes
    behaviour. It is also gated on an empty argument list, so a module with its
    own name(which) reaches its dispatch exactly as before.
  * getPluginInterface() advertises the same two methods when the provider does
    not list them. Without this a module would ANSWER a method it claimed not
    to have: present to whoever already knew to ask, invisible to `lm` and to
    every untyped caller. Additive only -- an entry the provider already lists
    wins, keeping its description and parameters.

Identity is a method, not introspection, so it stays behind the auth gate. The
three getPlugin* calls are ungated on purpose (they precede the token
exchange); these are not.

This is the one place both transports converge -- the plain transport publishes
a ModuleProxy and reaches it through QMetaObject::invokeMethod -- so one change
covers qt_remote and plain alike.

475/475 tests pass, 6 new.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 14:50:26 -03:00

412 lines
25 KiB
CMake

find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Test)
# ── which of these tests are detectors, and how that was checked ─────────────
#
# A race assertion that has never been seen to fail is not evidence. Three of the
# guarantees in this suite are single points of mechanism — the exactly-once CAS
# in AsyncCall::deliver(), the deadline living on its own thread rather than the
# shared io thread, and the serialization that keeps a concurrent first caller
# from outrunning the completion subscription — so each was checked by running
# the tests against code that does not have it.
#
# That check is done on the PRE-FIX TREE, not on a switch in this one: copy the
# test file onto the commit the fix replaced and run it there. For the completion
# subscription that tree is `master`, which still has both the racy
# ensureCompletionSub() and the raw-`this` handler capture; for the fold's two
# mechanisms it is a local edit to the transport, made in a throwaway checkout
# and thrown away with it. The numbers each run produced are recorded in the test
# files themselves.
#
# Deliberately not a build option, and not the environment-variable probes an
# earlier draft carried. Both have the same cost, which the getenv() version only
# made more obvious: production source ends up holding a second implementation of
# its own contract whose entire purpose is to be wrong, in a change whose entire
# purpose is correctness. Running the real pre-fix code is also better evidence,
# because it proves the test catches the bug that existed rather than a
# hand-written imitation of it.
#
# WHICH TESTS ARE DETECTORS, since it is not all of them and the difference
# matters when one goes green. Only these fail on code missing the mechanism:
#
# IoFoldTest.ReleaseRacingRepliesInFlightDeliversEachCallOnce
# IoFoldTest.DeadlineFiresOnTimeWhileTheIoThreadIsBusy
# IoFoldTest.DeadlineFiresWhileTheIoThreadIsBlockedForever
# PlainCompletionSubOrderTest.AConcurrentFirstCallCannotOutrunTheSubscription
# PlainCompletionSubOrderTest.TheSameRaceThroughTheRealHost
# PlainCancelPendingRaceTest.AnExtractedReplyRacingTeardownDeliversExactlyOnce
# PlainSendAfterFailTest.* (all four; validated on cf1b9b0)
# PlainEventSubSharingTest.ASecondHandleDoesNotStealTheFirstsCompletionChannel
# PlainEventSubSharingTest.TheSameTheftThroughTheRealHost
# PlainEventSubSharingTest.EveryHandleGetsEveryEmission
# PlainEventSubSharingTest.ANamedAndAWildcardSubscriberEachGetOneCopy
# PlainEventSubSharingTest.ReleasingOneHandleLeavesTheOtherSubscribed
# PlainEventSubSharingTest.SubscriptionsSurviveChurn
# PlainEventSubSharingTest.ACompletionThatOvertakesItsOwnResultIsStillDelivered
# PlainEventSubSharingTest.ANewConsumerAgainstAnOldHost
# PlainEventSubSharingTest.AnOldConsumersFrameSequenceAgainstTheNewHost
# SyncCallReleaseRaceTest.ReleaseWithASyncCallInFlightIsReportedAndFatal
# SyncCallReleaseRaceTest.AParkedSyncCallSurvivesReleaseFromAnotherThread
# SyncCallReleaseRaceTest.ReleaseDefersDestructionToTheLastCallInFlight
# SyncCallReleaseRaceTest.TheReleaseWakeRaceIsSafeEveryTime (under gmalloc)
# SyncCallReleaseRaceTest.DeletingTheHandleWithACallInFlightIsReportedAndFatal
# SyncCallReleaseRaceTest.StartingACallAfterReleaseIsReportedAndFatal
# SyncCallReleaseRaceTest.EveryEntryPointLeavesTheCountAtZero
# SyncCallReleaseRaceTest.ConcurrentCallersThatHaveReturnedAreNotAccused
# SyncCallReleaseRaceTest.ReleaseFromInsideAnEventCallbackIsNotAccused
# every test in NoQtLoopTest (protocol_noqt_tests — see the second executable
# at the bottom of this file), including the after-main() probe whose verdict
# is that binary's EXIT CODE
#
# The release-race group is three detectors of three different things, which is
# why all of it is listed. THE FIX: on cf1b9b0 the survival test's child dies by
# signal ("died but not with expected exit code") and the deferred-destruction
# test exits 139, both three runs out of three; the release-wake stress is silent
# there without Guard Malloc and 139 with it, which is why it is marked. THE
# REMAINING MISUSES: the two death tests fail on cf1b9b0 (no diagnostic exists to
# match). A BROKEN DETECTOR: with the decrement removed from EntryGuard's
# destructor the three "not accused" tests abort on a correct program, which is
# the failure mode that would make the whole thing unshippable. Every one of
# those was checked by running it — against the pre-fix commit for the first two
# groups, against a throwaway local edit for the last.
# PlainHostEventTeardownTest.QueuedEmissionsNeverOutliveTheHost
#
# The last one is a detector for the HARNESS rather than for the transport, and
# it is checked the same way: put a plain `m_host.reset()` back in its fixture
# and it takes the process down with SIGSEGV on the first round, with or without
# a memory detector. The backtrace it produces is recorded in the file.
#
# The subscription-sharing group is nine detectors of ONE mechanism: every handle
# holding its own registration on the shared connection. All nine were run RED on
# the pre-fix tree — where the second handle to subscribe to an event on a
# connection silently takes the first one's channel — and green here; the tenth
# case in that file is a pin, and is named below.
#
# Note what is NOT on that list: the PER-PATH exactly-once tests stay GREEN with
# the CAS removed. They are PINS, NOT DETECTORS — a call resolved once calls
# deliver() once no matter what guards it — and only racing teardown against
# replies in flight (the first entry) and the hand-built extract-then-cancel
# interleaving (the last) actually catch a doubled callback. Do not read a green
# per-path test as evidence the gate is there. Nor is
# PlainEventSubSharingTest.TeardownRemovesEverySubscription, which passes on the
# pre-fix tree: only the tests that assert a SECOND live subscriber still has its
# channel detect the subscription clobber.
#
# AND DO NOT READ ReleaseRacingRepliesInFlightDeliversEachCallOnce AS COVERING
# EVERY DOUBLE. It races teardown of the HANDLE against replies with the
# connection still up, so it never enters sendCallAsync's stopped branch: against
# a deliberately broken version of that branch it reports 0 doubles in 10,000
# calls while PlainSendAfterFailTest's own 10,000-call burst — which stops the
# CONNECTION mid-burst — reports 5 to 10. Same shape, different contended object,
# and only the second one sees that path.
# AND "THE CAS" IS TWO GATES, which is a correction to the paragraph above and
# was found while re-validating this on the no-Qt binary. 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.
# Removing the CAS ALONE leaves every test here green (0 doubles) because the
# swap absorbs the duplicate; removing BOTH makes the release-race shape report
# 22 doubled deliveries in 20 rounds x 500 calls while the per-path tests stay
# green. Measured on the no-Qt twin
# (NoQtLoopTest.ReleaseRacingRepliesDeliversEachCallOnceWithNoQtLoop). A
# validation that removes one of the two proves nothing.
add_executable(protocol_tests
test_main.cpp
# New protocol-level tests
test_protocol_version.cpp
test_json_convert_bytes.cpp
# The `result` half of the canonical converter pair: qvariantToNlohmann has
# always owned LogosResult -> JSON; jsonToLogosResult is its inverse, and
# the state it exists to protect is an ABSENT error (which no
# std::string-typed intermediate can carry).
test_json_convert_result.cpp
test_universal_provider_dispatch.cpp
# Event payload fidelity across the universal -> Qt bridge
# (setEventListenerStdBridge). Every Qt-free provider emits through that one
# conversion; test_universal_provider_dispatch references it only to satisfy
# the pure virtual and asserts nothing about payloads.
test_event_payload_fidelity.cpp
test_lp_client.cpp
# The event-delivery regression matrix: transport x provider kind x consumer
# path x timing. Event delivery has broken silently three times, each in a
# cell nothing covered — a one-shot subscription, a deferred one that never
# armed outside Remote mode, and a plain host dropping a Subscribe for a
# not-yet-published object. Every case here has a control that is green
# independently of those fixes.
test_event_delivery_matrix.cpp
# The canonical LIDL <-> JSON codec (cpp/logos_codec.h): one implementation
# of an encoding that used to exist six times with divergent semantics.
# Pins the contract — accepted shapes, rejected shapes, and bytes staying
# tagged at any nesting depth.
test_codec.cpp
# Owner-thread anchoring for lp clients: a client created from a worker
# thread (the generated bind_<iface>() wrapper is lazy, so the first caller
# wins) must still be serviced by the Qt main thread's event loop instead of
# falling out on replica-acquire timeouts.
test_lp_client_owner_thread.cpp
test_call_error.cpp
# The ASYNC half of the same channel. lp_invoke_async subscribed with the
# value-only transport overload and hard-coded cb(1, ...), so a failed call
# reached lp_result_cb as a success carrying a default value — the exact
# thing lp_result_cb's own doc comment (and its sync twin lp_invoke) says
# cannot happen. Matched pair: a failing call must report ok=0 with the
# canonical error object, a succeeding one must still report ok=1 with its
# value. Both over a real (plain TCP) transport.
test_lp_invoke_async_error.cpp
# The rest of that channel: everything the TRANSPORT learns once acquire has
# already succeeded — a timeout, and MODULE_NOT_LOADED against a host that is
# up (which is not an acquire failure on the plain transport, because
# requestObject never checks publication). Both were discarded by
# PlainLogosObject and by the hard-coded empty CallError above it, on the
# SYNC path as much as the async one. Matched set: the two failures must
# report the canonical error object, the successes must still report their
# value, and the one case that genuinely cannot be fixed here (an unknown
# method, which every provider answers with a bare null) is pinned as-is.
test_call_error_after_acquire.cpp
# The same channel over the DEFAULT transport (LocalSocket/QtRO), so the fix
# is not silently plain-TCP-only. Exercises the deferred-completion timeout,
# which needs no blocking provider and therefore no second event loop.
test_call_error_qt_remote.cpp
# Teardown of a PlainLogosObject with a call still IN FLIGHT. Written when
# in-flight calls were threads: joining them closed a use-after-free but left
# release() blocking for the remainder of the call's timeout, so they were
# asked to stop first and teardown cost one 25ms wait slice. Public API only,
# so the io_context fold did not touch it — but its BOUNDS are now loose
# rather than tight: there is no wait slice any more (0ms, measured) and no
# join, because nothing captures `this`. What it still pins exactly is the
# part that matters most: the callback fires EXACTLY ONCE on every outcome
# including cancellation, since a dropped one turns a stall into a hang.
test_plain_object_teardown.cpp
# The other half of the same mechanism: what a COMPLETED call leaves behind.
# Written for the waiter registry (a map of threads plus a publish list) and
# retargeted by the io_context fold at the registry that replaced it,
# CallState::inflight. The claims are unchanged and are why it survived the
# rework: retention must not grow with call count, a burst that goes idle
# must drain with no further call, and teardown must still deliver exactly
# once.
test_plain_waiter_reaping.cpp
# test_plain_waiter_publish_is_last.cpp — DELETED BY THE io_context FOLD.
# It pinned one rule: publishFinishedWaiter() is a waiter thread's LAST
# access to the object, which was the only reason stopAndJoinWaiters() could
# return while a reaper was still mid-join on a waiter it had already taken
# out of m_waiters. There are no waiter threads, no reaper and no publish
# list any more, so there is no ordering left to pin — the property it
# protected ("no handler touches the object after teardown") is structural
# now: handlers hold a weak_ptr to CallState and never dereference the
# handle. test_iofold.cpp OBSERVES that instead of pinning the protocol that
# used to be needed for it.
test_iofold.cpp
# The OTHER thing release() leaves behind: the deferred-completion event
# subscription. Its handler captured raw `this` and lives in the
# RpcConnection, which is shared across every handle and outlives all of
# them. dispatchIncoming copies the handler out under its mutex and invokes
# it unlocked, so the unsubscribe release() sends cannot reach one already in
# flight on the io thread — and nothing joins that thread. Pins the handler
# not reaching the object at all.
test_plain_completion_sub_lifetime.cpp
# The ORDERING half of the same subscription. ensureCompletionSub() raised
# its flag under the rendezvous mutex and released the mutex before
# subscribing, so a concurrent first caller could put a Call on the wire
# ahead of the Subscribe frame; a "multi" provider then completed into a
# subscription the host had not registered and the completion was dropped.
# Costs a caller its whole timeout and leaves nothing behind — see the file.
test_plain_completion_sub_order.cpp
# A THIRD thing release() and the shared RpcConnection get wrong together:
# m_eventCallbacks is keyed by (object, event) and ASSIGNED, so the second
# handle a connection hands out for the same module silently takes the
# first one's subscription — the completion channel included. Reachable with
# no concurrency at all. Covers the theft over the raw wire and through the
# shipping host, every-handle-gets-every-emission, one handle leaving without
# taking another's subscription with it, and randomized churn with a quiesced
# per-round audit.
test_plain_event_sub_sharing.cpp
# cancelPending() and the two things it is NOT. dispatchIncoming copies a
# handler out under the connection mutex and invokes it unlocked, so a
# cancel arriving in that gap stops nothing — at-most-once INVOCATION is the
# extract-and-erase, and exactly-once DELIVERY is AsyncCall::deliver()'s CAS.
# Constructs the interleaving by hand rather than racing for it, and covers
# the OTHER handler shape (the promise sendCall registers) too.
test_plain_cancel_pending_race.cpp
# The gap between sendCallAsync's stopped-check and its registration under
# m_mu. A fail() that completes in it sweeps a map the caller has not
# written to yet, so the handler lands on a connection nobody will ever
# sweep again and the call is answered by NOTHING — the caller waits out its
# whole deadline and is then told "timeout" for a connection that was
# already gone. Predates the fold (master has it on the promise path).
# Builds the interleaving by parking the caller on the connection's own
# mutex rather than racing for it.
test_plain_send_after_fail.cpp
# release() racing a call still running on another thread — a PRE-EXISTING
# use-after-free (reproduced identically on master, on #45 and on #46), now
# FIXED by a live-reference count: release() drops the owner's reference and
# the last call to leave destroys the object, so a call that entered first is
# safe and release() still waits for nothing. This file pins the fix (the
# reproduction survives, destruction is deferred and happens exactly once,
# the tight release-wake race 400 times over), pins the two shapes that are
# still caller errors (`delete obj` with a call in flight, and STARTING a
# call after release()) as named aborts rather than a SIGSEGV elsewhere, and
# — the half that decides whether a diagnostic is shippable at all — pins
# that a correct program is never accused: every entry point, every
# early-return path, many threads at once, and the shipped
# release()-from-an-io-thread-event-callback shape.
test_sync_call_release_race.cpp
# A test of the HARNESS the five tests above run on. Each of them puts a
# ModuleProxy on a worker QThread and publishes it through a
# PlainTransportHost, and each of them used to free that host from the TEST
# thread — while the worker was inside the direct-connection lambda
# publishObject() hooked onto eventResponse, which locks the host's m_mu
# after converting the payload. Shared fixture code, so a fault there can
# corrupt any run in this binary. Pins the ordering that live_host_teardown.h
# now owns: the host is destroyed ON the proxy's thread, every emission
# queued before that runs against a live host, and none runs after.
test_plain_host_event_teardown.cpp
# Component tests that moved here with their code (from logos-cpp-sdk)
test_token_manager.cpp
test_mock_store.cpp
test_mock_transport.cpp
test_logos_mode.cpp
test_logos_instance.cpp
test_plugin_registry.cpp
test_transport_factory.cpp
test_registry_factory.cpp
test_rpc_framing.cpp
test_json_codec.cpp
test_cbor_codec.cpp
# uint64 across the plain wire. RpcValue had no unsigned alternative, so a
# value above int64max wrapped to -1 independently in each direction; no
# plain-tier test used an integer outside int32 range. Also pins the
# off-by-one in the QJsonValue::Double -> int64 guard.
test_plain_uint64.cpp
# Qt-free socket-access + stale-socket reaper helpers (multi-user local
# transport): applySocketPerms group/mode policy, isSocketDead predicate,
# reapStaleSockets never touching live sockets or regular files.
test_socket_paths.cpp
# Per-module concurrent dispatch (concurrency:"multi") over the plain transport.
test_concurrent_dispatch.cpp
# The token-only handshake surface published before a module's initializer.
test_handshake_surface.cpp
test_handshake_surface_transport.cpp
# The trust-root grant and the two host services it gates. The grant has to
# be recorded in the SAME image as the gate that reads it (a host binary and
# a module cdylib each link their own copy of this library), which is the
# property a "simplification" would quietly break; these cases fail loudly
# if the gates stop being closed by default.
test_host_services_grant.cpp
# The in-band pending-call sentinel's shape check. Detection used to be a
# bare contains(), so any user map carrying the key hung the call for the
# full timeout. Includes what the guard does NOT close.
test_pending_sentinel.cpp
# Teardown race: fail() must close the socket on the connection's strand,
# not on the caller's thread — a caller-thread close ran concurrently with
# the io thread initiating async_write and segfaulted inside asio's
# reactor. Also guards the fix against leaking descriptors or hanging.
test_rpc_connection_teardown.cpp
# The acceptor half of the same race: RpcServer::stop() closed m_acceptor on
# the caller's thread while doAccept() re-armed async_accept on the io
# thread. Also guards that arming the first accept on the strand (rather
# than inline in start()) still serves a client that connects immediately.
test_rpc_server_teardown.cpp
# Event delivery + reentrant-release teardown over the qt_remote (LocalSocket)
# transport — the path a deferred "multi" completion event takes, and where the
# refresh_balances onClientRead re-entrancy crash lived.
test_remote_transport_events.cpp
test_deferred_subscription.cpp
# Token caching: regression for the IPC token-rotation race that surfaced
# on Linux when storage_ui issued back-to-back sync invokeRemoteMethod
# calls. Pre-fix the client re-mints per call and the target's per-caller
# token slot is overwritten, causing earlier in-flight calls to be
# rejected as unauthorized. Post-fix exactly one handshake fires per
# (client, target) pair.
test_token_cache.cpp
# Rejection-driven token re-exchange: when a provider rejects a call for a
# stale/unrecognized token it returns the structured sentinel
# (logos_rpc_status.h); the client drops the cached token, re-runs
# requestModule and retries once. Covers success, the false-positive guard
# (a legit empty return must NOT re-exchange), bounded retry (no loop), and
# the async path.
test_token_reexchange.cpp
# Per-identity token stores. The escalation being closed is that a host
# loading plugins in-process gives all of them ONE store holding every
# module's root token, and a client presents a cached token before it ever
# mints one — so a plugin reaches an undeclared backend with no handshake at
# all, and per-plugin origin STRINGS change nothing because origin was never
# consulted. Half the file pins that forIdentity() returns the SAME OBJECT
# instance() does until a name is explicitly isolated (the whole
# back-compatibility argument); the other half pins that isolated identities
# cannot see each other's or the ring's tokens. Every escalation case carries
# an ambient CONTROL asserting the token IS reachable without isolation —
# without that control the isolated assertions would pass on a store that
# never held the token in the first place.
test_token_store_identity.cpp
# Module identity: name()/version() answered by the proxy from
# providerName()/providerVersion() for a provider whose own dispatch does
# not, so a legacy module gains them with no edit. Pins the two properties
# the design rests on -- the fallback runs only after dispatch returned
# "unknown method" (no silent shadowing), and the listing agrees with what
# is callable -- plus the auth gate, which introspection deliberately lacks.
test_module_identity.cpp
# test_plain_transport_tcp.cpp — entirely #if 0 (see the note inside:
# in-process Qt-event-loop deadlock between consumer + provider under
# nix's test sandbox; exercised cross-process by the integration matrix).
)
target_include_directories(protocol_tests PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}
)
target_link_libraries(protocol_tests PRIVATE
logos_protocol
GTest::gtest
Qt${QT_VERSION_MAJOR}::Core
Qt${QT_VERSION_MAJOR}::RemoteObjects
Qt${QT_VERSION_MAJOR}::Test
)
gtest_discover_tests(protocol_tests)
# ── the Qt-LOOP-FREE half ────────────────────────────────────────────────────
#
# A SECOND executable, for one reason: QCoreApplication is a process-wide
# singleton and protocol_tests' main() constructs it before the first test runs,
# so no test in that binary can ever observe the state a Qt-free host process is
# permanently in — instance() == nullptr. That state is where the async delivery
# hop dropped every callback on the floor, and a test that cannot reach it
# cannot detect it. Destroying the application partway through the main run to
# get there would be a far worse trade than one more binary.
#
# Everything here links the same library and exercises the same code paths; only
# main() differs. See test_main_noqt.cpp.
add_executable(protocol_noqt_tests
test_main_noqt.cpp
# The async delivery hop, in a process with no Qt event loop. It read
# QCoreApplication::instance() and RETURNED when it was null, so in a
# Qt-free host — the deployment the plain transport exists for — every
# async callback was dropped: exactly-once became exactly-never, silently,
# on all four resolvers (reply, deferred completion, deadline,
# cancellation). Pins that each of them now lands, that none of them lands
# inline or on the io thread (which is the re-entrancy class that has
# already cost a SIGSEGV here, so a "just call it inline" fix would be
# worse than the bug), and that exactly-once survives the release-racing-
# replies shape against the new delivery vehicle.
#
# It also carries a probe that runs AFTER main() returns — a delivery issued
# from a static destructor, which is where a lazily-constructed delivery
# vehicle is already destroyed. Its verdict is this binary's EXIT CODE (70 on
# failure), because no test case runs in that window; see LateDeliveryProbe.
# That is also why a failure there shows up as a build-time error from
# gtest_discover_tests, which runs this binary to enumerate its tests.
test_delivery_without_qt.cpp
)
target_include_directories(protocol_noqt_tests PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}
)
target_link_libraries(protocol_noqt_tests PRIVATE
logos_protocol
GTest::gtest
Qt${QT_VERSION_MAJOR}::Core
Qt${QT_VERSION_MAJOR}::RemoteObjects
)
gtest_discover_tests(protocol_noqt_tests)