diff --git a/bazel_common/score_modules_target_sw.MODULE.bazel b/bazel_common/score_modules_target_sw.MODULE.bazel index 0b8024c37a6..4129117d7cd 100644 --- a/bazel_common/score_modules_target_sw.MODULE.bazel +++ b/bazel_common/score_modules_target_sw.MODULE.bazel @@ -35,6 +35,7 @@ git_override( patch_strip = 1, patches = [ "//patches/communication:001-expose-comm-examples.patch", + "//patches/communication:002-expose-com-api-gen.patch", "//patches/communication:003-module-deps-visibility.patch", "//patches/communication:004-rules-build-error-not-dev-dependency.patch", "//patches/communication:005-migrate-baselibs-rust-targets.patch", diff --git a/feature_integration_tests/itf/BUILD b/feature_integration_tests/itf/BUILD index 35dc1d33e6c..0e0d04ef5c4 100644 --- a/feature_integration_tests/itf/BUILD +++ b/feature_integration_tests/itf/BUILD @@ -30,12 +30,23 @@ config_setting( ], ) +# Grouped per suite so future additions don't mix into one undifferentiated +# list; all_tests below is the union of these groups plus shared utilities. +filegroup( + name = "communication_tests", + srcs = [ + "comm_helpers.py", + "test_communication.py", + ], +) + filegroup( name = "all_tests", srcs = [ "test_remote_logging.py", "test_showcases.py", "test_ssh.py", + ":communication_tests", ], ) @@ -82,7 +93,9 @@ py_itf_test( ], }, no_match_error = "Unsupported platform", - ), + ) + [ + "@score_itf//score/itf/plugins:attribute_plugin", + ], tags = [ "manual", ], diff --git a/feature_integration_tests/itf/comm_helpers.py b/feature_integration_tests/itf/comm_helpers.py new file mode 100644 index 00000000000..1b638025b59 --- /dev/null +++ b/feature_integration_tests/itf/comm_helpers.py @@ -0,0 +1,301 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +"""Helpers for the communication (LoLa / mw::com) ITF tests.""" + +from __future__ import annotations + +import logging +import re +from dataclasses import dataclass + +logger = logging.getLogger(__name__) + +FIT_SENDER_BIN = "/showcases/bin/fit_sender" +FIT_RECEIVER_BIN = "/showcases/bin/fit_receiver" +FIT_SENDER_CC_BIN = "/showcases/bin/fit_sender_cc" +FIT_RECEIVER_CC_BIN = "/showcases/bin/fit_receiver_cc" +COMM_CWD = "/showcases/data/comm" +DEFAULT_MANIFEST = "/showcases/data/comm/etc/mw_com_config.json" + +RECV_SAMPLE_RE = re.compile(r"FIT_RECV seq=(\S+)") +FOUND_SERVICE_MARKER = "FIT_FOUND_SERVICE" +RECV_DONE_MARKER = "FIT_RECV_DONE" + + +@dataclass +class CommResult: + """Captured output of one producer/consumer exchange.""" + + recv_exit_code: int + recv_stdout: str + send_stdout: str + + # --- oracles --------------------------------------------------------- + @property + def received_samples(self) -> list[int]: + """Ordered sequence numbers received; non-integer tokens fail (corruption guard).""" + tokens = RECV_SAMPLE_RE.findall(self.recv_stdout) + for token in tokens: + assert token.isdigit(), f"non-integer sequence token (corruption): {token!r} in {tokens}" + return [int(token) for token in tokens] + + @property + def found_service(self) -> bool: + """True if service discovery located the offered instance.""" + return FOUND_SERVICE_MARKER in self.recv_stdout + + @property + def completed(self) -> bool: + """True if the receiver consumed all requested samples and exited cleanly.""" + return RECV_DONE_MARKER in self.recv_stdout + + +def _shell_exit_marker(out: str, marker: str = "RECV_EXIT") -> int: + """Extract ``=`` printed by the exchange command.""" + m = re.search(rf"{marker}=(-?\d+)", out) + if m is None: + raise AssertionError(f"Could not find {marker}= in command output:\n{out}") + return int(m.group(1)) + + +def run_event_exchange( + target, + send_cycles: int = 50, + recv_cycles: int = 10, + interval_ms: int = 100, + startup_delay_s: int = 1, + manifest: str | None = None, + instance_specifier: str | None = None, + sender_bin: str | None = None, + receiver_bin: str | None = None, +) -> CommResult: + """Run one producer/consumer exchange on ``target`` and return parsed output.""" + manifest_path = manifest or DEFAULT_MANIFEST + instance_arg = f" -i {instance_specifier}" if instance_specifier else "" + sender = sender_bin or FIT_SENDER_BIN + receiver = receiver_bin or FIT_RECEIVER_BIN + send_log = "/tmp/fit_send.log" + recv_log = "/tmp/fit_recv.log" + + command = ( + f"cd {COMM_CWD} && rm -f {send_log} {recv_log} && " + f"{{ {sender} -n {send_cycles} -t {interval_ms} -s {manifest_path}{instance_arg} " + f"> {send_log} 2>&1 & SEND_PID=$!; }} && " + f"sleep {startup_delay_s} && " + f"{receiver} -n {recv_cycles} -t {interval_ms} -s {manifest_path}{instance_arg} " + f"> {recv_log} 2>&1 ; RECV_RC=$? ; " + f"kill $SEND_PID 2>/dev/null ; wait $SEND_PID 2>/dev/null ; " + f"echo RECV_EXIT=$RECV_RC" + ) + logger.info("running command:\n%s", command) + _, out = target.execute(command) + recv_exit_code = _shell_exit_marker(out.decode(errors="replace")) + + _, recv_bytes = target.execute(f"cat {recv_log}") + _, send_bytes = target.execute(f"cat {send_log}") + + return CommResult( + recv_exit_code=recv_exit_code, + recv_stdout=recv_bytes.decode(errors="replace"), + send_stdout=send_bytes.decode(errors="replace"), + ) + + +TRUNCATED_CONFIG_JSON = '{ "serviceTypes": [ { "serviceTypeName"' +SCHEMA_INVALID_CONFIG_JSON = "{}" +# Distinct instance specifier so a misconfigured receiver cannot latch onto leftover SHM state. +NEGATIVE_CONFIG_INSTANCE_SPECIFIER = "/Vehicle/ServiceNegativeConfigTest/Instance" + + +def write_remote_file(target, path: str, content: str) -> None: + """Write ``content`` to ``path`` on the target via a quoted heredoc.""" + heredoc = f"cat > {path} <<'COMM_FIT_EOF'\n{content}\nCOMM_FIT_EOF\n" + logger.info("writing remote file: %s", path) + exit_code, out = target.execute(heredoc) + if exit_code != 0: + raise AssertionError(f"Failed to write {path} on target: {out!r}") + + +def run_receiver_with_manifest( + target, + manifest_path: str, + cycles: int = 3, + interval_ms: int = 100, + max_polls: int = 10, + timeout_s: int = 10, + instance_specifier: str = NEGATIVE_CONFIG_INSTANCE_SPECIFIER, + receiver_bin: str | None = None, +) -> CommResult: + """Run only the receiver against ``manifest_path``; no producer started (negative-config scenarios).""" + receiver = receiver_bin or FIT_RECEIVER_BIN + recv_log = "/tmp/fit_recv_negative.log" + command = ( + f"cd {COMM_CWD} && rm -f {recv_log} && " + f"timeout {timeout_s} {receiver} -n {cycles} -t {interval_ms} " + f"--max-polls {max_polls} -s {manifest_path} -i {instance_specifier} " + f"> {recv_log} 2>&1 ; echo RECV_EXIT=$?" + ) + logger.info("running command:\n%s", command) + _, out = target.execute(command) + recv_exit_code = _shell_exit_marker(out.decode(errors="replace")) + _, recv_bytes = target.execute(f"cat {recv_log}") + return CommResult( + recv_exit_code=recv_exit_code, + recv_stdout=recv_bytes.decode(errors="replace"), + send_stdout="", + ) + + +ASILB_INSTANCE_SPECIFIER = "/Vehicle/ServiceAcl/Instance" + +# ACL denial surfaces as a panic in the generated bindings (ProxyCreationFailed), +# not as an Err from build() — so we match LoLa's own log strings, not just exit code. +ACL_DENIAL_MARKERS = ( + "FIT_RECV_PROXY_BUILD_ERROR", + "Could not create Proxy", + "ProxyCreationFailed", +) +DENIED_CONSUMER_UID = 64000 + +ASILB_CONFIG_TEMPLATE = """{{ + "serviceTypes": [ + {{ + "serviceTypeName": "/bmw/adp/VehicleInterface", + "version": {{"major": 1, "minor": 0}}, + "bindings": [ + {{ + "binding": "SHM", + "serviceId": 6433, + "events": [ + {{"eventName": "left_tire", "eventId": 1}}, + {{"eventName": "exhaust", "eventId": 2}} + ] + }} + ] + }} + ], + "global": {{"applicationID": 4099, "asil-level": "B"}}, + "serviceInstances": [ + {{ + "instanceSpecifier": "{instance_specifier}", + "serviceTypeName": "/bmw/adp/VehicleInterface", + "version": {{"major": 1, "minor": 0}}, + "instances": [ + {{ + "instanceId": 9, + "allowedConsumer": {{"B": [{allowed_uid}], "QM": [{allowed_uid}]}}, + "allowedProvider": {{"B": [{allowed_uid}], "QM": [{allowed_uid}]}}, + "asil-level": "B", + "binding": "SHM", + "events": [ + {{"eventName": "left_tire", "numberOfSampleSlots": 30, "maxSubscribers": 5}}, + {{"eventName": "exhaust", "numberOfSampleSlots": 30, "maxSubscribers": 5}} + ] + }} + ] + }} + ] +}}""" + + +def render_asilb_config(allowed_uid: int) -> str: + """ASIL-B manifest granting only ``allowed_uid`` (not DENIED_CONSUMER_UID).""" + return ASILB_CONFIG_TEMPLATE.format( + instance_specifier=ASILB_INSTANCE_SPECIFIER, + allowed_uid=allowed_uid, + ) + + +def discover_default_uid(target) -> int: + """The real UID `target.execute` commands run as by default.""" + _, out = target.execute("id -u") + return int(out.decode(errors="replace").strip()) + + +def is_qnx(target) -> bool: + """Same detection approach as test_remote_logging.py's `_is_qnx`.""" + _, out = target.execute("uname -s") + return b"QNX" in out + + +def as_uid_prefix(target, uid: int) -> str: + """ + Shell prefix that re-executes the following command under ``uid``. + + QNX: `on -u :` needs no /etc/passwd entry. + Linux: `setpriv` needs CAP_SETUID (i.e. running as root), the same + privilege level the Linux ITF container already runs at. + """ + if is_qnx(target): + return f"on -u {uid}:{uid} " + return f"setpriv --reuid={uid} --regid={uid} --clear-groups " + + +def run_acl_isolation_scenario( + target, + send_cycles: int = 40, + recv_cycles: int = 8, + interval_ms: int = 150, + startup_delay_s: int = 1, +): + """ + Run an ASIL-B producer, one consumer with an allowed UID, and one + consumer with a UID deliberately excluded from the ACL, all running + concurrently against the same instance. + + Returns (allowed_result, denied_result, allowed_uid). + """ + allowed_uid = discover_default_uid(target) + manifest_path = "/tmp/comm_fit_asilb_config.json" + write_remote_file(target, manifest_path, render_asilb_config(allowed_uid)) + + send_log = "/tmp/fit_acl_send.log" + allowed_log = "/tmp/fit_acl_recv_allowed.log" + denied_log = "/tmp/fit_acl_recv_denied.log" + denied_prefix = as_uid_prefix(target, DENIED_CONSUMER_UID) + + command = ( + f"cd {COMM_CWD} && rm -f {send_log} {allowed_log} {denied_log} && " + f"chmod 666 {manifest_path} && " + f"{{ {FIT_SENDER_BIN} -n {send_cycles} -t {interval_ms} -s {manifest_path} " + f"-i {ASILB_INSTANCE_SPECIFIER} > {send_log} 2>&1 & SEND_PID=$!; }} && " + f"sleep {startup_delay_s} && " + f"( {FIT_RECEIVER_BIN} -n {recv_cycles} -t {interval_ms} -s {manifest_path} " + f"-i {ASILB_INSTANCE_SPECIFIER} > {allowed_log} 2>&1 & ALLOWED_PID=$! ; " + f"{denied_prefix}timeout 8 {FIT_RECEIVER_BIN} -n {recv_cycles} -t {interval_ms} " + f"--max-polls 20 -s {manifest_path} -i {ASILB_INSTANCE_SPECIFIER} " + f"> {denied_log} 2>&1 ; DENIED_EXIT=$? ; " + f"wait $ALLOWED_PID ; ALLOWED_EXIT=$? ; " + f"echo ALLOWED_EXIT=$ALLOWED_EXIT ; echo DENIED_EXIT=$DENIED_EXIT ) ; " + f"kill $SEND_PID 2>/dev/null ; wait $SEND_PID 2>/dev/null" + ) + logger.info("running command:\n%s", command) + _, out = target.execute(command) + decoded = out.decode(errors="replace") + allowed_exit = _shell_exit_marker(decoded, "ALLOWED_EXIT") + denied_exit = _shell_exit_marker(decoded, "DENIED_EXIT") + + _, allowed_bytes = target.execute(f"cat {allowed_log}") + _, denied_bytes = target.execute(f"cat {denied_log}") + + allowed_result = CommResult( + recv_exit_code=allowed_exit, + recv_stdout=allowed_bytes.decode(errors="replace"), + send_stdout="", + ) + denied_result = CommResult( + recv_exit_code=denied_exit, + recv_stdout=denied_bytes.decode(errors="replace"), + send_stdout="", + ) + return allowed_result, denied_result, allowed_uid diff --git a/feature_integration_tests/itf/test_communication.py b/feature_integration_tests/itf/test_communication.py new file mode 100644 index 00000000000..c3e15ac23bb --- /dev/null +++ b/feature_integration_tests/itf/test_communication.py @@ -0,0 +1,443 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +""" +ITF tests for the communication module (LoLa / mw::com). + +Scope covers delivery, ordering, integrity, service discovery, runtime +deployment config, late join, deployment-config integrity (negative +scenarios), and ASIL-B/ACL isolation. +""" + +import json +import logging + +import comm_helpers as comm +import pytest +from attribute_plugin import add_test_properties # type: ignore[import-untyped] + +logger = logging.getLogger(__name__) + + +def test_fit_binaries_are_deployed(target): + """The communication sender and receiver binaries are present in the image.""" + for binary in ( + comm.FIT_SENDER_BIN, + comm.FIT_RECEIVER_BIN, + comm.FIT_SENDER_CC_BIN, + comm.FIT_RECEIVER_CC_BIN, + ): + exit_code, _ = target.execute(f"test -f {binary}") + assert exit_code == 0, f"{binary} not found on target" + + +@add_test_properties( + partially_verifies=[ + "feat_req__com__event_type", + "feat_req__com__producer_consumer", + "feat_req__com__interfaces", + "feat_req__com__data_driven_arch", + "feat_req__com__service_discovery", + ], + test_type="requirements-based", + derivation_technique="requirements-analysis", +) +def test_event_exchange_delivers_data(target): + """ + Verify that a producer and consumer, running as two separate processes, + exchange cyclic events over shared memory: the consumer discovers the + offered service, receives samples, and exits cleanly. + """ + result = comm.run_event_exchange(target, send_cycles=50, recv_cycles=10) + logger.info("consumer stdout:\n%s", result.recv_stdout) + + assert result.recv_exit_code == 0, "consumer did not exit cleanly" + assert result.found_service, "service discovery did not locate the provider" + assert result.received_samples, "consumer received no samples" + assert result.completed, "consumer did not report a clean completion" + + +@add_test_properties( + partially_verifies=[ + "feat_req__com__multi_lang", + "feat_req__com__event_type", + "feat_req__com__producer_consumer", + ], + test_type="requirements-based", + derivation_technique="requirements-analysis", +) +def test_cpp_sender_rust_receiver(target): + """ + Verify that the C++ and Rust public APIs are interoperable: the C++ + fit_sender_cc offers VehicleInterface over shared memory and the Rust + fit_receiver consumes it. A successful exchange shows that the native + C++ API path and the Rust FFI API path operate on the same LoLa SHM + binding, partially evidencing feat_req__com__multi_lang. + """ + result = comm.run_event_exchange( + target, + send_cycles=50, + recv_cycles=10, + sender_bin=comm.FIT_SENDER_CC_BIN, + receiver_bin=comm.FIT_RECEIVER_BIN, + ) + logger.info("C++→Rust consumer stdout:\n%s", result.recv_stdout) + logger.info("C++ sender stdout:\n%s", result.send_stdout) + + assert "FIT_SEND_OFFERED" in result.send_stdout, "C++ sender did not report offering the service" + assert result.recv_exit_code == 0, "Rust consumer did not exit cleanly" + assert result.found_service, "Rust consumer did not discover the C++ service" + assert result.received_samples, "Rust consumer received no samples from C++ sender" + assert result.completed, "Rust consumer did not report a clean completion" + + +@add_test_properties( + partially_verifies=[ + "feat_req__com__multi_lang", + "feat_req__com__event_type", + "feat_req__com__producer_consumer", + ], + test_type="requirements-based", + derivation_technique="requirements-analysis", +) +def test_rust_sender_cpp_receiver(target): + """ + Verify the reverse direction of C++/Rust API interoperability: the Rust + fit_sender offers VehicleInterface over shared memory and the C++ + fit_receiver_cc consumes it. Together with test_cpp_sender_rust_receiver + this exercises both API directions for feat_req__com__multi_lang. + """ + result = comm.run_event_exchange( + target, + send_cycles=50, + recv_cycles=10, + sender_bin=comm.FIT_SENDER_BIN, + receiver_bin=comm.FIT_RECEIVER_CC_BIN, + ) + logger.info("Rust→C++ consumer stdout:\n%s", result.recv_stdout) + logger.info("Rust sender stdout:\n%s", result.send_stdout) + + assert "FIT_SEND_OFFERED" in result.send_stdout, "Rust sender did not report offering the service" + assert result.recv_exit_code == 0, "C++ consumer did not exit cleanly" + assert result.found_service, "C++ consumer did not discover the Rust service" + assert result.received_samples, "C++ consumer received no samples from Rust sender" + assert result.completed, "C++ consumer did not report a clean completion" + + +@add_test_properties( + partially_verifies=[ + "feat_req__com__data_corruption", + "feat_req__com__data_reordering", + ], + test_type="requirements-based", + derivation_technique="requirements-analysis", +) +def test_received_samples_are_ordered_and_intact(target): + """ + Verify that received samples are neither reordered, duplicated, nor + corrupted: each consecutive pair of sequence numbers the producer + embedded in the payload increases by exactly one, and every received + value is one the producer actually sent (never a garbled value). A + duplicate delivery of the same sample, a dropped-then-repeated value, or + an out-of-range/non-numeric payload all fail this check. + """ + send_cycles = 80 + result = comm.run_event_exchange(target, send_cycles=send_cycles, recv_cycles=20) + samples = result.received_samples + logger.info("received sample sequence: %s", samples) + + assert result.recv_exit_code == 0, "consumer did not exit cleanly" + assert samples, "consumer received no samples" + assert all(0 <= s < send_cycles for s in samples), ( + f"received samples contain out-of-range sequence values: {samples}" + ) + assert all(b == a + 1 for a, b in zip(samples, samples[1:])), ( + f"received samples were reordered or duplicated: {samples}" + ) + + +@add_test_properties( + partially_verifies=[ + "feat_req__com__depl_config_runtime", + ], + test_type="requirements-based", + derivation_technique="requirements-analysis", +) +def test_deployment_config_is_read_from_runtime_path(target): + """ + Verify that the SHM binding and service identity are taken from a + deployment manifest read at runtime from an explicit on-target path + (``-s ``), not compiled in. + + A copy of the deployed config is staged at a non-default runtime path + with its instance identity *changed* to one that exists nowhere else, + and both roles are pointed at the modified copy via ``-i``. If the + runtime path were ignored (e.g. silently falling back to the compiled-in + default), this instance would not exist anywhere and discovery would + fail -- so a successful exchange proves the modified content at the + runtime path was actually parsed and used, not merely that some path + argument was accepted. + """ + default_specifier = "/Vehicle/Service1/Instance" + modified_specifier = "/Vehicle/ServiceRuntimeConfigTest/Instance" + + _, default_bytes = target.execute(f"cat {comm.DEFAULT_MANIFEST}") + manifest = json.loads(default_bytes.decode()) + for instance in manifest["serviceInstances"]: + if instance["instanceSpecifier"] == default_specifier: + instance["instanceSpecifier"] = modified_specifier + break + else: + pytest.fail(f"test setup error: default manifest did not contain {default_specifier!r} to modify") + + staged = "/tmp/comm_fit_runtime_manifest.json" + comm.write_remote_file(target, staged, json.dumps(manifest)) + + result = comm.run_event_exchange( + target, send_cycles=30, recv_cycles=5, manifest=staged, instance_specifier=modified_specifier + ) + logger.info("runtime-config consumer stdout:\n%s", result.recv_stdout) + + assert result.recv_exit_code == 0, "consumer did not exit cleanly with a modified runtime-path config" + assert result.found_service, "service not discovered using the modified runtime-path config" + assert result.received_samples, "no samples received over the modified config-declared SHM binding" + + +@add_test_properties( + partially_verifies=[ + "feat_req__com__stateless_communication", + "feat_req__com__producer_consumer", + ], + test_type="requirements-based", + derivation_technique="requirements-analysis", +) +def test_late_joining_consumer_receives_data(target): + """ + Verify that a consumer subscribing well after the producer has started + still binds and receives valid samples, proving communication does not + depend on the consumer being present from the first cycle. + """ + result = comm.run_event_exchange(target, send_cycles=80, recv_cycles=10, startup_delay_s=3) + logger.info("late-join consumer stdout:\n%s", result.recv_stdout) + + assert result.recv_exit_code == 0, "late-joining consumer did not exit cleanly" + assert result.found_service, "late-joining consumer failed to discover the service" + assert result.received_samples, "late-joining consumer received no samples" + + +def _assert_missing_config_fails_deterministically(result: comm.CommResult) -> None: + """ + A missing manifest takes a distinct code path from malformed JSON (see + fit_receiver.rs's init_lola_runtime): no parse is attempted, so it must + be caught by our own controlled exit(1)/FIT_RECV_NO_SERVICE path, not a + parser abort. Pinned to that exact exit code and both markers -- rather + than just "exit != 0" -- so an unrelated crash (segfault, wrong binary, + panic elsewhere) cannot be mistaken for this specific contract. + """ + assert "FIT_RECV_CONFIG_MISSING" in result.recv_stdout, ( + f"consumer did not report the config file as missing:\n{result.recv_stdout}" + ) + assert result.recv_exit_code == 1, ( + f"expected the controlled exit(1)/FIT_RECV_NO_SERVICE path (got exit " + f"{result.recv_exit_code}):\n{result.recv_stdout}" + ) + assert "FIT_RECV_NO_SERVICE" in result.recv_stdout, ( + f"consumer did not report giving up on service discovery:\n{result.recv_stdout}" + ) + assert not result.found_service, "consumer proceeded past config load to service discovery" + assert not result.received_samples, "consumer proceeded to receive samples despite a missing config" + + +def _assert_malformed_config_aborts(result: comm.CommResult) -> None: + """ + Syntactically invalid or schema-invalid JSON is expected to abort the + process while parsing the config -- empirically observed as SIGABRT + (shell exit 134) on both Linux and QNX. Pinned to that exact exit code + -- rather than just "exit != 0" -- so an unrelated crash cannot be + mistaken for a config-parse failure. + """ + assert result.recv_exit_code == 134, ( + f"expected a config-parse abort (SIGABRT, shell exit 134), got exit " + f"{result.recv_exit_code}:\n{result.recv_stdout}" + ) + assert not result.found_service, "consumer proceeded past config load to service discovery" + assert not result.received_samples, "consumer proceeded to receive samples despite a malformed config" + + +@add_test_properties( + partially_verifies=[ + "feat_req__com__depl_config_runtime", + ], + test_type="requirements-based", + derivation_technique="requirements-analysis", +) +def test_missing_config_file_fails_deterministically(target): + """ + Verify that a missing deployment manifest causes the process to fail + deterministically and quickly rather than hang or silently continue. + """ + result = comm.run_receiver_with_manifest(target, "/tmp/comm_fit_missing_config.json") + logger.info("missing-config consumer output:\n%s", result.recv_stdout) + _assert_missing_config_fails_deterministically(result) + + +@add_test_properties( + partially_verifies=[ + "feat_req__com__depl_config_runtime", + ], + test_type="requirements-based", + derivation_technique="requirements-analysis", +) +def test_truncated_config_fails_deterministically(target): + """ + Verify that syntactically invalid JSON in the deployment manifest fails + deterministically rather than hang or silently continue. + """ + manifest_path = "/tmp/comm_fit_truncated_config.json" + comm.write_remote_file(target, manifest_path, comm.TRUNCATED_CONFIG_JSON) + + result = comm.run_receiver_with_manifest(target, manifest_path) + logger.info("truncated-config consumer output:\n%s", result.recv_stdout) + _assert_malformed_config_aborts(result) + + +@add_test_properties( + partially_verifies=[ + "feat_req__com__depl_config_runtime", + ], + test_type="requirements-based", + derivation_technique="requirements-analysis", +) +def test_schema_invalid_config_fails_deterministically(target): + """ + Verify that well-formed JSON missing the required schema structure fails + deterministically rather than hang or silently continue. + """ + manifest_path = "/tmp/comm_fit_schema_invalid_config.json" + comm.write_remote_file(target, manifest_path, comm.SCHEMA_INVALID_CONFIG_JSON) + + result = comm.run_receiver_with_manifest(target, manifest_path) + logger.info("schema-invalid-config consumer output:\n%s", result.recv_stdout) + _assert_malformed_config_aborts(result) + + +@add_test_properties( + partially_verifies=[ + "feat_req__com__depl_config_runtime", + ], + test_type="requirements-based", + derivation_technique="requirements-analysis", +) +def test_cc_missing_config_file_fails_deterministically(target): + """ + Verify the C++ receiver exits non-zero on a missing manifest rather than + hanging or silently continuing. The C++ receiver exits 1 immediately + after printing FIT_RECV_CONFIG_MISSING, unlike the Rust receiver which + also reaches FIT_RECV_NO_SERVICE; both contracts are non-zero + no data. + """ + result = comm.run_receiver_with_manifest( + target, "/tmp/comm_fit_cc_missing_config.json", receiver_bin=comm.FIT_RECEIVER_CC_BIN + ) + logger.info("C++ missing-config consumer output:\n%s", result.recv_stdout) + + assert "FIT_RECV_CONFIG_MISSING" in result.recv_stdout, ( + f"C++ consumer did not report the config file as missing:\n{result.recv_stdout}" + ) + assert result.recv_exit_code == 1, ( + f"expected controlled exit(1) from C++ consumer (got {result.recv_exit_code}):\n{result.recv_stdout}" + ) + assert not result.found_service, "C++ consumer proceeded to service discovery despite missing config" + assert not result.received_samples, "C++ consumer received samples despite a missing config" + + +@add_test_properties( + partially_verifies=[ + "feat_req__com__depl_config_runtime", + ], + test_type="requirements-based", + derivation_technique="requirements-analysis", +) +def test_cc_truncated_config_fails_deterministically(target): + """ + Verify the C++ receiver fails deterministically on syntactically invalid + JSON. Both C++ and Rust receivers share the same upstream JSON parser, so + the expected signal is SIGABRT (shell exit 134), same as the Rust test. + """ + manifest_path = "/tmp/comm_fit_cc_truncated_config.json" + comm.write_remote_file(target, manifest_path, comm.TRUNCATED_CONFIG_JSON) + + result = comm.run_receiver_with_manifest(target, manifest_path, receiver_bin=comm.FIT_RECEIVER_CC_BIN) + logger.info("C++ truncated-config consumer output:\n%s", result.recv_stdout) + _assert_malformed_config_aborts(result) + + +@add_test_properties( + partially_verifies=[ + "feat_req__com__asil", + "feat_req__com__acl_for_consumer", + "feat_req__com__acl_for_producer", + "feat_req__com__acl_per_service_instance", + ], + test_type="requirements-based", + derivation_technique="requirements-analysis", +) +def test_mixed_criticality_acl_isolation(target): + """ + Verify ASIL-B mixed-criticality isolation: an ASIL-B provider offers to + exactly one allowed UID (the target's own default identity). Two + consumers subscribe concurrently -- one running as that allowed UID, one + running as a UID deliberately excluded from allowedConsumer. + + The allowed consumer receives samples while the excluded consumer is + active concurrently, proving the excluded consumer cannot affect the + allowed stream. The excluded consumer fails deterministically (non-zero + exit, an ACL/proxy-denial diagnostic, never receives a sample) -- observed + as an uncaught panic in the generated bindings once the shared-memory + open is denied, not a graceful, catchable error. + + Obtaining the excluded consumer's distinct UID uses `setpriv` on Linux, + which lets an unprivileged UID still bring up its LoLa endpoint and so + reach the shared-memory ACL check the assertions below rely on. + + Skipped on QNX: LoLa's MessagePassingService endpoint registration requires + privilege, so a lower-privilege UID aborts at message-passing setup before + reaching the shared-memory ACL check. + """ + if comm.is_qnx(target): + pytest.skip( + "allowedConsumer ACL enforcement cannot be exercised via a " + "lower-privilege UID on QNX: LoLa's MessagePassingService endpoint " + "registration requires privilege, so a consumer run under `on -u` " + "aborts at message-passing setup before reaching the shared-memory " + "ACL check." + ) + + allowed_result, denied_result, allowed_uid = comm.run_acl_isolation_scenario(target) + logger.info("ACL scenario allowed_uid=%s", allowed_uid) + logger.info("allowed consumer stdout:\n%s", allowed_result.recv_stdout) + logger.info("denied consumer stdout:\n%s", denied_result.recv_stdout) + + # Allowed consumer: unaffected by the concurrently-running denied one. + assert allowed_result.recv_exit_code == 0, "allowed consumer did not exit cleanly" + assert allowed_result.received_samples, "allowed consumer received no samples" + + # Denied consumer: fails deterministically, never receives data, and the + # failure is specifically attributable to the ACL/proxy-creation path + # (not some unrelated crash) -- see comm.ACL_DENIAL_MARKERS for what this + # denial actually looks like on the wire (an uncaught panic in the + # generated bindings, not a graceful Err from builder.build()). + assert any(marker in denied_result.recv_stdout for marker in comm.ACL_DENIAL_MARKERS), ( + f"consumer with an excluded UID did not report an ACL/proxy denial:\n{denied_result.recv_stdout}" + ) + assert denied_result.recv_exit_code != 0, "consumer with an excluded UID was not denied" + assert not denied_result.found_service, "consumer with an excluded UID reached service discovery" + assert not denied_result.received_samples, "consumer with an excluded UID received samples" diff --git a/known_good.json b/known_good.json index 9f9be872721..8e3f7545331 100644 --- a/known_good.json +++ b/known_good.json @@ -47,6 +47,7 @@ "hash": "313f1178628189d267c8cd0d75971bdac04c20ee", "bazel_patches": [ "//patches/communication:001-expose-comm-examples.patch", + "//patches/communication:002-expose-com-api-gen.patch", "//patches/communication:003-module-deps-visibility.patch", "//patches/communication:004-rules-build-error-not-dev-dependency.patch", "//patches/communication:005-migrate-baselibs-rust-targets.patch" diff --git a/patches/communication/002-expose-com-api-gen.patch b/patches/communication/002-expose-com-api-gen.patch new file mode 100644 index 00000000000..be0a459f837 --- /dev/null +++ b/patches/communication/002-expose-com-api-gen.patch @@ -0,0 +1,23 @@ +diff --git a/score/mw/com/example/com-api-example/com-api-gen/BUILD b/score/mw/com/example/com-api-example/com-api-gen/BUILD +index 94f6c8f9..9217e658 100644 +--- a/score/mw/com/example/com-api-example/com-api-gen/BUILD ++++ b/score/mw/com/example/com-api-example/com-api-gen/BUILD +@@ -21,7 +21,7 @@ rust_library( + crate_name = "com_api_gen", + features = ["link_std_cpp_lib"], + visibility = [ +- "//score/mw/com:__subpackages__", ++ "//visibility:public", + ], + deps = [ + ":vehicle_gen_cpp", +diff --git a/quality/visibility_guard/public_targets.golden b/quality/visibility_guard/public_targets.golden +--- a/quality/visibility_guard/public_targets.golden ++++ b/quality/visibility_guard/public_targets.golden +@@ -54,5 +54,6 @@ + //score/mw/com/dependability:mw_com_doc_needs + //score/mw/com/dependability:mw_com_index + //score/mw/com/dependability:mw_com_rst ++//score/mw/com/example/com-api-example/com-api-gen:com-api-gen + //score/mw/com/example/com-api-example:com-api-example + //score/mw/com/performance_benchmarks/macro_benchmark/config_generator:config_generator_rule.bzl diff --git a/showcases/BUILD b/showcases/BUILD index 261f806ca08..126ed13e8a5 100644 --- a/showcases/BUILD +++ b/showcases/BUILD @@ -18,6 +18,7 @@ score_pkg_bundle( bins = ["//showcases/cli"], other_package_files = [ "//showcases/standalone:comm_pkg_files", + "//showcases/standalone/comm_fit:comm_fit_pkg_files", "//showcases/standalone:kyron_pkg_files", "//showcases/standalone:time_pkg_files", "//showcases/standalone:logging_pkg_files", @@ -43,6 +44,7 @@ pkg_filegroup( "//showcases/standalone:kyron_pkg_files", "//showcases/standalone:logging_pkg_files", "//showcases/standalone:time_pkg_files", + "//showcases/standalone/comm_fit:comm_fit_pkg_files", ], prefix = "showcases", visibility = ["//visibility:public"], diff --git a/showcases/standalone/comm_fit/BUILD b/showcases/standalone/comm_fit/BUILD new file mode 100644 index 00000000000..0eb319ebccf --- /dev/null +++ b/showcases/standalone/comm_fit/BUILD @@ -0,0 +1,70 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +load("@rules_cc//cc:cc_binary.bzl", "cc_binary") +load("@rules_rust//rust:defs.bzl", "rust_binary") +load("//bazel_common:bundlers.bzl", "score_pkg_bundle") + +_COM_API_DEPS = [ + "@score_communication//score/mw/com/example/com-api-example/com-api-gen", + "@score_communication//score/mw/com/rust:score_com", +] + +_CC_COM_API_DEPS = [ + "@score_communication//score/mw/com", +] + +rust_binary( + name = "fit_sender", + srcs = ["fit_sender.rs"], + features = ["link_std_cpp_lib"], + visibility = ["//visibility:public"], + deps = _COM_API_DEPS, +) + +rust_binary( + name = "fit_receiver", + srcs = ["fit_receiver.rs"], + features = ["link_std_cpp_lib"], + visibility = ["//visibility:public"], + deps = _COM_API_DEPS, +) + +cc_binary( + name = "fit_sender_cc", + srcs = [ + "fit_sender.cc", + "vehicle_interface.h", + ], + visibility = ["//visibility:public"], + deps = _CC_COM_API_DEPS, +) + +cc_binary( + name = "fit_receiver_cc", + srcs = [ + "fit_receiver.cc", + "vehicle_interface.h", + ], + visibility = ["//visibility:public"], + deps = _CC_COM_API_DEPS, +) + +score_pkg_bundle( + name = "comm_fit", + bins = [ + ":fit_sender", + ":fit_receiver", + ":fit_sender_cc", + ":fit_receiver_cc", + ], +) diff --git a/showcases/standalone/comm_fit/fit_receiver.cc b/showcases/standalone/comm_fit/fit_receiver.cc new file mode 100644 index 00000000000..ab3d060559c --- /dev/null +++ b/showcases/standalone/comm_fit/fit_receiver.cc @@ -0,0 +1,168 @@ +// ******************************************************************************* +// Copyright (c) 2026 Contributors to the Eclipse Foundation +// +// See the NOTICE file(s) distributed with this work for additional +// information regarding copyright ownership. +// +// This program and the accompanying materials are made available under the +// terms of the Apache License Version 2.0 which is available at +// https://www.apache.org/licenses/LICENSE-2.0 +// +// SPDX-License-Identifier: Apache-2.0 +// ******************************************************************************* + +// Standalone C++ consumer for the communication feature integration tests. +// +// Discovers the VehicleInterface service instance offered by fit_sender (or +// fit_sender_cc), subscribes to left_tire, and prints each received sequence +// number as "FIT_RECV seq=". Emits the same FIT_RECV_* markers as the Rust +// fit_receiver so the ITF oracles in comm_helpers.py work unchanged for +// cross-language tests. +// +// Usage: fit_receiver_cc [-s ] [-n ] [-t ] +// [--max-polls ] [-i ] + +#include "showcases/standalone/comm_fit/vehicle_interface.h" +#include "score/mw/com/runtime.h" +#include "score/mw/com/runtime_configuration.h" +#include "score/mw/com/types.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace rt = score::mw::com::runtime; +using VehicleProxy = score::mw::com::AsProxy; + +namespace +{ + +constexpr const char* kDefaultManifest = "./etc/mw_com_config.json"; +constexpr const char* kDefaultSpecifier = "/Vehicle/Service1/Instance"; + +struct Args +{ + std::string manifest{kDefaultManifest}; + int cycles{10}; + int interval_ms{100}; + int max_polls{100}; + std::string specifier{kDefaultSpecifier}; +}; + +static int parse_int_arg(const char* flag, const char* val) +{ + char* end = nullptr; + const long n = std::strtol(val, &end, 10); + if (end == val || *end != '\0') { + std::fprintf(stderr, "FIT_RECV_ERROR invalid integer for %s: %s\n", flag, val); + std::exit(1); + } + return static_cast(n); +} + +Args ParseArgs(int argc, char* argv[]) +{ + Args a; + for (int i = 1; i < argc; ++i) { + const std::string flag{argv[i]}; + const bool has_next = (i + 1 < argc); + if ((flag == "-s" || flag == "--service-instance-manifest") && has_next) { + a.manifest = argv[++i]; + } else if ((flag == "-n" || flag == "--cycles") && has_next) { + a.cycles = parse_int_arg(flag.c_str(), argv[++i]); + } else if ((flag == "-t" || flag == "--interval-ms") && has_next) { + a.interval_ms = parse_int_arg(flag.c_str(), argv[++i]); + } else if (flag == "--max-polls" && has_next) { + a.max_polls = parse_int_arg(flag.c_str(), argv[++i]); + } else if ((flag == "-i" || flag == "--instance-specifier") && has_next) { + a.specifier = argv[++i]; + } else { + std::fprintf(stderr, "FIT_RECV_IGNORED_ARG %s\n", argv[i]); + } + } + return a; +} + +bool FileExists(const std::string& path) +{ + struct stat st {}; + return stat(path.c_str(), &st) == 0; +} + +} // namespace + +int main(int argc, char* argv[]) +{ + const auto args = ParseArgs(argc, argv); + + if (!FileExists(args.manifest)) { + std::fprintf(stderr, "FIT_RECV_CONFIG_MISSING path=%s\n", args.manifest.c_str()); + return 1; + } + + rt::InitializeRuntime( + rt::RuntimeConfiguration{score::filesystem::Path{args.manifest}}); + + auto spec_result = score::mw::com::InstanceSpecifier::Create(std::string{args.specifier}); + if (!spec_result.has_value()) { + std::fprintf(stderr, "FIT_RECV_SPEC_ERROR\n"); + return 1; + } + const auto& spec = spec_result.value(); + + std::optional proxy; + for (int poll = 0; poll < args.max_polls && !proxy.has_value(); ++poll) { + auto handles = VehicleProxy::FindService(spec); + if (handles.has_value() && !handles.value().empty()) { + auto proxy_result = VehicleProxy::Create(handles.value().front()); + if (proxy_result.has_value()) { + proxy = std::move(proxy_result.value()); + } else { + std::fprintf(stderr, "FIT_RECV_PROXY_BUILD_ERROR\n"); + } + } + if (!proxy.has_value()) { + std::this_thread::sleep_for(std::chrono::milliseconds(args.interval_ms)); + } + } + + if (!proxy.has_value()) { + std::fprintf(stderr, "FIT_RECV_NO_SERVICE\n"); + return 1; + } + std::printf("FIT_FOUND_SERVICE\n"); + std::fflush(stdout); + + if (!proxy->left_tire.Subscribe(static_cast(args.cycles)).has_value()) { + std::fprintf(stderr, "FIT_RECV_SUBSCRIBE_ERROR\n"); + return 1; + } + + int received = 0; + for (int poll = 0; poll < args.max_polls && received < args.cycles; ++poll) { + auto result = proxy->left_tire.GetNewSamples( + [&received](auto&& sample) noexcept { + std::printf("FIT_RECV seq=%.9g\n", static_cast(sample.Get()->pressure)); + std::fflush(stdout); + ++received; + }, + static_cast(args.cycles)); + + if (!result.has_value() || result.value() == 0U) { + std::this_thread::sleep_for(std::chrono::milliseconds(args.interval_ms)); + } + } + + proxy->left_tire.Unsubscribe(); + + if (received >= args.cycles) { + std::printf("FIT_RECV_DONE count=%d\n", received); + return 0; + } + std::fprintf(stderr, "FIT_RECV_INCOMPLETE count=%d expected=%d\n", received, args.cycles); + return 1; +} diff --git a/showcases/standalone/comm_fit/fit_receiver.rs b/showcases/standalone/comm_fit/fit_receiver.rs new file mode 100644 index 00000000000..26ef97e417c --- /dev/null +++ b/showcases/standalone/comm_fit/fit_receiver.rs @@ -0,0 +1,198 @@ +// ******************************************************************************* +// Copyright (c) 2026 Contributors to the Eclipse Foundation +// +// See the NOTICE file(s) distributed with this work for additional +// information regarding copyright ownership. +// +// This program and the accompanying materials are made available under the +// terms of the Apache License Version 2.0 which is available at +// +// +// SPDX-License-Identifier: Apache-2.0 +// ******************************************************************************* + +//! Standalone *consumer* for the communication feature integration tests. +//! +//! It discovers the `VehicleInterface` service instance offered by `fit_sender` +//! (retrying, so start order does not matter), subscribes to `left_tire`, and +//! prints each received sequence number as `FIT_RECV seq=`. Because the +//! sender encodes a monotonically increasing counter in the payload, the ITF +//! can assert on this output that delivery happened, that samples arrived in +//! order, and that their values were not corrupted -- all from data the +//! consumer actually observed. +//! +//! Exit code: 0 when it received the requested number of samples, 1 otherwise +//! (so a delivery regression surfaces as a non-zero exit, not a silent +//! under-count). + +use std::path::{Path, PathBuf}; +use std::thread::sleep; +use std::time::Duration; + +use com_api_gen::VehicleInterface; +use score_com::{ + Builder, FindServiceSpecifier, InstanceSpecifier, LolaRuntimeBuilderImpl, LolaRuntimeImpl, Runtime, RuntimeBuilder, + SampleContainer, ServiceDiscovery, Subscriber, Subscription, +}; + +/// Default instance specifier, matching com-api-example's deployment manifest. +/// Overridable via `-i` so tests can target a dedicated (e.g. ASIL-B) instance +/// without disturbing the default exchange scenario. +const DEFAULT_INSTANCE_SPECIFIER: &str = "/Vehicle/Service1/Instance"; + +struct Arguments { + /// Deployment manifest read at runtime (feat_req__com__depl_config_runtime). + service_instance_manifest: PathBuf, + /// Number of samples to receive before exiting successfully. + cycles: u32, + /// Poll interval in milliseconds (discovery retry and receive loop). + interval_ms: u64, + /// Maximum number of poll iterations before giving up (bounds the run). + max_polls: u32, + /// Service instance specifier to subscribe to. + instance_specifier: String, +} + +fn parse_args() -> Arguments { + let mut manifest = PathBuf::from("./etc/mw_com_config.json"); + let mut cycles: u32 = 10; + let mut interval_ms: u64 = 100; + let mut max_polls: u32 = 100; + let mut instance_specifier = DEFAULT_INSTANCE_SPECIFIER.to_string(); + + let argv: Vec = std::env::args().collect(); + let mut i = 1; + while i < argv.len() { + match argv[i].as_str() { + "-s" | "--service-instance-manifest" => { + i += 1; + manifest = PathBuf::from(argv.get(i).expect("missing value for -s")); + }, + "-n" | "--cycles" => { + i += 1; + cycles = argv + .get(i) + .expect("missing value for -n") + .parse() + .expect("invalid -n/--cycles"); + }, + "-t" | "--interval-ms" => { + i += 1; + interval_ms = argv + .get(i) + .expect("missing value for -t") + .parse() + .expect("invalid -t/--interval-ms"); + }, + "--max-polls" => { + i += 1; + max_polls = argv + .get(i) + .expect("missing value for --max-polls") + .parse() + .expect("invalid --max-polls"); + }, + "-i" | "--instance-specifier" => { + i += 1; + instance_specifier = argv.get(i).expect("missing value for -i").clone(); + }, + other => eprintln!("FIT_RECV_IGNORED_ARG {other}"), + } + i += 1; + } + + Arguments { + service_instance_manifest: manifest, + cycles, + interval_ms, + max_polls, + instance_specifier, + } +} + +fn init_lola_runtime(config_path: &Path) -> LolaRuntimeImpl { + let mut builder = LolaRuntimeBuilderImpl::new(); + if config_path.exists() { + builder.load_config(config_path); + } else { + eprintln!("FIT_RECV_CONFIG_MISSING path={}", config_path.display()); + } + builder.build().expect("Failed to build Lola runtime") +} + +/// Discover and build the consumer, retrying so start order does not matter. +fn discover_consumer( + runtime: &LolaRuntimeImpl, + service_id: &InstanceSpecifier, + retries: u32, + interval_ms: u64, +) -> Option<::Consumer> { + for _ in 0..retries { + let discovery = runtime.find_service::(FindServiceSpecifier::Specific(service_id.clone())); + if let Ok(instances) = discovery.get_available_instances() { + if let Some(builder) = instances.into_iter().next() { + match builder.build() { + Ok(consumer) => return Some(consumer), + // A denied proxy build (e.g. ACL rejection) is expected to + // keep retrying rather than abort, but the rejection must + // still be visible for diagnosis instead of silently + // discarded. + Err(error) => eprintln!("FIT_RECV_PROXY_BUILD_ERROR {error:?}"), + } + } + } + sleep(Duration::from_millis(interval_ms)); + } + None +} + +fn main() { + let args = parse_args(); + let runtime = init_lola_runtime(&args.service_instance_manifest); + + let service_id = InstanceSpecifier::new(&args.instance_specifier).expect("Failed to create InstanceSpecifier"); + + let consumer = match discover_consumer(&runtime, &service_id, args.max_polls, args.interval_ms) { + Some(consumer) => consumer, + None => { + eprintln!("FIT_RECV_NO_SERVICE"); + std::process::exit(1); + }, + }; + println!("FIT_FOUND_SERVICE"); + + let subscription = consumer + .left_tire + .subscribe(args.cycles as usize) + .expect("Failed to subscribe"); + + let mut received: u32 = 0; + let mut buffer = SampleContainer::new(args.cycles as usize); + for _ in 0..args.max_polls { + if received >= args.cycles { + break; + } + match subscription.try_receive(&mut buffer, args.cycles as usize) { + Ok(0) => sleep(Duration::from_millis(args.interval_ms)), + Ok(_) => { + while let Some(sample) = buffer.pop_front() { + // Print via Display, not cast to i64: a corrupted f32 saturates + // to a plausible integer, hiding corruption from the ITF. + let pressure = sample.pressure; + println!("FIT_RECV seq={pressure}"); + received += 1; + } + }, + Err(error) => eprintln!("FIT_RECV_ERROR {error:?}"), + } + } + + let _ = subscription.unsubscribe(); + + if received >= args.cycles { + println!("FIT_RECV_DONE count={received}"); + } else { + eprintln!("FIT_RECV_INCOMPLETE count={received} expected={}", args.cycles); + std::process::exit(1); + } +} diff --git a/showcases/standalone/comm_fit/fit_sender.cc b/showcases/standalone/comm_fit/fit_sender.cc new file mode 100644 index 00000000000..f18a0709969 --- /dev/null +++ b/showcases/standalone/comm_fit/fit_sender.cc @@ -0,0 +1,143 @@ +// ******************************************************************************* +// Copyright (c) 2026 Contributors to the Eclipse Foundation +// +// See the NOTICE file(s) distributed with this work for additional +// information regarding copyright ownership. +// +// This program and the accompanying materials are made available under the +// terms of the Apache License Version 2.0 which is available at +// https://www.apache.org/licenses/LICENSE-2.0 +// +// SPDX-License-Identifier: Apache-2.0 +// ******************************************************************************* + +// Standalone C++ producer for the communication feature integration tests. +// +// Offers the VehicleInterface service instance and publishes left_tire samples +// whose pressure field carries a monotonically increasing sequence number +// (0, 1, 2, ...). Emits the same FIT_SEND_* markers as the Rust fit_sender so +// the ITF oracles in comm_helpers.py work unchanged for cross-language tests. +// +// Usage: fit_sender_cc [-s ] [-n ] [-t ] +// [-i ] + +#include "showcases/standalone/comm_fit/vehicle_interface.h" +#include "score/mw/com/runtime.h" +#include "score/mw/com/runtime_configuration.h" +#include "score/mw/com/types.h" + +#include +#include +#include +#include +#include +#include + +namespace rt = score::mw::com::runtime; +using VehicleSkeleton = score::mw::com::AsSkeleton; + +namespace +{ + +constexpr const char* kDefaultManifest = "./etc/mw_com_config.json"; +constexpr const char* kDefaultSpecifier = "/Vehicle/Service1/Instance"; + +struct Args +{ + std::string manifest{kDefaultManifest}; + int cycles{50}; + int interval_ms{100}; + std::string specifier{kDefaultSpecifier}; +}; + +static int parse_int_arg(const char* flag, const char* val) +{ + char* end = nullptr; + const long n = std::strtol(val, &end, 10); + if (end == val || *end != '\0') { + std::fprintf(stderr, "FIT_SEND_ERROR invalid integer for %s: %s\n", flag, val); + std::exit(1); + } + return static_cast(n); +} + +Args ParseArgs(int argc, char* argv[]) +{ + Args a; + for (int i = 1; i < argc; ++i) { + const std::string flag{argv[i]}; + const bool has_next = (i + 1 < argc); + if ((flag == "-s" || flag == "--service-instance-manifest") && has_next) { + a.manifest = argv[++i]; + } else if ((flag == "-n" || flag == "--cycles") && has_next) { + a.cycles = parse_int_arg(flag.c_str(), argv[++i]); + } else if ((flag == "-t" || flag == "--interval-ms") && has_next) { + a.interval_ms = parse_int_arg(flag.c_str(), argv[++i]); + } else if ((flag == "-i" || flag == "--instance-specifier") && has_next) { + a.specifier = argv[++i]; + } else { + std::fprintf(stderr, "FIT_SEND_IGNORED_ARG %s\n", argv[i]); + } + } + return a; +} + +bool FileExists(const std::string& path) +{ + struct stat st {}; + return stat(path.c_str(), &st) == 0; +} + +} // namespace + +int main(int argc, char* argv[]) +{ + const auto args = ParseArgs(argc, argv); + + if (!FileExists(args.manifest)) { + std::fprintf(stderr, "FIT_SEND_CONFIG_MISSING path=%s\n", args.manifest.c_str()); + return 1; + } + + rt::InitializeRuntime( + rt::RuntimeConfiguration{score::filesystem::Path{args.manifest}}); + + auto spec_result = score::mw::com::InstanceSpecifier::Create(std::string{args.specifier}); + if (!spec_result.has_value()) { + std::fprintf(stderr, "FIT_SEND_SPEC_ERROR\n"); + return 1; + } + + auto skel_result = VehicleSkeleton::Create(spec_result.value()); + if (!skel_result.has_value()) { + std::fprintf(stderr, "FIT_SEND_CREATE_ERROR\n"); + return 1; + } + auto& skeleton = skel_result.value(); + + if (!skeleton.OfferService().has_value()) { + std::fprintf(stderr, "FIT_SEND_OFFER_ERROR\n"); + return 1; + } + std::printf("FIT_SEND_OFFERED\n"); + std::fflush(stdout); + + for (int seq = 0; seq < args.cycles; ++seq) { + auto alloc = skeleton.left_tire.Allocate(); + if (!alloc.has_value()) { + std::fprintf(stderr, "FIT_SEND_ERROR allocate failed at seq=%d\n", seq); + return 1; + } + alloc.value().Get()->pressure = static_cast(seq); + if (!skeleton.left_tire.Send(std::move(alloc.value())).has_value()) { + std::fprintf(stderr, "FIT_SEND_ERROR send failed at seq=%d\n", seq); + return 1; + } + std::printf("FIT_SEND seq=%d\n", seq); + std::fflush(stdout); + std::this_thread::sleep_for(std::chrono::milliseconds(args.interval_ms)); + } + + std::printf("FIT_SEND_DONE count=%d\n", args.cycles); + return 0; +} diff --git a/showcases/standalone/comm_fit/fit_sender.rs b/showcases/standalone/comm_fit/fit_sender.rs new file mode 100644 index 00000000000..a940fb4d982 --- /dev/null +++ b/showcases/standalone/comm_fit/fit_sender.rs @@ -0,0 +1,131 @@ +// ******************************************************************************* +// Copyright (c) 2026 Contributors to the Eclipse Foundation +// +// See the NOTICE file(s) distributed with this work for additional +// information regarding copyright ownership. +// +// This program and the accompanying materials are made available under the +// terms of the Apache License Version 2.0 which is available at +// +// +// SPDX-License-Identifier: Apache-2.0 +// ******************************************************************************* + +//! Standalone *producer* for the communication feature integration tests. +//! +//! It offers the `VehicleInterface` service instance and publishes `left_tire` +//! samples whose `pressure` field carries a monotonically increasing sequence +//! number (0, 1, 2, ...). Running the sender and `fit_receiver` as two separate +//! OS processes over real shared memory lets the ITF assert delivery, ordering +//! and value-integrity on the data the consumer actually observes, rather than +//! on strings the app prints about itself. +//! +//! Usage: `fit_sender [-s ] [-n ] [-t ]` + +use std::path::{Path, PathBuf}; +use std::thread::sleep; +use std::time::Duration; + +use com_api_gen::{Tire, VehicleInterface}; +use score_com::{ + Builder, InstanceSpecifier, LolaRuntimeBuilderImpl, LolaRuntimeImpl, Producer, Publisher, Runtime, RuntimeBuilder, + SampleMaybeUninit, SampleMut, +}; + +/// Default instance specifier, matching com-api-example's deployment manifest. +/// Overridable via `-i` so tests can target a dedicated (e.g. ASIL-B) instance +/// without disturbing the default exchange scenario. +const DEFAULT_INSTANCE_SPECIFIER: &str = "/Vehicle/Service1/Instance"; + +struct Arguments { + /// Deployment manifest read at runtime (feat_req__com__depl_config_runtime). + service_instance_manifest: PathBuf, + /// Number of samples to send. + cycles: u32, + /// Delay between samples in milliseconds. + interval_ms: u64, + /// Service instance specifier to offer. + instance_specifier: String, +} + +fn parse_args() -> Arguments { + let mut manifest = PathBuf::from("./etc/mw_com_config.json"); + let mut cycles: u32 = 50; + let mut interval_ms: u64 = 100; + let mut instance_specifier = DEFAULT_INSTANCE_SPECIFIER.to_string(); + + let argv: Vec = std::env::args().collect(); + let mut i = 1; + while i < argv.len() { + match argv[i].as_str() { + "-s" | "--service-instance-manifest" => { + i += 1; + manifest = PathBuf::from(argv.get(i).expect("missing value for -s")); + }, + "-n" | "--cycles" => { + i += 1; + cycles = argv + .get(i) + .expect("missing value for -n") + .parse() + .expect("invalid -n/--cycles"); + }, + "-t" | "--interval-ms" => { + i += 1; + interval_ms = argv + .get(i) + .expect("missing value for -t") + .parse() + .expect("invalid -t/--interval-ms"); + }, + "-i" | "--instance-specifier" => { + i += 1; + instance_specifier = argv.get(i).expect("missing value for -i").clone(); + }, + other => eprintln!("FIT_SEND_IGNORED_ARG {other}"), + } + i += 1; + } + + Arguments { + service_instance_manifest: manifest, + cycles, + interval_ms, + instance_specifier, + } +} + +fn init_lola_runtime(config_path: &Path) -> LolaRuntimeImpl { + let mut builder = LolaRuntimeBuilderImpl::new(); + if config_path.exists() { + builder.load_config(config_path); + } else { + eprintln!("FIT_SEND_CONFIG_MISSING path={}", config_path.display()); + } + builder.build().expect("Failed to build Lola runtime") +} + +fn main() { + let args = parse_args(); + let runtime = init_lola_runtime(&args.service_instance_manifest); + + let service_id = InstanceSpecifier::new(&args.instance_specifier).expect("Failed to create InstanceSpecifier"); + let producer = runtime + .producer_builder::(service_id) + .build() + .expect("Failed to build producer instance"); + let offered = producer.offer().expect("Failed to offer producer instance"); + println!("FIT_SEND_OFFERED"); + + for seq in 0..args.cycles { + let uninit_sample = offered.left_tire.allocate().expect("Failed to allocate sample"); + // Encode the sequence number in the payload so the receiver can verify + // ordering and value-integrity independently of anything printed here. + let sample = uninit_sample.write(Tire { pressure: seq as f32 }); + sample.send().expect("Failed to send sample"); + println!("FIT_SEND seq={seq}"); + sleep(Duration::from_millis(args.interval_ms)); + } + + println!("FIT_SEND_DONE count={}", args.cycles); +} diff --git a/showcases/standalone/comm_fit/vehicle_interface.h b/showcases/standalone/comm_fit/vehicle_interface.h new file mode 100644 index 00000000000..53043307dd9 --- /dev/null +++ b/showcases/standalone/comm_fit/vehicle_interface.h @@ -0,0 +1,44 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + ********************************************************************************/ + +// VehicleInterface template for the C++ FIT binaries. +// Matches the /bmw/adp/VehicleInterface service type used by the Rust FIT +// binaries so C++ and Rust binaries are wire-compatible over the same SHM. + +#ifndef SHOWCASES_STANDALONE_COMM_FIT_VEHICLE_INTERFACE_H +#define SHOWCASES_STANDALONE_COMM_FIT_VEHICLE_INTERFACE_H + +namespace score::mw::com::comm_fit +{ + +struct Tire +{ + float pressure; +}; + +struct Exhaust +{ +}; + +template +class VehicleInterface : public Trait::Base +{ + public: + using Trait::Base::Base; + typename Trait::template Event left_tire{*this, "left_tire"}; + typename Trait::template Event exhaust{*this, "exhaust"}; +}; + +} // namespace score::mw::com::comm_fit + +#endif // SHOWCASES_STANDALONE_COMM_FIT_VEHICLE_INTERFACE_H