From 03fcb69e808a87f373fd39d3e4ca340f01fc235a Mon Sep 17 00:00:00 2001 From: Matt <47545907+SoundMatt@users.noreply.github.com> Date: Thu, 30 Jul 2026 10:56:36 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20v2.19.0=20=E2=80=94=20remove=20retired?= =?UTF-8?q?=20Zone/Command/Controller/Registry=20model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes cpp-RCP-FS-01 through cpp-RCP-FS-05, cpp-RCP-05, and cpp-RCP-07 from the 2026-07-29 ecosystem audit (#84-88, #74, #75). cpp-RCP-FS-06/#89 is intentionally NOT included — it's owned by a parallel PR rewriting rcp/acf.hpp's wire-format code, and this PR does not touch that file. - rcp/rcp.hpp is stripped to the three primitives that never depended on the retired model (Errc sentinels minus zone_mismatch, the Context alias, and the generic Loan RAII buffer holder); Zone, Priority, CommandType, Command, Response, Status, StatusChannel, Controller, LoaningController, and Registry are deleted outright. - rcp/legacy_mock.hpp and tooling/zone_manifest_schema.json are deleted. - rcp/config.hpp is rebound onto a stream_key/byte_bus_id topology manifest that bootstraps rcp::shmem::Registry, replacing the Zone-name manifest that constructed legacy_mock::Controller instances. - rcp/faultinject.hpp, rcp/redundancy.hpp, and rcp/admin.hpp are rebound onto rcp::RequestFn / rcp::shmem::Registry instead of the retired rcp::Controller / rcp::Registry. - rcp/adapt.hpp's endpoint_id_to_relay_id/relay_id_to_endpoint_id/ response_to_message/message_to_request drop the stream_key parameter: RELAY spec v2.0 §15.7.5 defines relay.Message.ID for RCP as the plain decimal ByteBusID string, with StreamID carried by the Caller instance itself, not folded into the ID. - relay/relay.hpp's kRelaySpecVersion bumped 1.11 -> 2.0. - rcp/cli.hpp's capabilities_json() drops "loaning" from its reported features (no LoaningController is wired into the CLI-exposed surface). - Test suite rewritten to match: test_rcp.cpp drops its Zone/CommandType/ Priority numeric-value certification in favor of coverage for what rcp.hpp actually defines plus two TC18 conformance checks; test_config/ test_faultinject/test_redundancy/test_admin/test_relay/test_cli/ bench_mock/command_latency_test are all rebound accordingly; test_legacy_mock.cpp is deleted. - README.md quick start, header tables, and error table rewritten around the TC18 model; stale rows for already-deleted headers (federation, firmware, proxy, zonegroup, canbr, linbr, prioqueue) removed. - .fusa-reqs.json: retired-model requirement families removed (54 entries), rebound modules' requirement text updated in place, one new requirement (REQ-LOAN-007) added for Loan's release-exactly-once contract. Full build (GCC 16, C++17) and ctest pass (52/52). See ROADMAP.md's new "Retired-model residue cleanup" entry for full detail, including what was deliberately left unresolved (a new RELAY §8.5-shaped Controller interface is out of scope; clang-tidy/cpfusa could not be run exactly as CI does, since this repo ships no .clang-tidy config and cpfusa is a separate binary not available in this environment). Signed-off-by: Matt <47545907+SoundMatt@users.noreply.github.com> --- .fusa-reqs.json | 516 +++--------------------------- CMakeLists.txt | 2 +- HARA.md | 2 +- README.md | 148 ++++----- ROADMAP.md | 160 +++++++++ include/rcp/adapt.hpp | 103 +++--- include/rcp/admin.hpp | 54 ++-- include/rcp/cli.hpp | 15 +- include/rcp/config.hpp | 148 ++++++--- include/rcp/faultinject.hpp | 77 +++-- include/rcp/legacy_mock.hpp | 276 ---------------- include/rcp/rcp.hpp | 275 +++------------- include/rcp/redundancy.hpp | 100 +++--- include/rcp/version.hpp | 2 +- include/relay/relay.hpp | 27 +- tests/CMakeLists.txt | 4 - tests/bench_mock.cpp | 117 +++---- tests/command_latency_test.cpp | 24 +- tests/test_admin.cpp | 60 ++-- tests/test_cli.cpp | 50 ++- tests/test_config.cpp | 148 +++------ tests/test_faultinject.cpp | 154 +++++---- tests/test_legacy_mock.cpp | 467 --------------------------- tests/test_rcp.cpp | 228 +++++-------- tests/test_redundancy.cpp | 194 ++++++----- tests/test_relay.cpp | 100 +++--- tooling/zone_manifest_schema.json | 59 ---- 27 files changed, 1136 insertions(+), 2374 deletions(-) delete mode 100644 include/rcp/legacy_mock.hpp delete mode 100644 tests/test_legacy_mock.cpp delete mode 100644 tooling/zone_manifest_schema.json diff --git a/.fusa-reqs.json b/.fusa-reqs.json index 44c56f4..da0020b 100644 --- a/.fusa-reqs.json +++ b/.fusa-reqs.json @@ -1,29 +1,5 @@ { "requirements": [ - { - "id": "REQ-PRI-001", - "title": "Priority::Normal has value 0", - "text": "Priority::Normal shall have the numeric value 0, making it the zero value of Priority and the safe default for Command fields left unset.", - "standard": "iso26262", - "level": "ASIL-A", - "asil": "ASIL-A" - }, - { - "id": "REQ-PRI-002", - "title": "Priority::High is greater than Priority::Normal", - "text": "The numeric value of Priority::High shall be strictly greater than Priority::Normal.", - "standard": "iso26262", - "level": "ASIL-A", - "asil": "ASIL-A" - }, - { - "id": "REQ-PRI-003", - "title": "Priority::Critical is greater than Priority::High", - "text": "The numeric value of Priority::Critical shall be strictly greater than Priority::High.", - "standard": "iso26262", - "level": "ASIL-B", - "asil": "ASIL-B" - }, { "id": "REQ-ERR-001", "title": "ErrClosed is truthy", @@ -104,94 +80,6 @@ "level": "ASIL-B", "asil": "ASIL-B" }, - { - "id": "REQ-ERR-011", - "title": "ErrZoneMismatch is a distinct truthy sentinel error", - "text": "ErrZoneMismatch shall be truthy and distinct from all other sentinel errors so callers can detect zone-misrouting without parsing error strings.", - "standard": "iso26262", - "level": "ASIL-B", - "asil": "ASIL-B" - }, - { - "id": "REQ-CMDSTRUCT-001", - "title": "Zero-value Command is a safe no-op", - "text": "A zero-initialised Command shall have zone=Zone::Unknown, type=CommandType::Noop, and priority=Priority::Normal.", - "standard": "iso26262", - "level": "ASIL-B", - "asil": "ASIL-B" - }, - { - "id": "REQ-CMDSTRUCT-002", - "title": "Command.payload may be empty", - "text": "Command.payload shall be optional; an empty payload shall be accepted by any Controller::send implementation without error or crash.", - "standard": "iso26262", - "level": "ASIL-A", - "asil": "ASIL-A" - }, - { - "id": "REQ-RESP-001", - "title": "Response.command_id echoes the originating Command.id", - "text": "Controller::send shall return a Response whose command_id field equals the id field of the submitted Command.", - "standard": "iso26262", - "level": "ASIL-B", - "asil": "ASIL-B" - }, - { - "id": "REQ-RESP-002", - "title": "Response.zone identifies the zone that processed the command", - "text": "Controller::send shall return a Response whose zone field equals the Zone of the Controller that processed the command.", - "standard": "iso26262", - "level": "ASIL-B", - "asil": "ASIL-B" - }, - { - "id": "REQ-RESP-003", - "title": "Zero-value Response has ResponseStatus::OK", - "text": "A zero-initialised Response shall have status=ResponseStatus::OK (value 0).", - "standard": "iso26262", - "level": "ASIL-A", - "asil": "ASIL-A" - }, - { - "id": "REQ-STAT-001", - "title": "Status.zone identifies the publishing controller", - "text": "Every Status published by a Controller shall carry a zone field equal to the Controller's declared zone.", - "standard": "iso26262", - "level": "ASIL-B", - "asil": "ASIL-B" - }, - { - "id": "REQ-STAT-002", - "title": "Status.seq is monotonically increasing", - "text": "Each successive Status published by a given Controller instance shall have a seq value strictly greater than the previous one.", - "standard": "iso26262", - "level": "ASIL-B", - "asil": "ASIL-B" - }, - { - "id": "REQ-STAT-003", - "title": "Status.healthy is true while the controller is open", - "text": "A Status published by a Controller that has not yet been closed shall carry healthy=true.", - "standard": "iso26262", - "level": "ASIL-B", - "asil": "ASIL-B" - }, - { - "id": "REQ-STAT-004", - "title": "Status.payload carries the bytes passed to publish", - "text": "Status.payload in every delivered Status update shall be byte-for-byte identical to the payload argument supplied to the publish call that produced it.", - "standard": "iso26262", - "level": "ASIL-B", - "asil": "ASIL-B" - }, - { - "id": "REQ-STAT-005", - "title": "Status.payload may be empty", - "text": "publish shall accept an empty payload without error or crash. The resulting Status.payload shall be empty.", - "standard": "iso26262", - "level": "ASIL-A", - "asil": "ASIL-A" - }, { "id": "REQ-MOCK-001", "title": "Server starts HW_UNCONFIGURED with a representative two-endpoint register map", @@ -272,326 +160,6 @@ "level": "ASIL-B", "asil": "ASIL-B" }, - { - "id": "REQ-CTRL-001", - "title": "Controller::send returns StatusOK with no handler", - "text": "When no Handler is configured, Controller::send shall return a Response with status=ResponseStatus::OK.", - "standard": "iso26262", - "level": "ASIL-B", - "asil": "ASIL-B" - }, - { - "id": "REQ-CTRL-002", - "title": "Controller::send dispatches to the configured handler", - "text": "When a Handler is configured, Controller::send shall invoke the handler exactly once per send call and return the handler's Response.", - "standard": "iso26262", - "level": "ASIL-B", - "asil": "ASIL-B" - }, - { - "id": "REQ-CTRL-003", - "title": "Controller::send rejected after close", - "text": "Controller::send called after Controller::close shall return ErrClosed.", - "standard": "iso26262", - "level": "ASIL-B", - "asil": "ASIL-B" - }, - { - "id": "REQ-CTRL-004", - "title": "Controller::send respects an already-done context", - "text": "If the Context passed to Controller::send is already done at call time, send shall return ErrTimeout without invoking the handler.", - "standard": "iso26262", - "level": "ASIL-B", - "asil": "ASIL-B" - }, - { - "id": "REQ-CTRL-005", - "title": "Controller::close is idempotent", - "text": "Controller::close shall be safe to call multiple times. Every call after the first shall return a success error_code without side effects.", - "standard": "iso26262", - "level": "ASIL-A", - "asil": "ASIL-A" - }, - { - "id": "REQ-CTRL-006", - "title": "Published Status is delivered to all active subscribers", - "text": "A Status produced by publish shall be received by every channel returned by an active subscribe call on the same controller.", - "standard": "iso26262", - "level": "ASIL-B", - "asil": "ASIL-B" - }, - { - "id": "REQ-CTRL-007", - "title": "Subscribe channels are closed when the controller closes", - "text": "When Controller::close is called, all channels previously returned by subscribe shall be closed.", - "standard": "iso26262", - "level": "ASIL-B", - "asil": "ASIL-B" - }, - { - "id": "REQ-CTRL-008", - "title": "Subscribe rejected after close", - "text": "Controller::subscribe called after Controller::close shall return ErrClosed. No channel shall be returned.", - "standard": "iso26262", - "level": "ASIL-A", - "asil": "ASIL-A" - }, - { - "id": "REQ-CTRL-009", - "title": "Controller::zone returns the declared zone", - "text": "Controller::zone shall return the Zone value provided at construction time for the lifetime of the controller.", - "standard": "iso26262", - "level": "ASIL-A", - "asil": "ASIL-A" - }, - { - "id": "REQ-CTRL-010", - "title": "Subscribe seq values are strictly increasing", - "text": "Successive Status updates received on a subscribe channel shall carry strictly increasing seq values.", - "standard": "iso26262", - "level": "ASIL-B", - "asil": "ASIL-B" - }, - { - "id": "REQ-CTRL-011", - "title": "Subscribe channel is closed when its context is done", - "text": "When the Context passed to subscribe becomes done, the channel returned by that subscribe call shall be closed.", - "standard": "iso26262", - "level": "ASIL-B", - "asil": "ASIL-B" - }, - { - "id": "REQ-CTRL-012", - "title": "Multiple concurrent subscribers each receive published Status", - "text": "When multiple subscribe calls are active on the same controller, each returned channel shall independently receive every Status published via publish.", - "standard": "iso26262", - "level": "ASIL-B", - "asil": "ASIL-B" - }, - { - "id": "REQ-CTRL-013", - "title": "CommandType::Noop is accepted without error", - "text": "Controller::send with Command.type=CommandType::Noop shall return ResponseStatus::OK and shall not return an error.", - "standard": "iso26262", - "level": "ASIL-B", - "asil": "ASIL-B" - }, - { - "id": "REQ-CTRL-014", - "title": "CommandType::Watchdog is accepted without error", - "text": "Controller::send with Command.type=CommandType::Watchdog shall return ResponseStatus::OK and shall not return an error.", - "standard": "iso26262", - "level": "ASIL-B", - "asil": "ASIL-B" - }, - { - "id": "REQ-CTRL-015", - "title": "CommandType::Reset is accepted without error", - "text": "Controller::send with Command.type=CommandType::Reset shall return ResponseStatus::OK and shall not return an error.", - "standard": "iso26262", - "level": "ASIL-B", - "asil": "ASIL-B" - }, - { - "id": "REQ-CTRL-016", - "title": "Handler Response is returned verbatim", - "text": "When a Handler is configured, the Response returned by the handler shall be returned directly by Controller::send without modification.", - "standard": "iso26262", - "level": "ASIL-B", - "asil": "ASIL-B" - }, - { - "id": "REQ-CTRL-017", - "title": "Publish on a closed controller does not crash", - "text": "Calling publish on a controller after Controller::close shall not throw or crash.", - "standard": "iso26262", - "level": "ASIL-B", - "asil": "ASIL-B" - }, - { - "id": "REQ-CTRL-018", - "title": "Concurrent send calls are data-race free", - "text": "Multiple concurrent calls to Controller::send on the same controller shall be free of data races.", - "standard": "iso26262", - "level": "ASIL-B", - "asil": "ASIL-B" - }, - { - "id": "REQ-CTRL-019", - "title": "Concurrent publish and subscribe are data-race free", - "text": "Concurrent calls to publish and subscribe on the same controller shall be free of data races.", - "standard": "iso26262", - "level": "ASIL-B", - "asil": "ASIL-B" - }, - { - "id": "REQ-CTRL-020", - "title": "Subscribe channel carries the correct Zone", - "text": "Every Status received on a subscribe channel shall carry a zone field equal to the subscribing Controller's zone.", - "standard": "iso26262", - "level": "ASIL-B", - "asil": "ASIL-B" - }, - { - "id": "REQ-CTRL-021", - "title": "Subscribe channel carries correct Payload", - "text": "Status.payload on each received update shall be byte-for-byte identical to the payload argument of the publish call that produced it.", - "standard": "iso26262", - "level": "ASIL-B", - "asil": "ASIL-B" - }, - { - "id": "REQ-CTRL-022", - "title": "Subscribe channel carries healthy=true for open controller", - "text": "Status.healthy shall be true on every update published while the controller is open.", - "standard": "iso26262", - "level": "ASIL-B", - "asil": "ASIL-B" - }, - { - "id": "REQ-CTRL-023", - "title": "Done context does not invoke handler", - "text": "If the Context passed to send is already done, the Handler shall not be invoked.", - "standard": "iso26262", - "level": "ASIL-B", - "asil": "ASIL-B" - }, - { - "id": "REQ-CTRL-024", - "title": "Send with empty payload does not crash", - "text": "Controller::send with an empty Command.payload shall not throw or crash.", - "standard": "iso26262", - "level": "ASIL-A", - "asil": "ASIL-A" - }, - { - "id": "REQ-CTRL-025", - "title": "Controller::send rejects commands whose zone does not match the controller zone", - "text": "Controller::send shall return ErrZoneMismatch when cmd.zone does not equal the zone declared by the controller.", - "standard": "iso26262", - "level": "ASIL-B", - "asil": "ASIL-B" - }, - { - "id": "REQ-CTRL-026", - "title": "Controller::send copies Command.payload before passing to the handler", - "text": "Controller::send shall copy Command.payload before invoking the handler. Subsequent modification of the original payload by the caller shall not affect the bytes seen by the handler.", - "standard": "iso26262", - "level": "ASIL-B", - "asil": "ASIL-B" - }, - { - "id": "REQ-CTRL-027", - "title": "Controller::publish copies the payload before embedding in Status", - "text": "Controller::publish shall copy the payload argument before embedding it in the Status value delivered to subscribers.", - "standard": "iso26262", - "level": "ASIL-B", - "asil": "ASIL-B" - }, - { - "id": "REQ-REG-001", - "title": "new_registry pre-populates all five standard zones", - "text": "A Registry returned by mock::new_registry shall contain controllers for Zone::FrontLeft, FrontRight, RearLeft, RearRight, and Central.", - "standard": "iso26262", - "level": "ASIL-B", - "asil": "ASIL-B" - }, - { - "id": "REQ-REG-002", - "title": "Duplicate zone registration returns ErrAlreadyExists", - "text": "Registry::register_ctrl for a zone that already has a registered controller shall return ErrAlreadyExists.", - "standard": "iso26262", - "level": "ASIL-B", - "asil": "ASIL-B" - }, - { - "id": "REQ-REG-003", - "title": "Deregister removes the zone and closes its controller", - "text": "Registry::deregister shall remove the controller for the given zone and call close on it. After deregistration, lookup for the same zone shall return ErrNotFound.", - "standard": "iso26262", - "level": "ASIL-B", - "asil": "ASIL-B" - }, - { - "id": "REQ-REG-004", - "title": "Lookup returns ErrNotFound for an absent zone", - "text": "Registry::lookup for a zone with no registered controller shall return ErrNotFound.", - "standard": "iso26262", - "level": "ASIL-B", - "asil": "ASIL-B" - }, - { - "id": "REQ-REG-005", - "title": "Registry::close is idempotent", - "text": "Registry::close shall be safe to call multiple times. Every call after the first shall succeed without side effects.", - "standard": "iso26262", - "level": "ASIL-A", - "asil": "ASIL-A" - }, - { - "id": "REQ-REG-006", - "title": "Controllers returns all currently registered controllers", - "text": "Registry::controllers shall return exactly the set of controllers currently registered, with no duplicates and no omissions.", - "standard": "iso26262", - "level": "ASIL-A", - "asil": "ASIL-A" - }, - { - "id": "REQ-REG-007", - "title": "Register rejected after Registry::close", - "text": "Registry::register_ctrl called after Registry::close shall return ErrClosed.", - "standard": "iso26262", - "level": "ASIL-A", - "asil": "ASIL-A" - }, - { - "id": "REQ-REG-008", - "title": "Deregister returns ErrNotFound for a zone that was never registered", - "text": "Registry::deregister for a zone with no registered controller shall return ErrNotFound.", - "standard": "iso26262", - "level": "ASIL-B", - "asil": "ASIL-B" - }, - { - "id": "REQ-REG-009", - "title": "Registered controller is immediately retrievable via lookup", - "text": "After a successful Registry::register_ctrl call, Registry::lookup for the same zone shall return the registered controller without error.", - "standard": "iso26262", - "level": "ASIL-B", - "asil": "ASIL-B" - }, - { - "id": "REQ-REG-010", - "title": "Registry::close calls close on all registered controllers", - "text": "Registry::close shall call close on every controller currently registered before returning.", - "standard": "iso26262", - "level": "ASIL-B", - "asil": "ASIL-B" - }, - { - "id": "REQ-REG-011", - "title": "Lookup on a closed Registry returns an error", - "text": "Registry::lookup called after Registry::close shall return a non-success error_code.", - "standard": "iso26262", - "level": "ASIL-B", - "asil": "ASIL-B" - }, - { - "id": "REQ-REG-012", - "title": "Deregister for an already-deregistered zone returns ErrNotFound", - "text": "A second call to Registry::deregister for a zone that was already deregistered shall return ErrNotFound.", - "standard": "iso26262", - "level": "ASIL-A", - "asil": "ASIL-A" - }, - { - "id": "REQ-REG-013", - "title": "Registry::lookup returns ErrClosed when called on a closed registry", - "text": "Registry::lookup called after Registry::close shall return ErrClosed specifically, not ErrNotFound.", - "standard": "iso26262", - "level": "ASIL-B", - "asil": "ASIL-B" - }, { "id": "REQ-UDP-001", "title": "Frame round-trips an NTSCF-framed ACF_ABB request", @@ -1163,7 +731,7 @@ { "id": "REQ-FI-001", "title": "No fault when no rules", - "text": "When no rules are active, send() shall delegate to the inner controller unchanged.", + "text": "When no rules are active, send() shall delegate to the inner RequestFn unchanged.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" @@ -1171,15 +739,15 @@ { "id": "REQ-FI-002", "title": "Drop rule returns error without calling inner", - "text": "A Drop rule shall cause send() to return an error without calling the inner controller.", + "text": "A Drop rule shall cause send() to return an error without calling the inner RequestFn.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" }, { "id": "REQ-FI-003", - "title": "Error rule returns ResponseStatus::Error", - "text": "An Error rule shall return {} (no error code) with the Response status set to ResponseStatus::Error.", + "title": "Error rule returns a response with err set", + "text": "An Error rule shall return {} (no error code) with the response AcfMessageInfo's err flag set, without calling the inner RequestFn.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" @@ -1187,7 +755,7 @@ { "id": "REQ-FI-004", "title": "Slow rule adds configured latency", - "text": "A Slow rule shall sleep for Rule::latency before delegating to the inner controller.", + "text": "A Slow rule shall sleep for Rule::latency before delegating to the inner RequestFn.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" @@ -1203,7 +771,7 @@ { "id": "REQ-FI-006", "title": "clear_rules() removes all active rules", - "text": "clear_rules() shall remove all rules, returning the controller to pass-through mode.", + "text": "clear_rules() shall remove all rules, returning the Interceptor to pass-through mode.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" @@ -1211,15 +779,15 @@ { "id": "REQ-FI-007", "title": "Timeout rule returns ErrTimeout", - "text": "A Timeout rule shall return ErrTimeout without calling the inner controller.", + "text": "A Timeout rule shall return ErrTimeout without calling the inner RequestFn.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" }, { "id": "REQ-FI-008", - "title": "zone() delegates to inner", - "text": "zone() shall return the inner controller's zone.", + "title": "Interceptor is itself usable as an rcp::RequestFn", + "text": "A faultinject::Interceptor shall be usable anywhere an rcp::RequestFn is expected (e.g. passed directly to rcp::Adapt()).", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" @@ -1595,7 +1163,7 @@ { "id": "REQ-RED-001", "title": "RedundantController routes sends to primary", - "text": "RedundantController::send() shall forward commands to the primary controller when it is active.", + "text": "RedundantRequestFn::send() shall forward requests to the primary RequestFn when it is active.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" @@ -1603,7 +1171,7 @@ { "id": "REQ-RED-002", "title": "RedundantController promotes standby on ErrClosed", - "text": "When the primary returns ErrClosed and auto_promote is true, RedundantController shall promote the standby and retry.", + "text": "When the primary returns ErrClosed and auto_promote is true, RedundantRequestFn shall promote the standby and retry.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" @@ -1611,7 +1179,7 @@ { "id": "REQ-RED-003", "title": "RedundantController promotes standby on ErrTimeout", - "text": "When the primary returns ErrTimeout and auto_promote is true, RedundantController shall promote the standby and retry.", + "text": "When the primary returns ErrTimeout and auto_promote is true, RedundantRequestFn shall promote the standby and retry.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" @@ -1619,7 +1187,7 @@ { "id": "REQ-RED-004", "title": "promote() is manually invocable", - "text": "RedundantController::promote() shall switch the active controller between primary and standby.", + "text": "RedundantRequestFn::promote() shall switch the active RequestFn between primary and standby.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" @@ -1627,15 +1195,15 @@ { "id": "REQ-RED-005", "title": "is_primary_active reflects current active", - "text": "RedundantController::is_primary_active() shall return true when primary is active and false when standby is active.", + "text": "RedundantRequestFn::is_primary_active() shall return true when primary is active and false when standby is active.", "standard": "iso26262", "level": "ASIL-A", "asil": "ASIL-A" }, { "id": "REQ-RED-006", - "title": "close() closes both controllers", - "text": "RedundantController::close() shall call close() on both the primary and standby controllers.", + "title": "Standby remains active across repeated requests after a primary failure", + "text": "Once RedundantRequestFn has promoted the standby, subsequent send() calls shall continue to be served by the standby without reverting to the primary on their own.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" @@ -1643,15 +1211,15 @@ { "id": "REQ-RED-007", "title": "auto_promote=false disables automatic failover", - "text": "When Config::auto_promote is false, RedundantController::send() shall not switch to standby on primary failure.", + "text": "When Config::auto_promote is false, RedundantRequestFn::send() shall not switch to standby on primary failure.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" }, { "id": "REQ-RED-008", - "title": "zone() returns zone of active controller", - "text": "RedundantController::zone() shall return the zone of the currently active controller.", + "title": "RedundantRequestFn is itself usable as an rcp::RequestFn", + "text": "A redundancy::RedundantRequestFn shall be usable anywhere an rcp::RequestFn is expected (e.g. passed directly to rcp::Adapt()).", "standard": "iso26262", "level": "ASIL-A", "asil": "ASIL-A" @@ -1722,8 +1290,8 @@ }, { "id": "REQ-ADMIN-001", - "title": "AdminServer::zones() returns all registered controllers", - "text": "AdminServer::zones() shall return one ZoneInfo entry for every controller registered in the wrapped Registry.", + "title": "AdminServer::streams() returns all registered channels", + "text": "AdminServer::streams() shall return one StreamInfo entry for every channel registered in the wrapped shmem::Registry.", "standard": "iso26262", "level": "ASIL-A", "asil": "ASIL-A" @@ -1762,8 +1330,8 @@ }, { "id": "REQ-ADMIN-006", - "title": "ZoneInfo.registered is true for all listed zones", - "text": "All ZoneInfo entries returned by AdminServer::zones() shall have registered = true.", + "title": "StreamInfo.registered is true for all listed streams", + "text": "All StreamInfo entries returned by AdminServer::streams() shall have registered = true.", "standard": "iso26262", "level": "ASIL-A", "asil": "ASIL-A" @@ -1778,8 +1346,8 @@ }, { "id": "REQ-ADMIN-008", - "title": "EventCallback receives event with correct type and zone", - "text": "The Event passed to each EventCallback shall have the EventType and Zone specified in the emit() call.", + "title": "EventCallback receives event with correct type and stream_key", + "text": "The Event passed to each EventCallback shall have the EventType and stream_key specified in the emit() call.", "standard": "iso26262", "level": "ASIL-A", "asil": "ASIL-A" @@ -1850,16 +1418,16 @@ }, { "id": "REQ-CFG-001", - "title": "parse_json extracts zone entries", - "text": "config::parse_json() shall return a Manifest containing one ZoneManifestEntry for each object with a 'zone' key in the input JSON.", + "title": "parse_json extracts endpoint entries", + "text": "config::parse_json() shall return a Manifest containing one EndpointManifestEntry for each object with both a 'stream_key' and a 'byte_bus_id' key in the input JSON.", "standard": "iso26262", "level": "ASIL-A", "asil": "ASIL-A" }, { "id": "REQ-CFG-002", - "title": "parse_json throws ParseError for unknown zones", - "text": "config::parse_json() shall throw config::ParseError if the 'zone' string value does not match a known Zone name.", + "title": "parse_json throws ParseError for an out-of-range byte_bus_id", + "text": "config::parse_json() shall throw config::ParseError if the 'byte_bus_id' value is not in the range [0, 255], or if 'stream_key' does not parse as an integer.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" @@ -1867,23 +1435,23 @@ { "id": "REQ-CFG-003", "title": "parse_json extracts priority field", - "text": "config::parse_json() shall populate ZoneManifestEntry::priority from the 'priority' JSON field when present.", + "text": "config::parse_json() shall populate EndpointManifestEntry::priority from the 'priority' JSON field when present.", "standard": "iso26262", "level": "ASIL-A", "asil": "ASIL-A" }, { "id": "REQ-CFG-004", - "title": "config::load registers controllers in registry", - "text": "config::load() shall call registry.register_ctrl() for each zone defined in the manifest.", + "title": "config::load bootstraps one channel per distinct stream_key", + "text": "config::load() shall call registry.add_channel() once for each distinct stream_key defined in the manifest, regardless of how many endpoints on that stream_key the manifest lists.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" }, { "id": "REQ-CFG-005", - "title": "config::load returns ErrAlreadyExists for duplicate zones", - "text": "config::load() shall return ErrAlreadyExists if the manifest defines the same zone more than once.", + "title": "config::load returns ErrAlreadyExists for a stream_key already registered", + "text": "config::load() shall return ErrAlreadyExists if the target shmem::Registry already has a channel registered for a stream_key named in the manifest.", "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" @@ -2275,7 +1843,7 @@ { "id": "REQ-RELAY-001", "title": "RELAY spec version constant is current (\u00a719.4)", - "text": "relay::kRelaySpecVersion shall report the RELAY spec version the binding targets (\"1.0\").", + "text": "relay::kRelaySpecVersion shall report the RELAY spec version the binding targets, kept in sync with spec/version.json (currently \"2.0\").", "standard": "relay", "level": "QM", "asil": "QM" @@ -2291,7 +1859,7 @@ { "id": "REQ-RELAY-003", "title": "RCP-to-RELAY error equivalence mapping (\u00a75.2)", - "text": "rcp:: sentinel error codes shall be std::error_condition-equivalent to their RELAY counterparts: ErrClosed\u2192ErrClosed, ErrTimeout/ErrBusy\u2192ErrTimeout, ErrNotFound/ErrZoneMismatch\u2192ErrNotConnected; ErrAlreadyExists shall remain standalone.", + "text": "rcp:: sentinel error codes shall be std::error_condition-equivalent to their RELAY counterparts: ErrClosed\u2192ErrClosed, ErrTimeout/ErrBusy\u2192ErrTimeout, ErrNotFound\u2192ErrNotConnected; ErrAlreadyExists shall remain standalone.", "standard": "relay", "level": "QM", "asil": "QM" @@ -2299,7 +1867,7 @@ { "id": "REQ-RELAY-004", "title": "Adapt() wraps a Controller as a relay::Caller (\u00a710.3)", - "text": "rcp::Adapt(RequestFn) shall return a non-null relay::Caller whose protocol() is RCP and whose call()/send() delegate to the wrapped RequestFn, resolving the target server+endpoint from relay::Message.id; subscribe() shall report std::errc::function_not_supported, and close() shall succeed unconditionally.", + "text": "rcp::Adapt(RequestFn) shall return a non-null relay::Caller whose protocol() is RCP and whose call()/send() delegate to the wrapped RequestFn, resolving the target endpoint (byte_bus_id) from relay::Message.id; subscribe() shall report std::errc::function_not_supported, and close() shall succeed unconditionally.", "standard": "relay", "level": "QM", "asil": "QM" @@ -2307,7 +1875,7 @@ { "id": "REQ-RELAY-005", "title": "RELAY core bindings and RCP message mapping (\u00a718.2, \u00a714, \u00a715.7.5)", - "text": "rcp::Context shall alias relay::Context and rcp::StatusChannel shall alias relay::Channel; SubscriberOptions shall default channel_depth=64 and expose topic_name; endpoint_id_to_relay_id()/relay_id_to_endpoint_id() shall round-trip a (stream_key, byte_bus_id) pair through relay::Message.id, and message_to_request()/response_to_message() shall correctly decode/encode the op, evt_op, and response_kind fields.", + "text": "rcp::Context shall alias relay::Context; SubscriberOptions shall default channel_depth=64 and expose topic_name; endpoint_id_to_relay_id()/relay_id_to_endpoint_id() shall round-trip a byte_bus_id through relay::Message.id as its plain decimal string form (RELAY spec v2.0 \u00a715.7.5), and message_to_request()/response_to_message() shall correctly decode/encode the op, evt_op, and response_kind fields.", "standard": "relay", "level": "QM", "asil": "QM" @@ -3335,6 +2903,14 @@ "standard": "iso26262", "level": "ASIL-B", "asil": "ASIL-B" + }, + { + "id": "REQ-LOAN-007", + "title": "Loan releases exactly once, via destruction or ret()", + "text": "rcp::Loan shall invoke its bound release function exactly once over its lifetime, whether that release happens via destruction or an explicit ret() call, and a default-constructed Loan (no release function bound) shall be safe to destroy.", + "standard": "iso26262", + "level": "ASIL-B", + "asil": "ASIL-B" } ] } diff --git a/CMakeLists.txt b/CMakeLists.txt index ba17f58..d73d7c1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,7 +1,7 @@ cmake_minimum_required(VERSION 3.21) project(cpp-rcp - VERSION 2.18.0 + VERSION 2.19.0 DESCRIPTION "Remote Control Protocol for automotive zonal architecture (C++)" HOMEPAGE_URL "https://github.com/SoundMatt/cpp-RCP" LANGUAGES CXX diff --git a/HARA.md b/HARA.md index c617235..52845c9 100644 --- a/HARA.md +++ b/HARA.md @@ -58,7 +58,7 @@ Phase 13-16 work are appended as H-011/SG-011. | SG-006 | Transport authentication (mTLS on the UDP/IP variant, or link-layer authentication on native Ethernet) and per-endpoint access policy shall be enforced on every external RC Server connection. | ASIL-B | `tls::SecureClient`/`SecureServer`, `authz::AccessPolicy`, `discovery::DiscoveryClaim` | | SG-007 | An RC Server that stops responding shall be detected as unreachable within the configured liveness deadline. | ASIL-B | `deadline::Monitor`/`LivenessTracker` | | SG-008 | An RC Server shall only be treated as operational after its lifecycle state machine reports RCP_CONFIGURED following a successful wake sequence. | ASIL-B | `lifecycle::ServerLifecycle`, `powerstate::PowerManager` | -| SG-009 | Fault injection rules shall not persist beyond the lifetime of the injecting process. | ASIL-A | `faultinject::Controller` in-process state only | +| SG-009 | Fault injection rules shall not persist beyond the lifetime of the injecting process. | ASIL-A | `faultinject::Interceptor` in-process state only | | SG-010 | A stream's watchdog/safe-state status shall be deterministically derivable from its own kick/overflow/latch history alone. | ASIL-B | `e2e::RxWatchdog`, `watchdog::StreamWatchdog` deterministic state | | SG-011 | A request whose computed E2E CRC does not match its received trailer shall be rejected, and a stream configured with rx_enforce_e2e shall latch safe state on the first such failure until explicitly reset. | ASIL-B | `e2e::verify_crc`, `e2e::RxStreamGuard` | diff --git a/README.md b/README.md index 98e0a72..3b5fa11 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # cpp-RCP -A C++17 library implementing the Remote Control Protocol (RCP) for zonal control in automotive systems. +A C++17 library implementing the OPEN Alliance TC18 Remote Control Protocol (RCP) Specification v0.5.1_RC for zonal control in automotive systems. -RCP connects a high-performance central computer to distributed Ethernet-based zone controllers, keeping application logic centralised while remote zones provide access to local I/O, sensors, CAN/LIN gateways, and actuators. +RCP connects a high-performance central computer (an RC Client) to distributed Ethernet-based RC Servers over IEEE 1722 AVTPDU/ACF framing, keeping application logic centralised while remote Endpoints provide access to local I/O, sensors, CAN/LIN gateways, and actuators. Feature and API equivalent of [go-RCP](https://github.com/SoundMatt/go-RCP). @@ -21,11 +21,15 @@ reference. | Header | Description | |---|---| -| `` | Core interfaces: `Controller`, `Registry`, `Command`, `Response`, `Status`, `Zone`, `Context`, `StatusChannel` | -| `` | `Adapt()` — the RELAY §10.3 entry point, wraps a `RequestFn` (a client-side send-equivalent call) as a `relay::Caller`; `response_to_message`/`message_to_request` conversions, keyed by a server+endpoint identifier (ROADMAP.md v2.16.0) | +| `` | TC18 wire codec, framing half — IEEE 1722 AVTPDU (NTSCF/TSCF) header framing, `StreamId`, `ByteBusId` | +| `` | TC18 wire codec, message half — ACF_ABB/ACF_GBB message format, `AcfMessageInfo` | +| `` | RC Server register-map model (generic + functional config, EP0) | +| `` | RC Server lifecycle state machine | +| `` | Conditional-request taxonomy (compound, triggered, timed, chained, ...) and sequencers | +| `` | Shared primitives used codebase-wide: the `Errc` sentinel error category, `Context` (a `relay::Context` alias), and `Loan` (a generic RAII buffer-loan holder) | +| `` | `Adapt()` — the RELAY §10.3 entry point, wraps a `RequestFn` (a client-side send-equivalent call) as a `relay::Caller`; `response_to_message`/`message_to_request` conversions, addressed by the endpoint's `byte_bus_id` (RELAY spec §15.7.5) | | `` | `relay::` namespace types (§18.2): `Protocol`, `Message`, `Errc` sentinels, `Channel`, `Context`, `Node`, `Caller` | | `` | In-process TC18 RC Server simulator (lifecycle + register map + GPIO/SPI) — zero I/O, default for unit tests (ROADMAP.md v2.12.0) | -| `` | Pre-replacement in-process `Controller`/`Registry`, kept only for the one old-model dependent not yet rebound to the new request model (`config.hpp`) | | `` | RELAY-conformant CLI (§11/§12): `version`/`capabilities`/`status`/`send`; `send` addresses an RC Server endpoint via `--server`/`--endpoint` against the `` demo backend; `cli/main.cpp` is a thin wrapper around it (ROADMAP.md v2.16.0) | | `` | Binary version string — single source of truth for the CLI | | `` / `` | C ABI / FFI surface for RTOS/bare-metal targets (Zephyr/FreeRTOS): server+endpoint addressing, a caller-supplied `rcp_request_fn_t` callback, no heap allocation (ROADMAP.md v2.16.0) | @@ -34,41 +38,41 @@ reference. | Header | Description | |---|---| -| `` | CAN / CAN-FD bridge — maps RCP commands to CAN frames via SocketCAN | -| `` | LIN bridge — maps RCP commands to LIN master-frame requests | -| `` | DDS bridge — publishes RCP commands as DDS typed topics | -| `` | MQTT bridge — publishes commands to MQTT topics | -| `` | SOME/IP bridge — routes commands over SOME/IP service discovery | -| `` | REST/HTTP bridge — maps commands to `POST /zones/{zone}/command` | +| `` | DDS bridge — publishes RCP requests as DDS typed topics | +| `` | MQTT bridge — publishes requests to MQTT topics | +| `` | SOME/IP bridge — routes requests over SOME/IP service discovery | +| `` | REST/HTTP bridge — maps requests to `POST /endpoints/{byte_bus_id}/request` | | `` | gRPC bridge — translates RCP wire frames to gRPC unary/streaming RPCs | | `` | DoIP (ISO 13400) bridge — encapsulates UDS requests over TCP/IP | -| `` | UDS (ISO 14229) bridge — wraps RCP commands as UDS service requests | +| `` | UDS (ISO 14229) bridge — wraps RCP requests as UDS service requests | + +CAN and LIN are native TC18 Endpoint types (see ``/``), not bridge targets, so this package carries no `canbr.hpp`/`linbr.hpp`. ### RCP control-plane concerns | Header | Description | |---|---| -| `` | Command-level access control (ISO 21434 / IEC 62443 SL-2) | +| `` | Request-level access control (ISO 21434 / IEC 62443 SL-2) | | `` | End-to-end communication protection (ISO 26262 Part 7 E2E profile) | -| `` | Liveness deadline monitor for zone controller Status streams | -| `` | Watchdog keeper — periodic `CommandType::Watchdog` kicks (ASIL-B) | -| `` | Multi-HPC federation: cross-HPC zone forwarding with lease-based ownership | -| `` | Hot-standby registry and HPC failover for ASIL-B fault tolerance | -| `` | Zone controller OTA firmware update session | -| `` | Structured fault injection for validating safety mechanisms | -| `` | Zone registry loader from JSON/YAML configuration files | -| `` | In-process Admin API: zone listing, SSE events, Prometheus metrics | -| `` | Per-zone token-bucket admission control against command flooding | -| `` | Zone controller power state manager (Sleep/Wake) | -| `` | Per-zone priority queue honouring `Priority::Critical` > `High` > `Normal` | -| `` | Transparent zone proxy for cascaded zonal topologies | -| `` | Atomic multi-zone command broadcast with typed zone group sets | +| `` | Liveness deadline monitor for RC Server connections | +| `` | Watchdog keeper — periodic per-stream kicks (ASIL-B) | +| `` | Hot-standby primary/standby failover for ASIL-B fault tolerance, over a pair of `RequestFn`s | +| `` | Structured fault injection for validating safety mechanisms, wrapping a `RequestFn` | +| `` | RC Server/endpoint topology manifest loader from JSON/YAML configuration files, bootstrapping an `rcp::shmem::Registry` | +| `` | In-process Admin API: stream listing, SSE events, Prometheus metrics, over an `rcp::shmem::Registry` | +| `` | Per-target token-bucket admission control against request flooding | +| `` | RC Server power state manager (Sleep/Wake) | | `` | Runtime schema registry and dynamic payload encoding | | `` | `loan::BufferPool` — zero-copy payload loaning via a pre-allocated pool, for AVTPDU/ACF request-building (ROADMAP.md v2.14.0) | | `` | Binary record and replay of RC-Client-level request/response traffic (ROADMAP.md v2.14.0) | | `` | OpenTelemetry-style observability: spans and counters around a client-side send-equivalent call (ROADMAP.md v2.14.0) | | `` | mDNS/DNS-SD host:port discovery (RFC 6762/6763), scoped to the UDP/IP transport variant (ROADMAP.md v2.14.0) | +Multi-HPC federation, a transparent zone-proxy, atomic multi-zone broadcast, +client-side priority queuing, and OTA firmware update were part of the +retired pre-TC18 model and have no TC18 analog (see ROADMAP.md's Satellite +Package Disposition table); their headers were removed rather than adapted. + ### Transports | Header | Description | @@ -77,8 +81,6 @@ reference. | `` | Secure-channel option for the UDP/IP transport variant (DTLS/application-layer, `SecureClient`/`SecureServer`); the specification's own preferred link security is MACsec (802.1AE) at layer 2, which this package does not address (ROADMAP.md v2.14.0) | | `` | Zero-copy in-process request `Channel`/`Registry`, keyed by opaque stream_key (ROADMAP.md v2.14.0) | | `` | IEEE 802.1p PCP-priority hint (`apply_priority`) keyed off `rcp::request::RequestCategory`'s execution-priority ordering; prefer genuine IEEE 1722 stream reservation where available (ROADMAP.md v2.14.0) | -| `` | TC18 wire codec, framing half — IEEE 1722 AVTPDU (NTSCF/TSCF) header framing (ROADMAP.md v2.0.0) | -| `` | TC18 wire codec, message half — ACF_ABB/ACF_GBB message format (ROADMAP.md v2.0.0) | | `` | Timing-realistic RC Server simulator for SiL/HIL testing — wraps `` with latency/jitter and Fault/Recover controls, watchdog wired via `` (ROADMAP.md v2.12.0) | ## Build @@ -93,74 +95,58 @@ ctest --test-dir build --output-on-failure ## Quick start -The example below uses ``'s pre-replacement -`Controller`/`Registry` pair, since `rcp/rcp.hpp`'s `Zone`/`Command`/ -`Response` model is itself still pre-replacement (see ROADMAP.md's -Satellite Package Disposition table). For a TC18-shaped in-process server, -see ``'s `mock::Server` instead. - ```cpp -#include -#include +#include +#include +#include #include int main() { - auto reg = rcp::legacy_mock::new_registry(); - - std::shared_ptr ctrl; - reg->lookup(rcp::Zone::FrontLeft, ctrl); - - rcp::Command cmd; - cmd.id = 1; - cmd.zone = rcp::Zone::FrontLeft; - cmd.type = rcp::CommandType::Set; - cmd.priority = rcp::Priority::Normal; - cmd.payload = {0x01, 0x02}; - - rcp::Response resp; - auto ec = ctrl->send(rcp::Context::background(), cmd, resp); + // rcp::mock::Server is an in-process TC18 RC Server simulator — no I/O, + // suitable for tests and this example alike. A real deployment dials + // rcp::udp::Client (or another transport) instead and wraps its own + // send-equivalent call the same way. + rcp::mock::Server server; + auto ec = server.advance_to_rcp_configured(); assert(!ec); - assert(resp.status == rcp::ResponseStatus::OK); - reg->close(); + // RequestFn is the "client-side send-equivalent call" every transport + // and control-plane decorator in this library standardizes on. + rcp::RequestFn request_fn = [&server](const rcp::Context&, + const rcp::acf::AcfMessageInfo& req, + const std::vector& payload, + rcp::acf::AcfMessageInfo& out, + std::vector& out_payload) { + return server.dispatch(/*client=*/0, req, payload, out, out_payload); + }; + + // Adapt() wraps any RequestFn as a relay::Caller (RELAY spec §10.3), so + // application code can address the RC Server through the + // protocol-agnostic relay::Node/relay::Caller interface. + auto caller = rcp::Adapt(request_fn); + + relay::Message req; + req.protocol = relay::Protocol::RCP; + req.id = rcp::endpoint_id_to_relay_id(rcp::mock::kGpioByteBusId); + req.meta["rcp.op"] = "read"; + + auto [resp, call_ec] = caller->call(relay::Context::with_timeout(std::chrono::seconds(1)), req); + assert(!call_ec); + assert(resp.protocol == relay::Protocol::RCP); } ``` -## Zones - -| Constant | Value | Description | -|---|---|---| -| `Zone::Unknown` | 0 | Zero value / uninitialized | -| `Zone::FrontLeft` | 1 | Front-left zone controller | -| `Zone::FrontRight` | 2 | Front-right zone controller | -| `Zone::RearLeft` | 3 | Rear-left zone controller | -| `Zone::RearRight` | 4 | Rear-right zone controller | -| `Zone::Central` | 5 | Central zone controller | - -## Command types - -| Constant | Value | Description | -|---|---|---| -| `CommandType::Noop` | 0 | No-op / keepalive | -| `CommandType::Set` | 1 | Set an output or actuator state | -| `CommandType::Get` | 2 | Query current state | -| `CommandType::Reset` | 3 | Reset zone controller | -| `CommandType::Watchdog` | 4 | Watchdog kick | -| `CommandType::Sleep` | 5 | Request zone controller to enter low-power sleep | -| `CommandType::Wake` | 6 | Request zone controller to exit sleep | - ## Error codes Errors are returned as `std::error_code` values in the `rcp` category. | Sentinel | Description | |---|---| -| `rcp::ErrClosed` | Controller or registry is closed | -| `rcp::ErrNotFound` | Zone not found in registry | -| `rcp::ErrAlreadyExists` | Zone already registered | -| `rcp::ErrTimeout` | Command timed out or context expired | -| `rcp::ErrBusy` | Zone controller busy (rate limit hit) | -| `rcp::ErrZoneMismatch` | Command addressed to wrong zone | +| `rcp::ErrClosed` | Connection or resource is closed | +| `rcp::ErrNotFound` | Requested resource (e.g. a registry entry) not found | +| `rcp::ErrAlreadyExists` | Resource already registered | +| `rcp::ErrTimeout` | Request timed out or context expired | +| `rcp::ErrBusy` | Resource busy (e.g. rate limit hit) | ## Safety diff --git a/ROADMAP.md b/ROADMAP.md index ab6882c..a969403 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1567,6 +1567,166 @@ architecture is a substantial, separately-scoped task this milestone's own text does not name. Milestone #63 (TC18 RCP General Availability, v3.0.0) is next and, per its own scope, is the final milestone in this roadmap. +### Retired-model residue cleanup (2026-07-30, v2.19.0 — audit findings cpp-RCP-FS-01..05, cpp-RCP-05, cpp-RCP-07) + +Milestone 62's own closing note above named this exact gap and deferred it: +`rcp/config.hpp` was still the one live compiled dependent of `rcp.hpp`'s +pre-replacement `Zone`/`Command`/`Response`/`Status`/`Priority`/ +`CommandType`/`Controller`/`LoaningController`/`Registry` model, which kept +that entire retired surface — plus `rcp/legacy_mock.hpp` and the three +other headers still built against it — alive as compiled, tested, public +API. This pass finishes that removal end to end, closing cpp-RCP-FS-01 +through cpp-RCP-FS-05 and the two smaller cpp-RCP-05/cpp-RCP-07 findings +from the same audit register (cpp-RCP-FS-06 — a canonical `ControlFlags`/ +`Message` type in `rcp/acf.hpp` — is intentionally left to the parallel +pass already rewriting that file's wire-format code, to avoid two PRs +touching the same header's bit-packing logic at once). + +`rcp/config.hpp` is rebound first, per the dependency `rcp.hpp`'s own +header comment named: `ZoneManifestEntry`/`Manifest.zones`/`load(json, +rcp::Registry&)` become `EndpointManifestEntry`/`Manifest.endpoints`/ +`load(json, shmem::Registry&)`, parsing a `stream_key`+`byte_bus_id` +manifest and bootstrapping one `shmem::Channel` per distinct `stream_key` +instead of constructing one `legacy_mock::Controller` per zone name — the +embedding application still owns wiring each `Channel`'s `Handler`, since +there is no more generic in-process `Controller` this loader can construct +on the caller's behalf. `tooling/zone_manifest_schema.json` (the retired +zone-manifest JSON schema) and `rcp/legacy_mock.hpp` itself are then +deleted outright, along with `tests/test_legacy_mock.cpp`, and `rcp.hpp` is +stripped down to the three primitives that never depended on the retired +model: the `Errc` sentinel category (minus `zone_mismatch`/ +`ErrZoneMismatch`, which had no TC18 analog), the `Context` alias, and +`Loan` (a generic RAII buffer holder — `rcp/loan.hpp`'s `BufferPool` still +hands these out and is otherwise untouched). `rcp::StatusChannel` +(`relay::Channel`) has no meaning once `Status` is gone and is +removed with it; §18.2's own worked example still names it, which is a +RELAY-spec-side documentation lag this PR flags rather than tries to fix +from the cpp-RCP side. + +`rcp/faultinject.hpp`, `rcp/redundancy.hpp`, and `rcp/admin.hpp` are +rebound rather than deleted, since each is a genuine mechanism with no +TC18-side replacement of its own: `faultinject::Controller` becomes +`faultinject::Interceptor`, wrapping an `rcp::RequestFn` (rcp/adapt.hpp's +client-side send-equivalent call, the same shape rcp/record.hpp and +rcp/observe.hpp already standardize on) instead of decorating +`rcp::Controller`; the injected "error" case now sets +`AcfMessageInfo::err` instead of returning the retired +`ResponseStatus::Error`. `redundancy::RedundantController` becomes +`redundancy::RedundantRequestFn`, holding a primary/standby pair of +`RequestFn`s instead of `rcp::Controller`s. `admin::AdminServer` moves +from reporting `Zone`-keyed `rcp::Registry` state to reporting +stream-keyed `rcp::shmem::Registry` state (`ZoneInfo`/`Event.zone` become +`StreamInfo`/`Event.stream_key`). None of the three preserve a `close()` +or `zone()`/`subscribe()` passthrough that has no analog on a plain +`RequestFn`; their test suites (`test_faultinject.cpp`, +`test_redundancy.cpp`, `test_admin.cpp`) are rewritten against +`rcp::mock::Server`-backed `RequestFn`s and `rcp::shmem::Registry` instead +of `legacy_mock`, and `test_config.cpp` similarly against the rebound +loader. + +`tests/test_rcp.cpp` (cpp-RCP-FS-04) is purged of its +`Zone::FrontLeft==1`/`CommandType::Set==1`/`Priority` ordering +certification cases and now covers what `rcp.hpp` actually still defines +(`Errc` sentinels, `Context`, `Loan`'s release-exactly-once contract) plus +two supplementary TC18 conformance checks (`avtp::ByteBusId`'s single-byte +range, `AcfMessageInfo`'s zero-value defaults) that don't duplicate +`test_acf.cpp`/`test_avtp.cpp`'s own much larger suites. +`tests/bench_mock.cpp`/`tests/command_latency_test.cpp` (not named in the +audit but both compiled directly against `legacy_mock::Controller`) are +rebound to benchmark/time `rcp::mock::Server::dispatch()` instead — the +transport their own names already referred to. + +`rcp/adapt.hpp` (cpp-RCP-FS-05): `endpoint_id_to_relay_id`/ +`relay_id_to_endpoint_id`/`response_to_message`/`message_to_request` drop +the `stream_key` parameter entirely. RELAY spec v2.0 §15.7.5 defines +`relay.Message.ID` for RCP as just the decimal `ByteBusID` string (e.g. +`"9"`), with the `StreamID` carried by the `Caller`/connection object +itself (§8.5: one `StreamID` per `Caller` instance) rather than folded +into the ID — this file previously encoded +`":"`, which does not +round-trip through anything expecting the spec's plain decimal form. +`rcp/cli.hpp`'s `send` keeps its existing `--server`/`--endpoint` flags +unchanged for backward CLI compatibility, but `--server` is now validated +for shape only and no longer folded into the constructed `relay::Message`, +since this CLI's demo backend was already a single hardcoded +`mock::Server` regardless of `--server`'s value — the flag never actually +selected among multiple backends even before this fix, only its string +representation in `id` changes here. + +`relay/relay.hpp`'s `kRelaySpecVersion` (cpp-RCP-05, #74) is bumped +`"1.11"` → `"2.0"`, the current stable RELAY spec version +(`spec/version.json`), which retired the placeholder RCP model this PR +finishes removing and renamed the RCP-facing client interface to +`Controller` in the process (RELAY spec §8.5 — a distinct, `StreamID`- +scoped request/response interface, not the deleted `rcp::Controller`; +introducing that interface itself is out of scope here, since none of the +findings this PR closes ask for it, and `rcp::RequestFn` already covers +the same "client-side send-equivalent call" role every decorator in this +tree already standardizes on). A full re-read of every in-repo RELAY-spec +`§`-citation against the current spec text (not just the two the finding +named) turned up only the version-string gap: `avtp.hpp`/`acf.hpp`'s +`§13.7.2` module-name-registry citations and `rcp.hpp`'s `§18.2` +Context/StatusChannel citations both still match section content actually +present in the current v2.0 document, so neither warranted a citation +fix — recorded here since the finding text itself flagged this as +unverified and asked for a fresh check. + +`rcp/cli.hpp`'s `capabilities_json()` (cpp-RCP-07, #75) drops `"loaning"` +from its reported `features` array — no `LoaningController` (itself +deleted with the rest of the retired model) is wired into the CLI-exposed +`Adapt()` path, so the self-report was overclaiming a capability the +binary didn't actually expose. + +`README.md` — deferred at milestone 62 as "a substantial, separately- +scoped task" — is rewritten: the Quick Start no longer uses +`rcp/legacy_mock.hpp`/`Zone`/`CommandType`/`Priority` and instead builds an +`rcp::mock::Server`, wraps its `dispatch()` as an `rcp::RequestFn`, and +calls it through `rcp::Adapt()`; the "## Zones" and "## Command types" +sections and the `legacy_mock.hpp`/`rcp.hpp`-as-Controller-and-Registry +core-table rows are gone; the error table drops `ErrZoneMismatch`; and the +protocol-bridge/control-plane module rows for `federation.hpp`, +`firmware.hpp`, `proxy.hpp`, `zonegroup.hpp`, `canbr.hpp`, `linbr.hpp`, and +`prioqueue.hpp` — headers already deleted at earlier milestones per the +Satellite Package Disposition table below, but still documented as if +present — are removed. + +`HARA.md`'s SG-009 row is updated to name `faultinject::Interceptor` +instead of the now-renamed `faultinject::Controller`; no other hand- +authored safety/security document is touched, and `.fusa-hara.json`/ +`fmea.*`/`tara.*`/`safety-case.*`/etc. are left for `release.yml`'s +existing tag-triggered regeneration, per every prior milestone's own +convention. `.fusa-reqs.json`: the `REQ-PRI-*`/`REQ-CMDSTRUCT-*`/ +`REQ-RESP-*`/`REQ-STAT-*`/`REQ-ERR-011`/`REQ-CTRL-*`/`REQ-REG-*` families +(54 entries — the placeholder-model requirement groups milestone 62's own +scope note explicitly left untouched) are removed; `REQ-FI-*`/`REQ-RED-*`/ +`REQ-ADMIN-*`/`REQ-CFG-*`/`REQ-RELAY-001/003/004/005` keep their existing +ids with text updated to match the rebound behavior; one new id, +`REQ-LOAN-007`, covers `Loan`'s release-exactly-once contract now that +`Loan` lives directly in `rcp.hpp`. Every id in the resulting 364-entry +file has exactly one `fusa:req` and one `fusa:test` tag somewhere in the +tree (checked by hand pending a real `cpfusa trace` run, since the +`cpfusa` binary itself isn't available in this environment). + +`version.hpp`/`CMakeLists.txt` are bumped to 2.19.0. Full local build +(GCC 16, C++17, Ninja) and `ctest` (52/52 tests — one target, +`rcp_legacy_mock`, removed along with the file it built) pass, including +the 30-second `rcp_latency` safety-timing gate. `clang-tidy` could not be +run against this repo's own CI invocation as-is: there is no `.clang-tidy` +config committed, so `clang-tidy -p build --warnings-as-errors='*'` with +no explicit `--checks` reports "no checks enabled" and exits nonzero +before analyzing anything (CI's own step already masks this with +`|| true`, so it has not been gating merges). A representative +`--checks='clang-diagnostic-*,clang-analyzer-*,bugprone-*,performance-*'` +run against every file this PR touches found nothing new: the remaining +hits are Catch2's own `TEST_CASE` static-registration pattern (present +identically in every test file in the tree) and a couple of +`bugprone-branch-clone`/`bugprone-std-namespace-modification` hits on +switch/namespace-specialization shapes carried over unchanged from the +pre-existing code this PR migrated, not introduced by it. `cpfusa` +check/lint/trace and RELAY `conform --strict` were not run locally (the +`cpfusa` binary requires building from its own separate repo, as CI's own +`cpfusa-build` job does) — flagged for CI to confirm. + ### 63. TC18 RCP — General Availability (v3.0.0) - First release where cpp-RCP's `RCP` conforms to the OPEN Alliance TC18 diff --git a/include/rcp/adapt.hpp b/include/rcp/adapt.hpp index e36f224..6d2ec98 100644 --- a/include/rcp/adapt.hpp +++ b/include/rcp/adapt.hpp @@ -19,7 +19,7 @@ // }; // auto caller = rcp::Adapt(fn); // relay::Caller* // relay::Message req; -// req.id = rcp::endpoint_id_to_relay_id(stream_key, byte_bus_id); +// req.id = rcp::endpoint_id_to_relay_id(byte_bus_id); // auto [resp, ec] = caller->call(ctx, req); // // ROADMAP.md milestone 60, "C ABI & CLI Rebuild (v2.16.0)": this header is @@ -28,7 +28,7 @@ // rcp.hpp's pre-replacement Zone/Command/Controller model to the ACF // request/response shape (rcp/acf.hpp, v2.0.0) established through v2.15.0. // relay::Message addressing moves from a PascalCase zone name in -// relay::Message.id to a server+endpoint identifier there instead (see +// relay::Message.id to a plain decimal ByteBusID there instead (see // endpoint_id_to_relay_id/relay_id_to_endpoint_id below); relay/relay.hpp // itself needs no change, per this milestone's own scope note. Adapt() now // takes a RequestFn — the same "client-side send-equivalent call" shape @@ -42,6 +42,16 @@ // StatusChannel method already made at v2.15.0 — it belonged to // rcp::Controller's status-telemetry push model, which the target // specification's request/response shape has no equivalent of. +// +// Message.id encoding (cpp-RCP-FS-05, #88): RELAY spec v2.0 §15.7.5 defines +// relay.Message.ID for RCP as just the decimal ByteBusID string (0-255) on +// its own; a Caller (and the Adapt() wrapping it) already presents one fixed +// StreamID identity per §8.5, so the stream/server identity is carried by +// the RequestFn's own binding (e.g. whatever connection or in-process server +// it closes over) rather than folded into Message.id. Prior to this fix, +// this file encoded ":" +// into Message.id, which does not match the spec's plain decimal-string +// form and would have broken interop with anything expecting it. #pragma once #include @@ -62,50 +72,27 @@ namespace rcp { -// ── server+endpoint ↔ relay::Message.ID helpers (§15.7.5) ──────────────────── -// relay::Message carries a single string `id` field; a server+endpoint -// address (stream_key, byte_bus_id — the pair every Phase 14/15 header has -// keyed on since v2.10.0) is encoded into it as -// ":" — this -// implementation's own encoding, not something the target specification or -// the RELAY spec mandates a shape for. - -// endpoint_id_to_relay_id encodes (stream_key, byte_bus_id) into a -// relay::Message.id string. -inline std::string endpoint_id_to_relay_id(uint64_t stream_key, avtp::ByteBusId byte_bus_id) { - static const char* kHex = "0123456789abcdef"; - std::string out(16, '0'); - for (int i = 15; i >= 0; --i) { - out[static_cast(i)] = kHex[stream_key & 0xF]; - stream_key >>= 4; - } - out += ':'; - out += std::to_string(static_cast(byte_bus_id)); - return out; +// ── byte_bus_id ↔ relay::Message.ID helpers (§15.7.5) ───────────────────────── +// relay.Message.ID for RCP is just the decimal ByteBusID string (0-255) — +// the StreamID is not part of it (§15.7.5, §8.5: one StreamID per Caller +// instance). + +// endpoint_id_to_relay_id encodes a byte_bus_id into a relay::Message.id +// string. +inline std::string endpoint_id_to_relay_id(avtp::ByteBusId byte_bus_id) { + return std::to_string(static_cast(byte_bus_id)); } // relay_id_to_endpoint_id decodes a relay::Message.id string produced by -// endpoint_id_to_relay_id back into (stream_key, byte_bus_id). Returns false -// — leaving both outputs unspecified — for anything that doesn't match the -// "<16 hex digits>:" shape, including the pre-v2.16.0 PascalCase -// zone-name form this replaces. -inline bool relay_id_to_endpoint_id(const std::string& id, uint64_t& out_stream_key, - avtp::ByteBusId& out_byte_bus_id) { - auto colon = id.find(':'); - if (colon == std::string::npos || colon != 16) return false; - - uint64_t stream_key = 0; - auto [p1, ec1] = std::from_chars(id.data(), id.data() + colon, stream_key, 16); - if (ec1 != std::errc{} || p1 != id.data() + colon) return false; - +// endpoint_id_to_relay_id back into a byte_bus_id. Returns false — leaving +// the output unspecified — for anything that isn't a bare decimal integer +// in [0, 255], including the pre-#88 "<16 hex digits>:" form this +// replaces and the pre-v2.16.0 PascalCase zone-name form before that. +inline bool relay_id_to_endpoint_id(const std::string& id, avtp::ByteBusId& out_byte_bus_id) { + if (id.empty()) return false; unsigned bus_id = 0; - const char* rest_begin = id.data() + colon + 1; - const char* rest_end = id.data() + id.size(); - if (rest_begin == rest_end) return false; - auto [p2, ec2] = std::from_chars(rest_begin, rest_end, bus_id, 10); - if (ec2 != std::errc{} || p2 != rest_end || bus_id > 0xFF) return false; - - out_stream_key = stream_key; + auto [p, ec] = std::from_chars(id.data(), id.data() + id.size(), bus_id, 10); + if (ec != std::errc{} || p != id.data() + id.size() || bus_id > 0xFF) return false; out_byte_bus_id = static_cast(bus_id); return true; } @@ -131,13 +118,13 @@ inline uint8_t evt_op_from_meta(const std::map& meta) // ── ToMessage / FromMessage (§15.7.5) ──────────────────────────────────────── -// response_to_message converts an ACF response (rcp/acf.hpp) plus the -// stream_key it arrived on into a relay::Message (call direction). -inline relay::Message response_to_message(uint64_t stream_key, const acf::AcfMessageInfo& resp, +// response_to_message converts an ACF response (rcp/acf.hpp) into a +// relay::Message (call direction). +inline relay::Message response_to_message(const acf::AcfMessageInfo& resp, const std::vector& payload) { relay::Message msg; msg.protocol = relay::Protocol::RCP; - msg.id = endpoint_id_to_relay_id(stream_key, resp.byte_bus_id); + msg.id = endpoint_id_to_relay_id(resp.byte_bus_id); msg.payload = payload; msg.timestamp = std::chrono::system_clock::now(); msg.meta["rcp.response_kind"] = std::to_string(static_cast(acf::response_kind_of(resp))); @@ -147,11 +134,11 @@ inline relay::Message response_to_message(uint64_t stream_key, const acf::AcfMes // message_to_request converts a relay::Message into an ACF standard request // (call/send direction). Returns false — leaving all outputs unspecified — -// if msg.id does not decode as a server+endpoint identifier. -inline bool message_to_request(const relay::Message& msg, uint64_t& out_stream_key, +// if msg.id does not decode as a byte_bus_id. +inline bool message_to_request(const relay::Message& msg, acf::AcfMessageInfo& out_info, std::vector& out_payload) { avtp::ByteBusId byte_bus_id = 0; - if (!relay_id_to_endpoint_id(msg.id, out_stream_key, byte_bus_id)) return false; + if (!relay_id_to_endpoint_id(msg.id, byte_bus_id)) return false; out_info = acf::AcfMessageInfo{}; out_info.byte_bus_id = byte_bus_id; out_info.op = op_from_meta(msg.meta); @@ -182,10 +169,9 @@ class RcpCallerAdapter final : public relay::Caller { // send maps relay::Message → ACF request, discards the response (§10.6). std::error_code send(relay::Context ctx, const relay::Message& msg) override { if (!fn_) return std::make_error_code(std::errc::not_connected); - uint64_t stream_key = 0; acf::AcfMessageInfo req; std::vector payload; - if (!message_to_request(msg, stream_key, req, payload)) + if (!message_to_request(msg, req, payload)) return std::make_error_code(std::errc::invalid_argument); acf::AcfMessageInfo out_info; std::vector out_payload; @@ -196,16 +182,15 @@ class RcpCallerAdapter final : public relay::Caller { std::pair call(relay::Context ctx, const relay::Message& req_msg) override { if (!fn_) return {{}, std::make_error_code(std::errc::not_connected)}; - uint64_t stream_key = 0; acf::AcfMessageInfo req; std::vector payload; - if (!message_to_request(req_msg, stream_key, req, payload)) + if (!message_to_request(req_msg, req, payload)) return {{}, std::make_error_code(std::errc::invalid_argument)}; acf::AcfMessageInfo out_info; std::vector out_payload; auto ec = fn_(ctx, req, payload, out_info, out_payload); if (ec) return {{}, ec}; - return {response_to_message(stream_key, out_info, out_payload), {}}; + return {response_to_message(out_info, out_payload), {}}; } // subscribe: no analog in the target specification's request/response @@ -230,10 +215,12 @@ class RcpCallerAdapter final : public relay::Caller { // Adapt wraps a RequestFn as a relay::Caller. The returned relay::Caller // also satisfies relay::Node. Does NOT block or connect; wraps `fn` -// immediately. Each relay::Message passed to send()/call() carries its own -// full server+endpoint address in Message.id (see endpoint_id_to_relay_id -// above), so one adapter can address any number of distinct endpoints -// through the same underlying RequestFn. +// immediately. Per §8.5/§15.7.5, one Adapt()-wrapped Caller presents a +// single fixed StreamID identity — `fn` itself owns that binding (e.g. a +// connection it closes over) — and each relay::Message passed to +// send()/call() addresses one Endpoint on it via Message.id (see +// endpoint_id_to_relay_id above). An application juggling several RC +// Servers wraps each with its own Adapt() call (§8.5). inline std::unique_ptr Adapt(RequestFn fn) { return std::make_unique(std::move(fn)); } diff --git a/include/rcp/admin.hpp b/include/rcp/admin.hpp index 9c32fad..04b7c0d 100644 --- a/include/rcp/admin.hpp +++ b/include/rcp/admin.hpp @@ -7,19 +7,27 @@ // fusa:req REQ-ADMIN-007 // fusa:req REQ-ADMIN-008 -// In-process Admin API: zone listing, SSE events, Prometheus metrics (v0.26.0). +// In-process Admin API: stream listing, SSE events, Prometheus metrics (v0.26.0). // // AdminServer is a lightweight in-process interface: callers can query -// zone state, subscribe to events (SSE-style push channel), and snapshot -// Prometheus-format text metrics. An actual HTTP binding is out of scope; -// use a libmicrohttpd or Asio adapter to expose the HTTP surface. +// which streams are registered, subscribe to events (SSE-style push +// channel), and snapshot Prometheus-format text metrics. An actual HTTP +// binding is out of scope; use a libmicrohttpd or Asio adapter to expose +// the HTTP surface. +// +// Rebound (cpp-RCP-FS-03, #86): this used to report `rcp::Registry`'s +// registered Zones. `Zone`/`rcp::Registry` are retired (cpp-RCP-FS-01, #84); +// AdminServer is now bound to `rcp::shmem::Registry` (rcp/shmem.hpp), the +// TC18-shaped in-process registry keyed by opaque stream_key, and reports +// streams instead of zones. #pragma once #include "rcp.hpp" +#include "shmem.hpp" #include +#include #include -#include #include #include #include @@ -29,21 +37,21 @@ namespace rcp { namespace admin { -// ── ZoneInfo ────────────────────────────────────────────────────────────────── +// ── StreamInfo ──────────────────────────────────────────────────────────────── -struct ZoneInfo { - Zone zone; +struct StreamInfo { + uint64_t stream_key; bool registered; std::string extra; // JSON-encoded metadata blob, optional }; // ── Event ───────────────────────────────────────────────────────────────────── -enum class EventType : uint8_t { ZoneRegistered = 1, ZoneDeregistered = 2, StatusUpdate = 3 }; +enum class EventType : uint8_t { StreamRegistered = 1, StreamDeregistered = 2, StatusUpdate = 3 }; struct Event { EventType type; - Zone zone; + uint64_t stream_key; std::chrono::system_clock::time_point ts; }; @@ -61,15 +69,15 @@ struct Counter { class AdminServer { public: - explicit AdminServer(rcp::Registry& reg) : reg_(reg) {} - - // zones returns a snapshot of all registered zones. - std::vector zones() const { - auto ctrls = reg_.controllers(); - std::vector out; - out.reserve(ctrls.size()); - for (auto& c : ctrls) { - out.push_back({c->zone(), true, {}}); + explicit AdminServer(shmem::Registry& reg) : reg_(reg) {} + + // streams returns a snapshot of all registered streams. + std::vector streams() const { + auto channels = reg_.channels(); + std::vector out; + out.reserve(channels.size()); + for (auto& ch : channels) { + out.push_back({ch->stream_key(), true, {}}); } return out; } @@ -110,10 +118,10 @@ class AdminServer { } private: - rcp::Registry& reg_; - mutable std::mutex mu_; - std::vector subscribers_; - std::unordered_map counters_; + shmem::Registry& reg_; + mutable std::mutex mu_; + std::vector subscribers_; + std::unordered_map counters_; }; } // namespace admin diff --git a/include/rcp/cli.hpp b/include/rcp/cli.hpp index 7be7cc5..42778db 100644 --- a/include/rcp/cli.hpp +++ b/include/rcp/cli.hpp @@ -105,7 +105,7 @@ inline std::string capabilities_json() { + "\"commands\":[\"version\",\"capabilities\",\"status\",\"send\"]," + "\"transports\":[\"udp\",\"shmem\",\"mock\"]," + "\"features\":[\"gpio\",\"spi\",\"i2c\",\"uart\",\"adc\",\"pwm\",\"lin\",\"can\"," - "\"iseled\",\"mdio\",\"wakeup\",\"loaning\"]," + "\"iseled\",\"mdio\",\"wakeup\"]," + "\"interfaces\":[\"Node\",\"Caller\"]," + "\"optional_interfaces\":[]," + "\"adapt\":true" @@ -418,11 +418,17 @@ inline int send_one(const std::vector& args, bool json, return kInvalidArgs; } + // --server is validated for shape but, per rcp/adapt.hpp's #88 fix, no + // longer folded into the constructed relay::Message.id (RELAY spec + // §15.7.5: Message.ID for RCP is just the decimal ByteBusID). In a + // multi-server deployment --server would select which Adapt()-wrapped + // connection to dispatch through; this CLI's demo backend has only one. std::uint64_t stream_key = 0; if (!parse_u64(server_flag, stream_key)) { err << "error: invalid --server '" << server_flag << "'\n"; return kInvalidArgs; } + (void)stream_key; avtp::ByteBusId byte_bus_id = 0; if (!parse_byte_bus_id(endpoint_flag, byte_bus_id)) { err << "error: invalid --endpoint '" << endpoint_flag << "'\n"; @@ -460,7 +466,7 @@ inline int send_one(const std::vector& args, bool json, acf::AcfMessageInfo resp; std::vector resp_payload; auto ec = dispatch_endpoint(srv, req, payload, resp, resp_payload); - std::string addr = endpoint_id_to_relay_id(stream_key, byte_bus_id); + std::string addr = endpoint_id_to_relay_id(byte_bus_id); if (ec) { err << "send: " << addr << ": send failed\n"; return kError; @@ -500,10 +506,9 @@ inline int send_stream(std::istream& in, std::ostream& out, std::ostream& err) { err << "send: skipping malformed message\n"; continue; } - std::uint64_t stream_key = 0; acf::AcfMessageInfo req; std::vector payload; - if (!message_to_request(msg, stream_key, req, payload)) { + if (!message_to_request(msg, req, payload)) { err << "send: skipping message with unparseable id '" << msg.id << "'\n"; continue; } @@ -511,7 +516,7 @@ inline int send_stream(std::istream& in, std::ostream& out, std::ostream& err) { std::vector resp_payload; auto ec = dispatch_endpoint(srv, req, payload, resp, resp_payload); if (ec) { - err << "send: " << endpoint_id_to_relay_id(stream_key, req.byte_bus_id) + err << "send: " << endpoint_id_to_relay_id(req.byte_bus_id) << ": send failed\n"; continue; } diff --git a/include/rcp/config.hpp b/include/rcp/config.hpp index d3fba91..d6d4a7a 100644 --- a/include/rcp/config.hpp +++ b/include/rcp/config.hpp @@ -5,50 +5,63 @@ // fusa:req REQ-CFG-005 // fusa:req REQ-CFG-006 -// Zone registry loader from JSON/YAML configuration files (v0.28.0). +// RC Server/endpoint topology manifest loader from JSON/YAML configuration +// files (v0.28.0). // -// ConfigLoader parses a JSON zone-registry manifest and populates an -// rcp::Registry with mock controllers for each defined zone. YAML is -// supported transparently (requires an external YAML→JSON shim in the +// ConfigLoader parses a JSON manifest describing known RC Servers — each +// identified by an opaque stream_key (typically an avtp::StreamId::to_u64()) +// — and the Endpoints (byte_bus_id) each server exposes, and bootstraps an +// rcp::shmem::Registry with one Channel per distinct stream_key so an +// embedding application can wire a Handler and start dispatching immediately, +// ahead of or instead of rcp/discovery.hpp's own discovery mechanism. YAML +// is supported transparently (requires an external YAML→JSON shim in the // build; the loader only handles JSON natively). // // Example manifest (JSON): -// { "zones": [ -// { "zone": "FrontLeft", "priority": "Normal" }, -// { "zone": "FrontRight", "priority": "Normal" } +// { "endpoints": [ +// { "stream_key": "0", "byte_bus_id": 1, "priority": "Normal" }, +// { "stream_key": "0", "byte_bus_id": 2, "priority": "Normal" } // ]} // -// Still built on rcp.hpp's pre-replacement Zone/Controller/Registry model -// and rcp/legacy_mock.hpp's Controller (renamed from rcp/mock.hpp at -// ROADMAP.md milestone 56, v2.12.0 — see rcp/legacy_mock.hpp's header -// comment). Per the Satellite Package Disposition table's entry for -// `config.hpp`, rebuilding this loader around a server/endpoint manifest -// schema is a later milestone, not this one. +// Rebound onto the TC18 stream_key/byte_bus_id addressing model (cpp-RCP- +// FS-03, #86): previously this loader parsed a Zone-name manifest and +// constructed one rcp::legacy_mock::Controller per zone into an rcp::Registry +// — both retired alongside the rest of the pre-TC18 placeholder model +// (cpp-RCP-FS-01, #84). There is no direct per-entry replacement for that +// in-process Controller construction, since TC18 addresses Endpoints by +// stream_key/byte_bus_id on an already-connected RC Server rather than by +// zone lookup; this loader now bootstraps the stream-keyed +// rcp::shmem::Registry (rcp/shmem.hpp) instead, per the Satellite Package +// Disposition table's entry for `config.hpp` — "rebuilt around a +// server/endpoint manifest schema; still useful for bootstrapping known +// topologies alongside discovery." #pragma once -#include "legacy_mock.hpp" +#include "avtp.hpp" #include "rcp.hpp" +#include "shmem.hpp" +#include #include #include -#include #include namespace rcp { namespace config { -// ── ZoneManifestEntry ───────────────────────────────────────────────────────── +// ── EndpointManifestEntry ───────────────────────────────────────────────────── -struct ZoneManifestEntry { - Zone zone; - std::string priority; // "Low", "Normal", "High", "Critical" - std::string extra; // opaque metadata +struct EndpointManifestEntry { + uint64_t stream_key = 0; + avtp::ByteBusId byte_bus_id = 0; + std::string priority; // "Low", "Normal", "High", "Critical" — opaque to this loader + std::string extra; // opaque metadata }; // ── Manifest ────────────────────────────────────────────────────────────────── struct Manifest { - std::vector zones; + std::vector endpoints; }; // ── ParseError ──────────────────────────────────────────────────────────────── @@ -60,16 +73,8 @@ struct ParseError : std::runtime_error { // ── parse_json ──────────────────────────────────────────────────────────────── // Minimal JSON manifest parser (hand-rolled; no external dependency). -// Supports the schema described above. Throws ParseError on malformed input. +// Supports the schema described above. Throws ParseError on malformed input. inline Manifest parse_json(const std::string& json) { - static const std::unordered_map zone_map = { - {"FrontLeft", Zone::FrontLeft}, - {"FrontRight", Zone::FrontRight}, - {"RearLeft", Zone::RearLeft}, - {"RearRight", Zone::RearRight}, - {"Central", Zone::Central}, - }; - Manifest m; auto extract_str = [&](size_t start) -> std::string { @@ -80,32 +85,58 @@ inline Manifest parse_json(const std::string& json) { return json.substr(q1 + 1, q2 - q1 - 1); }; + // extract_uint reads the decimal digits immediately following the ':' + // at colon_pos (skipping whitespace), stopping at the first non-digit. + auto extract_uint = [&](size_t colon_pos) -> unsigned long long { + size_t p = colon_pos + 1; + while (p < json.size() && (json[p] == ' ' || json[p] == '\t')) ++p; + size_t begin = p; + while (p < json.size() && json[p] >= '0' && json[p] <= '9') ++p; + if (p == begin) throw ParseError("missing numeric value"); + return std::stoull(json.substr(begin, p - begin)); + }; + size_t pos = 0; - while ((pos = json.find("{", pos)) != std::string::npos) { - auto close = json.find("}", pos); + while ((pos = json.find('{', pos)) != std::string::npos) { + auto close = json.find('}', pos); if (close == std::string::npos) break; std::string obj = json.substr(pos, close - pos + 1); - // Check if this object has a "zone" key. - auto zk = obj.find("\"zone\""); - if (zk == std::string::npos) { pos = close + 1; continue; } - - ZoneManifestEntry entry; - entry.zone = Zone::Central; // default, overridden below + // Only objects carrying both "stream_key" and "byte_bus_id" are + // endpoint entries; anything else (e.g. the enclosing manifest + // object itself) is skipped. + auto sk = obj.find("\"stream_key\""); + auto bk = obj.find("\"byte_bus_id\""); + if (sk == std::string::npos || bk == std::string::npos) { pos = close + 1; continue; } + + EndpointManifestEntry entry; + + // stream_key is a decimal or "0x"-prefixed hex string. + auto sk_str = extract_str(pos + sk + 12); + try { + size_t consumed = 0; + entry.stream_key = std::stoull(sk_str, &consumed, 0); + if (consumed != sk_str.size()) throw std::invalid_argument(sk_str); + } catch (const std::exception&) { + throw ParseError("invalid stream_key: " + sk_str); + } + + // byte_bus_id is a bare JSON number in [0, 255]. + auto bk_colon = obj.find(':', bk); + if (bk_colon == std::string::npos) throw ParseError("malformed byte_bus_id"); + auto bus_val = extract_uint(pos + bk_colon); + if (bus_val > 0xFF) throw ParseError("byte_bus_id out of range: " + std::to_string(bus_val)); + entry.byte_bus_id = static_cast(bus_val); - // Extract zone name. - auto zone_str = extract_str(pos + zk + 6); - auto it = zone_map.find(zone_str); - if (it == zone_map.end()) - throw ParseError("unknown zone: " + zone_str); - entry.zone = it->second; - - // Extract optional priority. auto pk = obj.find("\"priority\""); if (pk != std::string::npos) entry.priority = extract_str(pos + pk + 10); - m.zones.push_back(entry); + auto ek = obj.find("\"extra\""); + if (ek != std::string::npos) + entry.extra = extract_str(pos + ek + 7); + + m.endpoints.push_back(entry); pos = close + 1; } @@ -114,13 +145,24 @@ inline Manifest parse_json(const std::string& json) { // ── load ────────────────────────────────────────────────────────────────────── -// load parses a JSON manifest string and registers one mock controller per -// zone entry into reg. Returns an error code if any registration fails. -inline std::error_code load(const std::string& json, rcp::Registry& reg) { +// load parses a JSON manifest string and bootstraps `reg` with one +// shmem::Channel per distinct stream_key named in the manifest. The +// embedding application still owns wiring each Channel's Handler — there is +// no more generic in-process Controller this loader can construct on the +// caller's behalf (see this header's own comment). Returns an error code if +// registering a channel fails (e.g. `reg` already has a channel for that +// stream_key). +inline std::error_code load(const std::string& json, shmem::Registry& reg) { Manifest m = parse_json(json); - for (auto& entry : m.zones) { - auto ctrl = std::make_shared(entry.zone); - auto ec = reg.register_ctrl(ctrl); + + std::vector bootstrapped; + for (auto& entry : m.endpoints) { + if (std::find(bootstrapped.begin(), bootstrapped.end(), entry.stream_key) != + bootstrapped.end()) + continue; // already bootstrapped this stream_key from an earlier entry + bootstrapped.push_back(entry.stream_key); + + auto ec = reg.add_channel(shmem::new_channel(entry.stream_key)); if (ec) return ec; } return {}; diff --git a/include/rcp/faultinject.hpp b/include/rcp/faultinject.hpp index c337f1f..d224b9e 100644 --- a/include/rcp/faultinject.hpp +++ b/include/rcp/faultinject.hpp @@ -9,16 +9,30 @@ // Structured fault injection for validating safety mechanisms (v0.11.0–v0.16.0). // -// Controller wraps any rcp::Controller and intercepts send() calls according -// to an ordered list of Rules. Rules may drop responses, add latency, return -// errors, or return timeouts. Count-based rules auto-expire after N applications. +// Interceptor wraps an rcp::RequestFn (rcp/adapt.hpp's client-side send- +// equivalent call) and intercepts each call according to an ordered list of +// Rules. Rules may drop responses, add latency, return errors, or return +// timeouts. Count-based rules auto-expire after N applications. +// +// Rebound (cpp-RCP-FS-03, #86): this was `faultinject::Controller`, an +// `rcp::Controller` decorator over the retired Zone/Command/Response model. +// That base interface is retired (cpp-RCP-FS-01, #84); this header now wraps +// the same `RequestFn` shape rcp/adapt.hpp, rcp/record.hpp, and +// rcp/observe.hpp already standardize on, so it can sit in front of any +// TC18 request path instead of only an in-process rcp::Controller. The +// fault-injected "error" case now flags AcfMessageInfo::err instead of +// returning the retired ResponseStatus::Error; there is no more +// Controller::zone()/subscribe() to preserve, since a plain RequestFn has no +// subscribe() of its own (see rcp/adapt.hpp's own note on subscribe() having +// no analog in the target request/response shape). #pragma once +#include "acf.hpp" +#include "adapt.hpp" #include "rcp.hpp" #include #include -#include #include #include #include @@ -29,10 +43,10 @@ namespace faultinject { // ── FaultType ───────────────────────────────────────────────────────────────── enum class FaultType : uint8_t { - Drop = 1, // return an error without calling inner send() - Slow = 2, // sleep Rule::latency, then call inner send() - Error = 3, // return ResponseStatus::Error without calling inner - Timeout = 4, // return ErrTimeout without calling inner + Drop = 1, // return an error without calling the inner RequestFn + Slow = 2, // sleep Rule::latency, then call the inner RequestFn + Error = 3, // return an AcfMessageInfo with err set, without calling the inner RequestFn + Timeout = 4, // return ErrTimeout without calling the inner RequestFn }; // ── Rule ───────────────────────────────────────────────────────────────────── @@ -44,12 +58,11 @@ struct Rule { int fired{0}; // internal counter }; -// ── Controller ──────────────────────────────────────────────────────────────── +// ── Interceptor ──────────────────────────────────────────────────────────────── -class Controller final : public rcp::Controller { +class Interceptor { public: - explicit Controller(std::shared_ptr inner) - : inner_(std::move(inner)) {} + explicit Interceptor(RequestFn inner) : inner_(std::move(inner)) {} void add_rule(Rule r) { std::lock_guard lk(mu_); @@ -61,44 +74,46 @@ class Controller final : public rcp::Controller { rules_.clear(); } - Zone zone() const noexcept override { return inner_->zone(); } - - std::error_code send(const rcp::Context& ctx, - const Command& cmd, - Response& out) override { + // send intercepts one request/response round trip through `inner` + // according to the active rule (if any), the same call shape + // rcp::RequestFn/rcp::Adapt() already standardize on. + std::error_code send(const rcp::Context& ctx, const acf::AcfMessageInfo& req, + const std::vector& payload, + acf::AcfMessageInfo& out, std::vector& out_payload) { if (closed_.load(std::memory_order_acquire)) return ErrClosed; Rule* rule = pick_rule(); - if (!rule) return inner_->send(ctx, cmd, out); + if (!rule) return inner_(ctx, req, payload, out, out_payload); switch (rule->type) { case FaultType::Drop: return make_error_code(Errc::closed); // injected drop case FaultType::Slow: std::this_thread::sleep_for(rule->latency); - return inner_->send(ctx, cmd, out); + return inner_(ctx, req, payload, out, out_payload); case FaultType::Error: - out = Response{cmd.id, inner_->zone(), ResponseStatus::Error, {}}; + out = acf::make_response(req, acf::ResponseKind::ErrorResponse); + out_payload = {}; return {}; case FaultType::Timeout: return ErrTimeout; default: - return inner_->send(ctx, cmd, out); + return inner_(ctx, req, payload, out, out_payload); } } - std::error_code subscribe(const rcp::Context& ctx, - std::shared_ptr& out) override { - return inner_->subscribe(ctx, out); + // operator() lets an Interceptor itself be handed anywhere an + // rcp::RequestFn is expected — e.g. straight into rcp::Adapt(). + std::error_code operator()(const rcp::Context& ctx, const acf::AcfMessageInfo& req, + const std::vector& payload, + acf::AcfMessageInfo& out, std::vector& out_payload) { + return send(ctx, req, payload, out, out_payload); } - std::error_code close() override { - closed_.store(true, std::memory_order_release); - return inner_->close(); - } + void close() { closed_.store(true, std::memory_order_release); } private: - std::shared_ptr inner_; + RequestFn inner_; std::atomic closed_{false}; std::mutex mu_; std::vector rules_; @@ -122,8 +137,8 @@ class Controller final : public rcp::Controller { } }; -inline std::shared_ptr new_controller(std::shared_ptr inner) { - return std::make_shared(std::move(inner)); +inline std::shared_ptr new_interceptor(RequestFn inner) { + return std::make_shared(std::move(inner)); } } // namespace faultinject diff --git a/include/rcp/legacy_mock.hpp b/include/rcp/legacy_mock.hpp deleted file mode 100644 index 8845bda..0000000 --- a/include/rcp/legacy_mock.hpp +++ /dev/null @@ -1,276 +0,0 @@ -// fusa:req REQ-CTRL-001 -// fusa:req REQ-CTRL-002 -// fusa:req REQ-CTRL-003 -// fusa:req REQ-CTRL-004 -// fusa:req REQ-CTRL-005 -// fusa:req REQ-CTRL-006 -// fusa:req REQ-CTRL-007 -// fusa:req REQ-CTRL-008 -// fusa:req REQ-CTRL-009 -// fusa:req REQ-CTRL-010 -// fusa:req REQ-CTRL-011 -// fusa:req REQ-CTRL-012 -// fusa:req REQ-CTRL-013 -// fusa:req REQ-CTRL-014 -// fusa:req REQ-CTRL-015 -// fusa:req REQ-CTRL-016 -// fusa:req REQ-CTRL-017 -// fusa:req REQ-CTRL-018 -// fusa:req REQ-CTRL-019 -// fusa:req REQ-CTRL-020 -// fusa:req REQ-CTRL-021 -// fusa:req REQ-CTRL-022 -// fusa:req REQ-CTRL-023 -// fusa:req REQ-CTRL-024 -// fusa:req REQ-CTRL-025 -// fusa:req REQ-CTRL-026 -// fusa:req REQ-CTRL-027 -// fusa:req REQ-REG-001 -// fusa:req REQ-REG-002 -// fusa:req REQ-REG-003 -// fusa:req REQ-REG-004 -// fusa:req REQ-REG-005 -// fusa:req REQ-REG-006 -// fusa:req REQ-REG-007 -// fusa:req REQ-REG-008 -// fusa:req REQ-REG-009 -// fusa:req REQ-REG-010 -// fusa:req REQ-REG-011 -// fusa:req REQ-REG-012 -// fusa:req REQ-REG-013 -// fusa:req REQ-RESP-001 -// fusa:req REQ-RESP-002 -// fusa:req REQ-STAT-001 -// fusa:req REQ-STAT-002 -// fusa:req REQ-STAT-003 -// fusa:req REQ-STAT-004 -// fusa:req REQ-STAT-005 -// fusa:req REQ-ERR-011 - -// In-process controller/registry for the pre-replacement Zone/Command/ -// Response/Status model (rcp.hpp), preserved unchanged under this name so -// the still-untouched old-model dependents that build against it keep -// working until each of them is rebound at its own later milestone. -// -// ROADMAP.md milestone 56, "Test & Simulation Harness Rebuild (v2.12.0)": -// this is the pre-replacement content that used to live in rcp/mock.hpp — -// moved here, verbatim in behavior and REQ-CTRL-*/REQ-REG-*/REQ-RESP-*/ -// REQ-STAT-*/REQ-ERR-011 identity, only renamed (mock -> legacy_mock, same -// as rcp/wire.hpp's old 16-byte codec became rcp/legacy_wire.hpp at -// v2.0.0) so rcp/mock.hpp itself could become the new RC Server simulator -// this milestone calls for without breaking the roughly three dozen other -// headers/tests still built on rcp.hpp's Zone/Command/Controller/Registry -// model and not yet rebound to the new one: rcp/capi_impl.hpp, -// rcp/cli.hpp, and rcp/config.hpp (all scheduled for v2.16.0 per the -// Satellite Package Disposition table's `capi_impl.hpp`/`cli.hpp`/ -// `config.hpp` entries), plus every test file that used the old -// mock::Controller/Registry purely as a generic in-process test double for -// its own not-yet-rebuilt package (firmware/prioqueue/proxy/record/ -// redundancy/tsn/zonegroup/federation/observe/faultinject/loan/admin — -// most themselves DEPRECATE/ADAPT candidates for later milestones per that -// same table). -// -// Do not build new functionality on top of this file — it is a -// deliberately temporary bridge, not a second mock implementation going -// forward. See rcp/mock.hpp for the new RC Server simulator. -// -// All operations execute synchronously in memory — no I/O, minimal threading -// (one watcher thread per subscription). The mock is safe for concurrent use. -#pragma once - -#include "rcp.hpp" - -#include -#include -#include -#include -#include -#include -#include - -namespace rcp { -namespace legacy_mock { - -// Handler is a user-supplied function that produces a Response for a Command. -// If nullptr, the controller returns StatusOK with empty payload. -using Handler = std::function; - -// Controller is a mock zone controller that handles commands in-process. -class Controller final : public rcp::Controller { -public: - explicit Controller(Zone zone, Handler handler = nullptr) - : zone_(zone), handler_(std::move(handler)) {} - - ~Controller() override { (void)close(); } - - Zone zone() const noexcept override { return zone_; } - - std::error_code send(const rcp::Context& ctx, - const Command& cmd, - Response& out) override { - if (closed_.load(std::memory_order_acquire)) - return ErrClosed; - if (ctx.done()) - return ErrTimeout; - if (cmd.zone != zone_) - return ErrZoneMismatch; - - // Copy payload before passing to handler (REQ-CTRL-026). - Command safe = cmd; - if (!cmd.payload.empty()) { - safe.payload = cmd.payload; - } - - if (handler_) { - out = handler_(safe); - } else { - out = Response{cmd.id, zone_, ResponseStatus::OK, {}}; - } - return {}; - } - - std::error_code subscribe(const rcp::Context& ctx, - std::shared_ptr& out) override { - auto ch = std::make_shared(16); - { - // Check closed_ and append under the same critical section close() - // uses, so a subscribe() racing a close() cannot observe "open" and - // then add to subs_ after close() has already cleared it (REQ-CTRL-008; - // RELAY spec §6.2/6.3/6.6 require a deterministic ErrClosed here, not - // a transiently-valid channel). - std::lock_guard lk(mu_); - if (closed_.load(std::memory_order_acquire)) - return ErrClosed; - subs_.push_back(ch); - } - - // Watcher thread: remove subscription when ctx expires or controller closes. - std::thread([this, weak_ch = std::weak_ptr(ch), ctx]() mutable { - // Poll until ctx done or channel closed. - while (!ctx.done()) { - if (closed_.load(std::memory_order_acquire)) break; - auto ch_ptr = weak_ch.lock(); - if (!ch_ptr || ch_ptr->is_closed()) break; - std::this_thread::sleep_for(std::chrono::milliseconds(1)); - } - auto ch_ptr = weak_ch.lock(); - if (!ch_ptr) return; - { - std::lock_guard lk(mu_); - auto it = std::find(subs_.begin(), subs_.end(), ch_ptr); - if (it != subs_.end()) subs_.erase(it); - } - ch_ptr->close(); - }).detach(); - - out = std::move(ch); - return {}; - } - - // Publish pushes a Status update to all active subscribers. - void publish(const std::vector& payload) { - uint32_t seq = ++seq_; - // Copy payload so caller mutation cannot affect delivered Status (REQ-CTRL-027). - std::vector p = payload; - - Status st{zone_, seq, !closed_.load(std::memory_order_acquire), std::move(p)}; - std::lock_guard lk(mu_); - for (auto& s : subs_) { - s->push(st); - } - } - - std::error_code close() override { - // The closed_ exchange lives under mu_ too, so it is properly serialized - // against subscribe()'s check-and-append critical section above. - std::lock_guard lk(mu_); - bool was_open = !closed_.exchange(true, std::memory_order_acq_rel); - if (!was_open) return {}; - for (auto& s : subs_) s->close(); - subs_.clear(); - return {}; - } - -private: - Zone zone_; - Handler handler_; - std::atomic closed_{false}; - std::atomic seq_{0}; - std::mutex mu_; - std::vector> subs_; -}; - -// Registry is an in-process RCP registry backed by mock controllers. -class Registry final : public rcp::Registry { -public: - Registry() { - for (auto z : {Zone::FrontLeft, Zone::FrontRight, - Zone::RearLeft, Zone::RearRight, - Zone::Central}) { - ctrls_[z] = std::make_shared(z); - } - } - - ~Registry() override { (void)close(); } - - std::error_code register_ctrl(std::shared_ptr ctrl) override { - auto* mc = dynamic_cast(ctrl.get()); - if (!mc) return std::make_error_code(std::errc::invalid_argument); - - std::unique_lock lk(mu_); - if (closed_) return ErrClosed; - if (ctrls_.count(mc->zone())) return ErrAlreadyExists; - ctrls_[mc->zone()] = std::shared_ptr(ctrl, mc); - return {}; - } - - std::error_code deregister(Zone z) override { - std::unique_lock lk(mu_); - auto it = ctrls_.find(z); - if (it == ctrls_.end()) return ErrNotFound; - auto ctrl = it->second; - ctrls_.erase(it); - lk.unlock(); - return ctrl->close(); - } - - std::error_code lookup(Zone z, std::shared_ptr& out) override { - std::shared_lock lk(mu_); - if (closed_) return ErrClosed; - auto it = ctrls_.find(z); - if (it == ctrls_.end()) return ErrNotFound; - out = it->second; - return {}; - } - - std::vector> controllers() override { - std::shared_lock lk(mu_); - std::vector> out; - out.reserve(ctrls_.size()); - for (auto& [z, c] : ctrls_) out.push_back(c); - return out; - } - - std::error_code close() override { - std::unique_lock lk(mu_); - if (closed_) return {}; - closed_ = true; - auto local = std::move(ctrls_); - lk.unlock(); - for (auto& [z, c] : local) (void)c->close(); - return {}; - } - -private: - mutable std::shared_mutex mu_; - std::map> ctrls_; - bool closed_ = false; -}; - -// Convenience factory — analogous to mock.NewRegistry() in Go. -inline std::unique_ptr new_registry() { - return std::make_unique(); -} - -} // namespace legacy_mock -} // namespace rcp diff --git a/include/rcp/rcp.hpp b/include/rcp/rcp.hpp index 31bcdf0..bf9fdae 100644 --- a/include/rcp/rcp.hpp +++ b/include/rcp/rcp.hpp @@ -1,6 +1,3 @@ -// fusa:req REQ-PRI-001 -// fusa:req REQ-PRI-002 -// fusa:req REQ-PRI-003 // fusa:req REQ-ERR-001 // fusa:req REQ-ERR-002 // fusa:req REQ-ERR-003 @@ -11,62 +8,42 @@ // fusa:req REQ-ERR-008 // fusa:req REQ-ERR-009 // fusa:req REQ-ERR-010 -// fusa:req REQ-ERR-011 -// fusa:req REQ-CMDSTRUCT-001 -// fusa:req REQ-CMDSTRUCT-002 -// fusa:req REQ-RESP-001 -// fusa:req REQ-RESP-002 -// fusa:req REQ-RESP-003 -// fusa:req REQ-STAT-001 -// fusa:req REQ-STAT-002 -// fusa:req REQ-STAT-003 -// fusa:req REQ-STAT-004 -// fusa:req REQ-STAT-005 +// fusa:req REQ-LOAN-007 -// Package rcp provides the Remote Control Protocol for automotive zonal architecture. +// Core rcp:: primitives shared codebase-wide: the `rcp::Errc` sentinel error +// category, `rcp::Context` (an alias for relay::Context, §18.2), and +// `rcp::Loan`, a generic RAII buffer-loan holder. // -// A central high-performance computer uses a Registry to discover zone controllers, -// then dispatches Commands to each Controller and receives Responses and Status -// telemetry in return. +// RELAY conformance: include for relay:: namespace types. +// The RC-Server-addressed request/response model itself — the TC18 wire +// codec, register map, lifecycle, and application-facing Adapt()/RequestFn +// surface — lives in rcp/avtp.hpp, rcp/acf.hpp, rcp/regmap.hpp, +// rcp/lifecycle.hpp, and rcp/adapt.hpp, not here. // -// RELAY conformance: include for relay:: namespace types, and -// for Adapt() which wraps a Controller as a relay::Caller. -// -// Pre-replacement model notice: this Zone/Command/Controller/Registry -// design predates cpp-RCP's alignment to the OPEN Alliance TC18 Remote -// Control Protocol Specification v0.5.1_RC (see ROADMAP.md's Satellite -// Package Disposition table, which marks this file REPLACE). It remains in -// place, unmodified in behavior, purely because one remaining header under -// include/rcp/ — rcp/config.hpp — still builds against it (its own rebind -// is a separate, still-open item per milestone 60's closeout); every other -// former dependent has been rebound at its own milestone through v2.16.0. -// The new stream/endpoint/register-map model that supersedes the types -// below lives in rcp/avtp.hpp/rcp/acf.hpp (v2.0.0), rcp/lifecycle.hpp, and -// rcp/regmap.hpp (both v2.1.0); do not build new functionality on top of -// Zone/Command/Controller/Registry going forward. -// -// Certification Refresh notice (ROADMAP.md milestone 62, v2.18.0): the -// REQ-ZONE-*/REQ-CMD-*/REQ-STATUS-* requirement groups that used to trace -// to the Zone enum, CommandType enum, and Status struct/StatusChannel -// below are retired from .fusa-reqs.json as of this milestone — this file -// having no TC18 analog was already recorded at REPLACE above, so carrying -// certified requirement coverage for it any further would misrepresent -// this as safety-relevant surface going forward. The Priority enum -// (REQ-PRI-*), sentinel error codes (REQ-ERR-*), and the Command/Response -// struct shapes and ResponseStatus enum (REQ-CMDSTRUCT-*/REQ-RESP-*/ -// REQ-STAT-*) keep their existing requirement coverage unchanged, since -// milestone 62's own scope names only the Zone/CommandType/Status trio for -// retirement. +// Retirement notice (cpp-RCP-FS-01, 2026-07): this file used to also define +// a pre-TC18 placeholder object model (`Zone`, `Priority`, `CommandType`, +// `Command`, `Response`, `Status`, `StatusChannel`, `Controller`, +// `LoaningController`, `Registry`, and `Errc::zone_mismatch`/ +// `ErrZoneMismatch`), predating cpp-RCP's alignment to the OPEN Alliance +// TC18 Remote Control Protocol Specification v0.5.1_RC. RELAY spec v2.0 +// §15.5 states that placeholder model is retired and that a conformant +// implementation does not also speak it as a compatibility shim, so that +// entire surface — the last live compiled consumer was rcp/config.hpp — was +// deleted outright rather than migrated; there is no direct replacement +// symbol for `Zone`/`Command`/`Controller`/`Registry` individually, since +// TC18 addresses Endpoints on an RC Server by StreamID/ByteBusID rather +// than by zone/controller lookup. See rcp/adapt.hpp's `RequestFn` and +// `Adapt()` for the current application-facing entry point, and +// ROADMAP.md's Satellite Package Disposition table for how each former +// dependent (`legacy_mock.hpp`, `config.hpp`, `faultinject.hpp`, +// `redundancy.hpp`, `admin.hpp`) was rebound or removed. `rcp::Loan` is +// unaffected by this retirement — it never depended on Zone/Command and is +// still the buffer type `rcp/loan.hpp`'s `BufferPool` hands out. #pragma once #include -#include -#include -#include #include -#include -#include #include #include #include @@ -81,7 +58,6 @@ enum class Errc : int { already_exists = 3, timeout = 4, busy = 5, - zone_mismatch = 6, }; inline const std::error_category& rcp_category() noexcept { @@ -89,16 +65,15 @@ inline const std::error_category& rcp_category() noexcept { const char* name() const noexcept override { return "rcp"; } std::string message(int ev) const override { switch (static_cast(ev)) { - case Errc::closed: return "rcp: controller closed"; - case Errc::not_found: return "rcp: zone not found"; - case Errc::already_exists: return "rcp: zone already registered"; - case Errc::timeout: return "rcp: command timeout"; - case Errc::busy: return "rcp: zone controller busy"; - case Errc::zone_mismatch: return "rcp: zone mismatch"; + case Errc::closed: return "rcp: closed"; + case Errc::not_found: return "rcp: not found"; + case Errc::already_exists: return "rcp: already exists"; + case Errc::timeout: return "rcp: timeout"; + case Errc::busy: return "rcp: busy"; default: return "rcp: unknown error"; } } - // Map rcp::Errc codes to relay::Errc conditions (§5.2, §5.3). + // Map rcp::Errc codes to relay::Errc conditions (§5.2, §5.3, §5.4). bool equivalent(int code, const std::error_condition& cond) const noexcept override { if (cond.category() != relay::relay_category()) return false; auto re = static_cast(cond.value()); @@ -107,8 +82,7 @@ inline const std::error_category& rcp_category() noexcept { case Errc::timeout: return re == relay::Errc::timeout; case Errc::busy: return re == relay::Errc::timeout; case Errc::not_found: return re == relay::Errc::not_connected; - case Errc::zone_mismatch: return re == relay::Errc::not_connected; - case Errc::already_exists: return false; // standalone per §5.4 (RELAY spec updated) + case Errc::already_exists: return false; // standalone per §5.4 — not a relay sentinel default: return false; } } @@ -127,98 +101,17 @@ inline const std::error_code ErrNotFound = make_error_code(Errc::not_found) inline const std::error_code ErrAlreadyExists = make_error_code(Errc::already_exists); inline const std::error_code ErrTimeout = make_error_code(Errc::timeout); inline const std::error_code ErrBusy = make_error_code(Errc::busy); -inline const std::error_code ErrZoneMismatch = make_error_code(Errc::zone_mismatch); - -// ── Zone ───────────────────────────────────────────────────────────────────── - -enum class Zone : uint8_t { - Unknown = 0, - FrontLeft = 1, - FrontRight = 2, - RearLeft = 3, - RearRight = 4, - Central = 5, -}; - -inline std::string to_string(Zone z) { - switch (z) { - case Zone::FrontLeft: return "front-left"; - case Zone::FrontRight: return "front-right"; - case Zone::RearLeft: return "rear-left"; - case Zone::RearRight: return "rear-right"; - case Zone::Central: return "central"; - default: return "unknown"; - } -} - -// ── Priority ────────────────────────────────────────────────────────────────── - -enum class Priority : uint8_t { - Normal = 0, - High = 1, - Critical = 2, -}; - -// ── CommandType ─────────────────────────────────────────────────────────────── - -enum class CommandType : uint16_t { - Noop = 0, - Set = 1, - Get = 2, - Reset = 3, - Watchdog = 4, - Sleep = 5, - Wake = 6, - Update = 7, // OTA firmware update session (M20) -}; - -// ── ResponseStatus ──────────────────────────────────────────────────────────── -enum class ResponseStatus : uint8_t { - OK = 0, - Error = 1, - Timeout = 2, - Busy = 3, - Unknown = 4, -}; - -inline std::string to_string(ResponseStatus s) { - switch (s) { - case ResponseStatus::OK: return "OK"; - case ResponseStatus::Error: return "error"; - case ResponseStatus::Timeout: return "timeout"; - case ResponseStatus::Busy: return "busy"; - default: return "unknown"; - } -} - -// ── Data structures ─────────────────────────────────────────────────────────── - -struct Command { - uint32_t id = 0; - Zone zone = Zone::Unknown; - CommandType type = CommandType::Noop; - Priority priority = Priority::Normal; - std::vector payload; -}; - -struct Response { - uint32_t command_id = 0; - Zone zone = Zone::Unknown; - ResponseStatus status = ResponseStatus::OK; - std::vector payload; -}; +// ── Context — relay::Context alias (§18.2) ──────────────────────────────────── +// rcp::Context is an alias for relay::Context per §18.2 conformance. -struct Status { - Zone zone = Zone::Unknown; - uint32_t seq = 0; - bool healthy = false; - std::vector payload; -}; +using Context = relay::Context; -// Loan is a payload buffer borrowed from a LoaningController's pool. -// The caller MUST either pass it to LoaningController::send_loaned (transferring -// ownership) or let it go out of scope (which calls the release function). +// ── Loan — generic RAII buffer-loan holder ──────────────────────────────────── +// A payload buffer borrowed from a pool (rcp/loan.hpp's BufferPool). The +// caller MUST either consume payload directly or let the Loan go out of +// scope (which calls the release function and returns the buffer to its +// pool), or release it early via ret(). class Loan { public: std::vector payload; @@ -247,88 +140,6 @@ class Loan { std::function release_; }; -// ── Context — relay::Context alias (§18.2) ──────────────────────────────────── -// rcp::Context is an alias for relay::Context per §18.2 conformance. - -using Context = relay::Context; - -// ── StatusChannel — relay::Channel alias (§18.2) ───────────────────── -// rcp::StatusChannel is an alias for relay::Channel. -// Legacy code using std::shared_ptr is unchanged. - -using StatusChannel = relay::Channel; - -// ── Controller ──────────────────────────────────────────────────────────────── - -class Controller { -public: - virtual ~Controller() = default; - - virtual Zone zone() const noexcept = 0; - - // Send dispatches a command and waits for the response. - // Returns ErrClosed if the controller has been closed. - // Returns ErrTimeout if ctx is done before a response arrives. - // Returns ErrZoneMismatch if cmd.zone != this->zone(). - virtual std::error_code send(const Context& ctx, - const Command& cmd, - Response& out) = 0; - - // Subscribe returns a channel of periodic Status updates. - // The channel is closed when ctx becomes done or the controller closes. - // Returns ErrClosed if the controller is already closed. - virtual std::error_code subscribe(const Context& ctx, - std::shared_ptr& out) = 0; - - // Close releases all resources. Safe to call multiple times. - virtual std::error_code close() = 0; -}; - -// ── LoaningController ───────────────────────────────────────────────────────── - -class LoaningController : public Controller { -public: - // Loan returns a zeroed payload buffer of exactly size bytes. - // Returns ErrClosed if the controller is closed. - // Returns an error if size < 0. - virtual std::error_code loan(int size, std::unique_ptr& out) = 0; - - // SendLoaned sends cmd whose payload is the buffer from a prior loan() call. - // Ownership of cmd.payload transfers to the transport on return. - virtual std::error_code send_loaned(const Context& ctx, - Command& cmd, - Response& out) = 0; -}; - -// ── Registry ───────────────────────────────────────────────────────────────── - -class Registry { -public: - virtual ~Registry() = default; - - // Register adds a controller. - // Returns ErrAlreadyExists if a controller for the same zone is registered. - // Returns ErrClosed if the registry is closed. - virtual std::error_code register_ctrl(std::shared_ptr ctrl) = 0; - - // Deregister removes and closes the controller for the given zone. - // Returns ErrNotFound if the zone is not registered. - virtual std::error_code deregister(Zone zone) = 0; - - // Lookup returns the controller for the given zone. - // Returns ErrNotFound if no controller is registered. - // Returns ErrClosed if the registry is closed. - virtual std::error_code lookup(Zone zone, - std::shared_ptr& out) = 0; - - // Controllers returns all currently registered controllers. - virtual std::vector> controllers() = 0; - - // Close closes all registered controllers and releases registry resources. - // Safe to call multiple times. - virtual std::error_code close() = 0; -}; - } // namespace rcp // Enable std::error_code construction from rcp::Errc. diff --git a/include/rcp/redundancy.hpp b/include/rcp/redundancy.hpp index f53577d..4bc261c 100644 --- a/include/rcp/redundancy.hpp +++ b/include/rcp/redundancy.hpp @@ -7,17 +7,24 @@ // fusa:req REQ-RED-007 // fusa:req REQ-RED-008 -// Hot-standby Registry and HPC failover for ASIL-B fault tolerance (v0.23.0). +// Hot-standby primary/standby failover for ASIL-B fault tolerance (v0.23.0). // -// RedundantController holds a primary and a standby rcp::Controller for the -// same zone. All sends go to the primary; on ErrClosed or ErrTimeout the -// controller promotes the standby automatically and retries. +// RedundantRequestFn holds a primary and a standby rcp::RequestFn (rcp/ +// adapt.hpp's client-side send-equivalent call) addressing the same RC +// Server endpoint over two independent paths. All requests go to the +// primary; on ErrClosed or ErrTimeout the active path is promoted to the +// standby automatically and the request is retried. // -// RedundantRegistry wraps two rcp::Registry instances (primary/standby). -// Heartbeat health monitoring (via watchdog::Keeper) drives promotion. +// Rebound (cpp-RCP-FS-03, #86): this was `RedundantController`, an +// `rcp::Controller` decorator keyed by the retired `Zone` (`Zone zone()` +// echoed whichever inner controller happened to be active). That base +// interface and Zone itself are retired (cpp-RCP-FS-01, #84); a redundant +// pair is now identified by whatever the embedding application already +// uses to key its two `RequestFn`s (e.g. a stream_key), not by this header. #pragma once #include "rcp.hpp" +#include "adapt.hpp" #include #include @@ -32,26 +39,35 @@ struct Config { int max_retries {1}; // number of retries on the standby before giving up }; -// ── RedundantController ─────────────────────────────────────────────────────── +// ── RedundantRequestFn ──────────────────────────────────────────────────────── -class RedundantController final : public rcp::Controller { +class RedundantRequestFn { public: - RedundantController(std::shared_ptr primary, - std::shared_ptr standby, - Config cfg = {}) + RedundantRequestFn(RequestFn primary, RequestFn standby, Config cfg = {}) : primary_(std::move(primary)) , standby_(std::move(standby)) , cfg_(cfg) { - // Both controllers must serve the same zone. - active_ = primary_; + active_ = &primary_; } - Zone zone() const noexcept override { return active_->zone(); } - - std::error_code send(const rcp::Context& ctx, - const Command& cmd, - Response& out) override { - auto ec = active_->send(ctx, cmd, out); + // active_ points into this object's own primary_/standby_ members, so + // copying or moving a RedundantRequestFn would leave active_ dangling + // (or pointing at the wrong instance) — not supported. Always hold one + // through a std::shared_ptr (see new_redundant() below). + RedundantRequestFn(const RedundantRequestFn&) = delete; + RedundantRequestFn& operator=(const RedundantRequestFn&) = delete; + RedundantRequestFn(RedundantRequestFn&&) = delete; + RedundantRequestFn& operator=(RedundantRequestFn&&) = delete; + + std::error_code send(const rcp::Context& ctx, const acf::AcfMessageInfo& req, + const std::vector& payload, + acf::AcfMessageInfo& out, std::vector& out_payload) { + RequestFn* active; + { + std::lock_guard lk(mu_); + active = active_; + } + auto ec = (*active)(ctx, req, payload, out, out_payload); if (!ec) return {}; if (!cfg_.auto_promote) return ec; @@ -60,51 +76,49 @@ class RedundantController final : public rcp::Controller { if (ec == ErrClosed || ec == ErrTimeout) { promote(); for (int i = 0; i < cfg_.max_retries; ++i) { - ec = active_->send(ctx, cmd, out); + RequestFn* retry_active; + { + std::lock_guard lk(mu_); + retry_active = active_; + } + ec = (*retry_active)(ctx, req, payload, out, out_payload); if (!ec) return {}; } } return ec; } - std::error_code subscribe(const rcp::Context& ctx, - std::shared_ptr& out) override { - return active_->subscribe(ctx, out); - } - - std::error_code close() override { - std::lock_guard lk(mu_); - auto ec = primary_->close(); - auto ec2 = standby_->close(); - return ec ? ec : ec2; + // operator() lets a RedundantRequestFn itself be handed anywhere an + // rcp::RequestFn is expected — e.g. straight into rcp::Adapt(). + std::error_code operator()(const rcp::Context& ctx, const acf::AcfMessageInfo& req, + const std::vector& payload, + acf::AcfMessageInfo& out, std::vector& out_payload) { + return send(ctx, req, payload, out, out_payload); } - // promote manually promotes the standby to active. + // promote manually promotes the standby to active (or back to primary + // if the standby is already active). void promote() { std::lock_guard lk(mu_); - if (active_ == primary_) active_ = standby_; - else active_ = primary_; + active_ = (active_ == &primary_) ? &standby_ : &primary_; } bool is_primary_active() const { std::lock_guard lk(mu_); - return active_ == primary_; + return active_ == &primary_; } private: - std::shared_ptr primary_; - std::shared_ptr standby_; - std::shared_ptr active_; + RequestFn primary_; + RequestFn standby_; + RequestFn* active_; Config cfg_; mutable std::mutex mu_; }; -inline std::shared_ptr new_controller( - std::shared_ptr primary, - std::shared_ptr standby, - Config cfg = {}) { - return std::make_shared( - std::move(primary), std::move(standby), cfg); +inline std::shared_ptr new_redundant( + RequestFn primary, RequestFn standby, Config cfg = {}) { + return std::make_shared(std::move(primary), std::move(standby), cfg); } } // namespace redundancy diff --git a/include/rcp/version.hpp b/include/rcp/version.hpp index cd89f63..6f76ce0 100644 --- a/include/rcp/version.hpp +++ b/include/rcp/version.hpp @@ -6,6 +6,6 @@ namespace rcp { // Semantic version of the cpp-RCP implementation (matches the latest git tag). -constexpr std::string_view kVersion = "2.18.0"; +constexpr std::string_view kVersion = "2.19.0"; } // namespace rcp diff --git a/include/relay/relay.hpp b/include/relay/relay.hpp index 5bc311f..1295a1e 100644 --- a/include/relay/relay.hpp +++ b/include/relay/relay.hpp @@ -1,9 +1,10 @@ -// RELAY C++ bindings — relay namespace types (RELAY spec §18.2, v0.2). +// RELAY C++ bindings — relay namespace types (RELAY spec §18.2). // // Defines the protocol-agnostic relay:: namespace: Protocol, Message, Errc // sentinels, Channel, Context, SubscriberOptions, Node, and Caller. // cpp-RCP conformance: include this header, then use rcp::Adapt() (adapt.hpp) -// to wrap a Controller as a relay::Caller. +// to wrap an rcp::RequestFn (a client-side send-equivalent call) as a +// relay::Caller. #pragma once #include @@ -24,14 +25,20 @@ namespace relay { // ── Spec version (§19.4) ───────────────────────────────────────────────────── -constexpr std::string_view kRelaySpecVersion = "1.11"; - -// NOTE: keep in sync with RELAY spec/version.json. cpp-RCP targets RELAY v1.11 -// (stable). It is core-conformant (§17): version/capabilities/status, canonical -// types, the §8 interface, lifecycle, and error sentinels. The §11.2 streaming -// JSON crossbar spoke and the protocol-flags single-message send are provided -// via the CLI; `convert`/interop (§20.3 tooling-conformance) is not declared. -// RCP's ToMessage()/FromMessage() mappings are unchanged from v1.0. +constexpr std::string_view kRelaySpecVersion = "2.0"; + +// NOTE: keep in sync with RELAY spec/version.json. cpp-RCP targets RELAY v2.0 +// (stable) — the breaking revision that retired the pre-TC18 placeholder +// Zone/Command/Response/Status/Priority/CommandType model in favor of the +// real OPEN Alliance TC18 Remote Control Protocol Specification v0.5.1_RC +// (§15.5) and renamed the RCP-facing client interface to `Controller` +// (§8.5); see cpp-RCP-FS-01 (#84) for the corresponding removal from this +// codebase. It is core-conformant (§17): version/capabilities/status, +// canonical types, the §8 interface, lifecycle, and error sentinels. The +// §11.2 streaming JSON crossbar spoke and the protocol-flags single-message +// send are provided via the CLI; `convert`/interop (§20.3 tooling- +// conformance) is not declared. RCP's ToMessage()/FromMessage() mappings +// changed at v2.0 per §15.7.5 (see rcp/adapt.hpp's own #88 fix note). // ── Protocol identifiers (§3) ──────────────────────────────────────────────── diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index e0d9b2b..a9b5efe 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -6,10 +6,6 @@ add_executable(test_mock test_mock.cpp) target_link_libraries(test_mock PRIVATE rcp::rcp Catch2::Catch2WithMain) add_test(NAME rcp_mock COMMAND test_mock) -add_executable(test_legacy_mock test_legacy_mock.cpp) -target_link_libraries(test_legacy_mock PRIVATE rcp::rcp Catch2::Catch2WithMain) -add_test(NAME rcp_legacy_mock COMMAND test_legacy_mock) - add_executable(test_avtp test_avtp.cpp) target_link_libraries(test_avtp PRIVATE rcp::rcp Catch2::Catch2WithMain) add_test(NAME rcp_avtp COMMAND test_avtp) diff --git a/tests/bench_mock.cpp b/tests/bench_mock.cpp index 24e4fd5..03e3b23 100644 --- a/tests/bench_mock.cpp +++ b/tests/bench_mock.cpp @@ -3,10 +3,18 @@ // Measures round-trip latency and throughput for common operations. // Run with: ctest -R bench --output-on-failure // Or directly: ./tests/bench_mock [!benchmark] +// +// Rebound (cpp-RCP-FS-01/#84): this used to benchmark +// legacy_mock::Controller::send() against the retired Zone/Command/Response +// model. That model is retired; rcp::mock::Server (rcp/mock.hpp) — the +// TC18-shaped in-process RC Server simulator this file's own name already +// referred to — is the transport actually under benchmark now. #include #include -#include +#include +#include +#include #include #include @@ -14,62 +22,70 @@ using namespace rcp; // ── Helpers ─────────────────────────────────────────────────────────────────── -static std::shared_ptr make_ctrl(Zone z = Zone::FrontLeft) { - return std::make_shared(z); +static std::shared_ptr make_server() { + auto srv = std::make_shared(); + srv->advance_to_rcp_configured(); + return srv; } // ── Sanity test (always runs in CTest) ─────────────────────────────────────── -TEST_CASE("bench mock baseline send succeeds", "[bench]") { - auto ctrl = make_ctrl(); - Command cmd; - cmd.zone = Zone::FrontLeft; - Response resp; - REQUIRE_FALSE(ctrl->send(Context::background(), cmd, resp)); +TEST_CASE("bench mock baseline dispatch succeeds", "[bench]") { + auto srv = make_server(); + auto req = acf::make_standard_request(mock::kGpioByteBusId, /*transaction_num=*/0, + /*write=*/false, /*read_size=*/0); + acf::AcfMessageInfo resp; + std::vector resp_payload; + REQUIRE_FALSE(srv->dispatch(0, req, {}, resp, resp_payload)); } // ── Benchmarks (run with: bench_mock [!benchmark]) ─────────────────────────── -TEST_CASE("Benchmark: Send_RoundTrip", "[!benchmark]") { - auto ctrl = make_ctrl(); - Command cmd; - cmd.zone = Zone::FrontLeft; - Response resp; - auto ctx = Context::background(); +TEST_CASE("Benchmark: Dispatch_RoundTrip", "[!benchmark]") { + auto srv = make_server(); + auto req = acf::make_standard_request(mock::kGpioByteBusId, 0, false, 0); + acf::AcfMessageInfo resp; + std::vector resp_payload; - BENCHMARK("legacy_mock::Controller::send round-trip") { - auto ec = ctrl->send(ctx, cmd, resp); + BENCHMARK("mock::Server::dispatch round-trip") { + auto ec = srv->dispatch(0, req, {}, resp, resp_payload); return ec; }; } -TEST_CASE("Benchmark: Send_RoundTrip_WithPayload", "[!benchmark]") { - auto ctrl = make_ctrl(); - Command cmd; - cmd.zone = Zone::FrontLeft; - cmd.payload = std::vector(64, 0xAB); - Response resp; - auto ctx = Context::background(); +TEST_CASE("Benchmark: Dispatch_RoundTrip_WithPayload", "[!benchmark]") { + auto srv = make_server(); + auto req = acf::make_standard_request(mock::kSpiByteBusId, 0, /*write=*/true, 0); + std::vector payload(64, 0xAB); + acf::AcfMessageInfo resp; + std::vector resp_payload; - BENCHMARK("legacy_mock::Controller::send 64-byte payload") { - auto ec = ctrl->send(ctx, cmd, resp); + BENCHMARK("mock::Server::dispatch 64-byte payload") { + auto ec = srv->dispatch(0, req, payload, resp, resp_payload); return ec; }; } -TEST_CASE("Benchmark: Send_Concurrent", "[!benchmark]") { - auto ctrl = make_ctrl(); +TEST_CASE("Benchmark: Dispatch_Concurrent", "[!benchmark]") { + // mock::Server (like rcp/shmem.hpp's Channel) dispatches synchronously + // on the calling thread and documents no locking of its own, so this + // benchmark gives each thread its own Server rather than sharing one — + // the same "one StreamID per connection" shape a real multi-client + // deployment would have anyway, and it avoids a genuine data race on + // shared mutable register-map/endpoint state that concurrent dispatch() + // calls against a single Server would otherwise introduce. constexpr int N = 8; - BENCHMARK("8-thread concurrent send") { + BENCHMARK("8-thread concurrent dispatch (one Server per thread)") { std::vector threads; threads.reserve(N); for (int i = 0; i < N; ++i) { - threads.emplace_back([&ctrl] { - Command cmd; - cmd.zone = Zone::FrontLeft; - Response resp; - auto ec = ctrl->send(Context::background(), cmd, resp); + threads.emplace_back([] { + auto srv = make_server(); + auto req = acf::make_standard_request(mock::kGpioByteBusId, 0, false, 0); + acf::AcfMessageInfo resp; + std::vector resp_payload; + auto ec = srv->dispatch(0, req, {}, resp, resp_payload); (void)ec; }); } @@ -77,36 +93,3 @@ TEST_CASE("Benchmark: Send_Concurrent", "[!benchmark]") { return N; }; } - -TEST_CASE("Benchmark: Publish_FanOut", "[!benchmark]") { - auto ctrl = make_ctrl(); - constexpr int kSubs = 8; - - // Register 8 subscribers. - std::vector> channels; - for (int i = 0; i < kSubs; ++i) { - std::shared_ptr ch; - auto ec = ctrl->subscribe(Context::background(), ch); - (void)ec; - channels.push_back(ch); - } - - std::vector payload(32, 0); - - BENCHMARK("publish to 8 subscribers") { - ctrl->publish(payload); - return kSubs; - }; - - for (auto& c : channels) c->close(); -} - -TEST_CASE("Benchmark: Registry_Lookup", "[!benchmark]") { - auto reg = legacy_mock::new_registry(); - - BENCHMARK("legacy_mock::Registry::lookup") { - std::shared_ptr out; - auto ec = reg->lookup(Zone::FrontLeft, out); - return ec; - }; -} diff --git a/tests/command_latency_test.cpp b/tests/command_latency_test.cpp index bf06fae..d39b259 100644 --- a/tests/command_latency_test.cpp +++ b/tests/command_latency_test.cpp @@ -1,14 +1,20 @@ // Command latency safety-timing test (GSN argument for ASIL-B timing budget). // -// Runs a 30-second workload against the mock controller and records P50/P99/ +// Runs a 30-second workload against the mock RC Server and records P50/P99/ // P999/Max latency. Writes results to COMMAND_LATENCY.md in the build directory // (relative to CWD) so cpfusa trace can include it as a safety artifact. // // Pass/fail gate: P99 < 500 ms and Max < 2 s, uniformly across environments // (wide enough to absorb OS/VM scheduler jitter on any shared, virtualized, or // sandboxed host, not only hosts with CI=1 set). +// +// Rebound (cpp-RCP-FS-01/#84): this used to drive legacy_mock::Controller +// against the retired Zone/Command/Response model. That model is retired; +// rcp::mock::Server (rcp/mock.hpp), the TC18-shaped in-process RC Server +// simulator, is the transport actually under timing evidence now. #include -#include +#include +#include #include #include @@ -25,18 +31,20 @@ static int64_t ns(steady_clock::duration d) { static double us(int64_t n) { return static_cast(n) / 1000.0; } TEST_CASE("Command latency P99 < 1ms over 30s workload", "[latency][safety]") { - auto ctrl = std::make_shared(Zone::FrontLeft); - Command cmd; - cmd.zone = Zone::FrontLeft; + mock::Server srv; + srv.advance_to_rcp_configured(); + auto req = acf::make_standard_request(mock::kGpioByteBusId, /*transaction_num=*/0, + /*write=*/false, /*read_size=*/0); std::vector samples; samples.reserve(100'000); auto deadline = steady_clock::now() + seconds(30); while (steady_clock::now() < deadline) { - Response resp; + acf::AcfMessageInfo resp; + std::vector resp_payload; auto t0 = steady_clock::now(); - auto ec = ctrl->send(Context::background(), cmd, resp); + auto ec = srv.dispatch(0, req, {}, resp, resp_payload); auto t1 = steady_clock::now(); (void)ec; samples.push_back(ns(t1 - t0)); @@ -54,7 +62,7 @@ TEST_CASE("Command latency P99 < 1ms over 30s workload", "[latency][safety]") { std::ofstream md("COMMAND_LATENCY.md"); if (md) { md << "# Command Latency Results\n\n" - << "Workload: " << n << " sends over 30 s (mock transport)\n\n" + << "Workload: " << n << " dispatches over 30 s (mock transport)\n\n" << "| Metric | Value |\n" << "|--------|-------|\n" << "| P50 | " << us(p50) << " µs |\n" diff --git a/tests/test_admin.cpp b/tests/test_admin.cpp index 66f06f4..9aea901 100644 --- a/tests/test_admin.cpp +++ b/tests/test_admin.cpp @@ -6,10 +6,13 @@ // fusa:test REQ-ADMIN-006 // fusa:test REQ-ADMIN-007 // fusa:test REQ-ADMIN-008 +// +// Rebound (cpp-RCP-FS-01/#84): AdminServer now reports streams from an +// rcp::shmem::Registry instead of zones from the retired rcp::Registry — +// see rcp/admin.hpp's own header comment. #include #include "rcp/admin.hpp" -#include "rcp/legacy_mock.hpp" #include #include @@ -17,38 +20,47 @@ using namespace rcp; -TEST_CASE("admin: zones lists registered controllers", "[admin]") { - legacy_mock::Registry reg; +namespace { +std::shared_ptr add(shmem::Registry& reg, uint64_t stream_key) { + auto ch = shmem::new_channel(stream_key); + reg.add_channel(ch); + return ch; +} +} // namespace + +TEST_CASE("admin: streams lists registered channels", "[admin]") { + shmem::Registry reg; + for (uint64_t k : {1, 2, 3, 4, 5}) add(reg, k); admin::AdminServer srv(reg); - auto zones = srv.zones(); - REQUIRE(zones.size() == 5); // legacy_mock::Registry pre-populates all 5 - for (auto& zi : zones) { - REQUIRE(zi.registered); + auto streams = srv.streams(); + REQUIRE(streams.size() == 5); + for (auto& si : streams) { + REQUIRE(si.registered); } } TEST_CASE("admin: subscribe receives emitted events", "[admin]") { - legacy_mock::Registry reg; + shmem::Registry reg; admin::AdminServer srv(reg); std::vector received; srv.subscribe([&](const admin::Event& ev) { received.push_back(ev); }); - srv.emit({admin::EventType::ZoneRegistered, Zone::FrontLeft, {}}); - srv.emit({admin::EventType::StatusUpdate, Zone::RearRight, {}}); + srv.emit({admin::EventType::StreamRegistered, 1, {}}); + srv.emit({admin::EventType::StatusUpdate, 4, {}}); REQUIRE(received.size() == 2); - REQUIRE(received[0].type == admin::EventType::ZoneRegistered); - REQUIRE(received[1].zone == Zone::RearRight); + REQUIRE(received[0].type == admin::EventType::StreamRegistered); + REQUIRE(received[1].stream_key == 4); } TEST_CASE("admin: metrics_text contains counter lines", "[admin]") { - legacy_mock::Registry reg; + shmem::Registry reg; admin::AdminServer srv(reg); - srv.record_counter("rcp.commands.total", "zone=\"FrontLeft\"", 10.0); - srv.record_counter("rcp.commands.total", "zone=\"FrontLeft\"", 5.0); + srv.record_counter("rcp.commands.total", "stream=\"1\"", 10.0); + srv.record_counter("rcp.commands.total", "stream=\"1\"", 5.0); auto text = srv.metrics_text(); REQUIRE(text.find("rcp.commands.total") != std::string::npos); @@ -56,34 +68,34 @@ TEST_CASE("admin: metrics_text contains counter lines", "[admin]") { } TEST_CASE("admin: multiple subscribers all receive events", "[admin]") { - legacy_mock::Registry reg; + shmem::Registry reg; admin::AdminServer srv(reg); int count_a = 0, count_b = 0; srv.subscribe([&](const admin::Event&) { ++count_a; }); srv.subscribe([&](const admin::Event&) { ++count_b; }); - srv.emit({admin::EventType::ZoneDeregistered, Zone::Central, {}}); + srv.emit({admin::EventType::StreamDeregistered, 3, {}}); REQUIRE(count_a == 1); REQUIRE(count_b == 1); } -TEST_CASE("admin: event delivers correct type and zone", "[admin][REQ-ADMIN-008]") { - legacy_mock::Registry reg; +TEST_CASE("admin: event delivers correct type and stream_key", "[admin][REQ-ADMIN-008]") { + shmem::Registry reg; admin::AdminServer srv(reg); admin::Event got{}; srv.subscribe([&](const admin::Event& ev) { got = ev; }); - srv.emit({admin::EventType::StatusUpdate, Zone::RearLeft, {}}); + srv.emit({admin::EventType::StatusUpdate, 2, {}}); REQUIRE(got.type == admin::EventType::StatusUpdate); - REQUIRE(got.zone == Zone::RearLeft); + REQUIRE(got.stream_key == 2); } TEST_CASE("admin: concurrent record_counter and emit are thread-safe", "[admin][REQ-ADMIN-004][REQ-ADMIN-005]") { - legacy_mock::Registry reg; + shmem::Registry reg; admin::AdminServer srv(reg); std::atomic events{0}; @@ -95,8 +107,8 @@ TEST_CASE("admin: concurrent record_counter and emit are thread-safe", for (int t = 0; t < kThreads; ++t) { ts.emplace_back([&] { for (int i = 0; i < kPerThread; ++i) { - srv.record_counter("rcp.commands.total", "zone=\"FrontLeft\"", 1.0); - srv.emit({admin::EventType::StatusUpdate, Zone::FrontLeft, {}}); + srv.record_counter("rcp.commands.total", "stream=\"1\"", 1.0); + srv.emit({admin::EventType::StatusUpdate, 1, {}}); } }); } diff --git a/tests/test_cli.cpp b/tests/test_cli.cpp index 6ceb16d..fe03d94 100644 --- a/tests/test_cli.cpp +++ b/tests/test_cli.cpp @@ -17,6 +17,13 @@ // `status` coverage is unchanged in shape, per this milestone's own scope // note, with `capabilities`'s transports/features assertions refreshed to // the v2.16.0 field values. +// +// cpp-RCP-05/#74: spec_version assertion bumped 1.11 -> 2.0. +// cpp-RCP-FS-05/#88: relay.Message ids below are the plain decimal +// ByteBusID string (RELAY spec v2.0 §15.7.5), not the old +// "<16-hex-stream_key>:" form. +// cpp-RCP-07/#75: capabilities no longer reports "loaning" — no +// LoaningController is wired into the CLI-exposed surface. #include #include @@ -94,7 +101,7 @@ TEST_CASE("cli: version --format json has all required fields", "[cli][conforman REQUIRE(has_key(s, k)); } REQUIRE(s.find("\"language\":\"cpp\"") != std::string::npos); - REQUIRE(s.find("\"spec_version\":\"1.11\"") != std::string::npos); + REQUIRE(s.find("\"spec_version\":\"2.0\"") != std::string::npos); REQUIRE(s.find("\"protocol_int\":5") != std::string::npos); } @@ -142,7 +149,17 @@ TEST_CASE("cli: capabilities transports/features reflect the v2.16.0 rebuild", REQUIRE(s.find("\"gpio\"") != std::string::npos); REQUIRE(s.find("\"spi\"") != std::string::npos); REQUIRE(s.find("\"can\"") != std::string::npos); - REQUIRE(s.find("\"loaning\"") != std::string::npos); +} + +TEST_CASE("cli: capabilities does not claim \"loaning\" (no LoaningController is wired up)", + "[cli][conformance][REQ-CLI-002]") { + // cpp-RCP-07/#75: the RELAY conformance gate checks self-reported + // capabilities against what's actually wired up; Adapt() has taken a + // plain RequestFn (not a LoaningController) since v2.16.0. + int code = 0; + auto s = capture({"capabilities"}, code); + REQUIRE(code == rcp::cli::kOk); + REQUIRE(s.find("\"loaning\"") == std::string::npos); } // ── §12.3 status ────────────────────────────────────────────────────────────── @@ -171,13 +188,14 @@ TEST_CASE("cli: send is declared in capabilities", "[cli][conformance][REQ-CLI-0 TEST_CASE("cli: send --format json publishes each NDJSON message", "[cli][conformance][REQ-CLI-005]") { // Three well-formed relay.Message lines addressed to the mock GPIO/SPI - // endpoints (byte_bus_id 1 and 2) via the v2.16.0 "<16 hex>:" id. - // SPI (endpoint 2) accepts any payload length, unlike GPIO's fixed - // 4-byte bitmask, so short payloads are addressed there. + // endpoints (byte_bus_id 1 and 2) via the plain decimal ByteBusID id + // (RELAY spec v2.0 §15.7.5; cpp-RCP-FS-05/#88). SPI (endpoint 2) accepts + // any payload length, unlike GPIO's fixed 4-byte bitmask, so short + // payloads are addressed there. const std::string nd = - "{\"protocol\":5,\"id\":\"0000000000000000:2\",\"payload\":\"AQ==\",\"meta\":{\"rcp.op\":\"write\"}}\n" - "{\"protocol\":5,\"id\":\"0000000000000000:2\",\"seq\":7}\n" - "{\"protocol\":5,\"id\":\"0000000000000000:1\",\"meta\":{\"rcp.op\":\"read\"}}\n"; + "{\"protocol\":5,\"id\":\"2\",\"payload\":\"AQ==\",\"meta\":{\"rcp.op\":\"write\"}}\n" + "{\"protocol\":5,\"id\":\"2\",\"seq\":7}\n" + "{\"protocol\":5,\"id\":\"1\",\"meta\":{\"rcp.op\":\"read\"}}\n"; int code = 0; auto out = capture_in({"send", "--format", "json"}, nd, code); REQUIRE(code == rcp::cli::kOk); @@ -188,10 +206,11 @@ TEST_CASE("cli: send skips malformed and undeliverable lines without aborting", "[cli][conformance][REQ-CLI-005]") { const std::string nd = "not json\n" - "{\"id\":\"0000000000000000:1\"}\n" // deliverable (GPIO) - "{\"id\":\"0000000000000000:99\"}\n" // unregistered endpoint -> skipped - "{\"id\":\"not-a-valid-id\"}\n" // unparseable id -> skipped - "\n"; // blank -> skipped + "{\"id\":\"1\"}\n" // deliverable (GPIO) + "{\"id\":\"99\"}\n" // unregistered endpoint -> skipped + "{\"id\":\"0000000000000000:1\"}\n" // pre-#88 stream_key:byte_bus_id form -> skipped + "{\"id\":\"not-a-valid-id\"}\n" // unparseable id -> skipped + "\n"; // blank -> skipped int code = 0; std::string errout; auto out = capture_in({"send", "--format", "json"}, nd, code, &errout); @@ -212,7 +231,7 @@ TEST_CASE("cli: send base64 payload decodes into the published request", // "AQID" -> bytes {1,2,3}; SPI (endpoint 2) accepts any payload length. int code = 0; auto out = capture_in({"send", "--format", "json"}, - "{\"id\":\"0000000000000000:2\",\"payload\":\"AQID\"," + "{\"id\":\"2\",\"payload\":\"AQID\"," "\"meta\":{\"rcp.op\":\"write\"}}\n", code); REQUIRE(code == rcp::cli::kOk); @@ -226,7 +245,8 @@ TEST_CASE("cli: send --server --endpoint --op read dispatches a single request ( int code = 0; auto s = capture({"send", "--server", "0", "--endpoint", "1", "--op", "read"}, code); REQUIRE(code == rcp::cli::kOk); - REQUIRE(s.find("0000000000000000:1") != std::string::npos); + // --server is validated but no longer folded into the id (#88). + REQUIRE(s.find("sent to 1 ") != std::string::npos); } TEST_CASE("cli: send --server --endpoint --op write --payload dispatches (json)", @@ -236,7 +256,7 @@ TEST_CASE("cli: send --server --endpoint --op write --payload dispatches (json)" "--payload", "0102", "--format", "json"}, code); REQUIRE(code == rcp::cli::kOk); REQUIRE(s.find("\"sent\":true") != std::string::npos); - REQUIRE(s.find("\"id\":\"000000000000002a:2\"") != std::string::npos); + REQUIRE(s.find("\"id\":\"2\"") != std::string::npos); } TEST_CASE("cli: send missing --op returns invalid-args (2)", diff --git a/tests/test_config.cpp b/tests/test_config.cpp index 9ce492f..11e9c75 100644 --- a/tests/test_config.cpp +++ b/tests/test_config.cpp @@ -4,128 +4,82 @@ // fusa:test REQ-CFG-004 // fusa:test REQ-CFG-005 // fusa:test REQ-CFG-006 +// +// Rebound (cpp-RCP-FS-03/#86): config::load now bootstraps an +// rcp::shmem::Registry (keyed by stream_key) from a stream_key/byte_bus_id +// manifest instead of registering rcp::legacy_mock::Controller instances +// into the retired rcp::Registry keyed by Zone — see rcp/config.hpp's own +// header comment. #include #include "rcp/config.hpp" -#include "rcp/legacy_mock.hpp" -#include -#include #include using namespace rcp; -namespace { - -// A minimal empty-starting rcp::Registry test double. -// -// legacy_mock::Registry always pre-populates all 5 zones (see its own -// constructor), so it can't be used to observe "did config::load actually -// register this zone" starting from a clean slate. Milestone 61's -// deprecation sweep (ROADMAP.md, v2.17.0) removed rcp/proxy.hpp, which -// these tests previously borrowed purely as a generic empty rcp::Registry -// implementation — no proxy-specific behavior was ever under test here. -// This local double replaces that borrowed dependency directly instead of -// pulling proxy.hpp's concept back in; rcp/config.hpp itself is otherwise -// unchanged, since its own rebind onto the new server/endpoint model -// remains a separate, still-open item (see the Satellite Package -// Disposition table's `config.hpp` entry). -class EmptyRegistry final : public rcp::Registry { -public: - std::error_code register_ctrl(std::shared_ptr ctrl) override { - if (closed_) return ErrClosed; - if (ctrls_.count(ctrl->zone())) return ErrAlreadyExists; - ctrls_[ctrl->zone()] = std::move(ctrl); - return {}; - } - - std::error_code deregister(Zone z) override { - auto it = ctrls_.find(z); - if (it == ctrls_.end()) return ErrNotFound; - auto ctrl = it->second; - ctrls_.erase(it); - return ctrl->close(); - } - - std::error_code lookup(Zone z, std::shared_ptr& out) override { - if (closed_) return ErrClosed; - auto it = ctrls_.find(z); - if (it == ctrls_.end()) return ErrNotFound; - out = it->second; - return {}; - } - - std::vector> controllers() override { - std::vector> out; - out.reserve(ctrls_.size()); - for (auto& kv : ctrls_) out.push_back(kv.second); - return out; - } - - std::error_code close() override { - if (closed_) return {}; - closed_ = true; - auto local = std::move(ctrls_); - for (auto& kv : local) (void)kv.second->close(); - return {}; - } - -private: - std::map> ctrls_; - bool closed_ = false; -}; +TEST_CASE("config: parse_json two endpoints on one stream", "[config]") { + const std::string json = R"({ + "endpoints": [ + { "stream_key": "0", "byte_bus_id": 1, "priority": "Normal" }, + { "stream_key": "0", "byte_bus_id": 2, "priority": "High" } + ] + })"; -} // namespace + auto m = config::parse_json(json); + REQUIRE(m.endpoints.size() == 2); + REQUIRE(m.endpoints[0].stream_key == 0); + REQUIRE(m.endpoints[0].byte_bus_id == 1); + REQUIRE(m.endpoints[1].byte_bus_id == 2); + REQUIRE(m.endpoints[1].priority == "High"); +} -TEST_CASE("config: parse_json two zones", "[config]") { +TEST_CASE("config: parse_json accepts a hex stream_key", "[config]") { const std::string json = R"({ - "zones": [ - { "zone": "FrontLeft", "priority": "Normal" }, - { "zone": "FrontRight", "priority": "High" } - ] + "endpoints": [ { "stream_key": "0x2A", "byte_bus_id": 1 } ] })"; auto m = config::parse_json(json); - REQUIRE(m.zones.size() == 2); - REQUIRE(m.zones[0].zone == Zone::FrontLeft); - REQUIRE(m.zones[1].zone == Zone::FrontRight); - REQUIRE(m.zones[1].priority == "High"); + REQUIRE(m.endpoints.size() == 1); + REQUIRE(m.endpoints[0].stream_key == 0x2A); } -TEST_CASE("config: parse_json unknown zone throws", "[config]") { - const std::string json = R"({ "zones": [{ "zone": "BadZone" }] })"; +TEST_CASE("config: parse_json out-of-range byte_bus_id throws", "[config]") { + const std::string json = R"({ "endpoints": [{ "stream_key": "0", "byte_bus_id": 300 }] })"; REQUIRE_THROWS_AS(config::parse_json(json), config::ParseError); } -TEST_CASE("config: load registers controllers", "[config]") { +TEST_CASE("config: parse_json invalid stream_key throws", "[config]") { + const std::string json = R"({ "endpoints": [{ "stream_key": "not-a-number", "byte_bus_id": 1 }] })"; + REQUIRE_THROWS_AS(config::parse_json(json), config::ParseError); +} + +TEST_CASE("config: load bootstraps one channel per distinct stream_key", "[config]") { const std::string json = R"({ - "zones": [ - { "zone": "RearLeft" }, - { "zone": "RearRight" } + "endpoints": [ + { "stream_key": "1", "byte_bus_id": 1 }, + { "stream_key": "1", "byte_bus_id": 2 }, + { "stream_key": "2", "byte_bus_id": 1 } ] })"; - // legacy_mock::Registry pre-populates all 5 zones, so it can't show - // config::load actually registering a new one; use the empty test - // double instead (see the EmptyRegistry comment above). - EmptyRegistry preg; - REQUIRE_FALSE(config::load(json, preg)); + shmem::Registry reg; + REQUIRE_FALSE(config::load(json, reg)); - std::shared_ptr ctrl; - REQUIRE_FALSE(preg.lookup(Zone::RearLeft, ctrl)); - REQUIRE_FALSE(preg.lookup(Zone::RearRight, ctrl)); + std::shared_ptr ch; + REQUIRE_FALSE(reg.lookup(1, ch)); + REQUIRE_FALSE(reg.lookup(2, ch)); + REQUIRE(reg.channels().size() == 2); // one channel per distinct stream_key, not per endpoint } -TEST_CASE("config: load duplicate zone returns ErrAlreadyExists", "[config]") { - const std::string json = R"({ - "zones": [ - { "zone": "Central" }, - { "zone": "Central" } - ] - })"; +TEST_CASE("config: load returns ErrAlreadyExists for a stream_key already in the registry", + "[config]") { + const std::string json = R"({ "endpoints": [{ "stream_key": "5", "byte_bus_id": 1 }] })"; + + shmem::Registry reg; + REQUIRE_FALSE(reg.add_channel(shmem::new_channel(5))); - EmptyRegistry preg; - auto ec = config::load(json, preg); + auto ec = config::load(json, reg); REQUIRE(ec == ErrAlreadyExists); } @@ -133,10 +87,10 @@ TEST_CASE("config: ParseError is a std::runtime_error subclass", "[config][REQ-C // Catchable as std::runtime_error (and thus std::exception) and carries a message. bool caught = false; try { - config::parse_json(R"({ "zones": [{ "zone": "Nope" }] })"); + config::parse_json(R"({ "endpoints": [{ "stream_key": "0", "byte_bus_id": 999 }] })"); } catch (const std::runtime_error& e) { caught = true; - REQUIRE(std::string(e.what()).find("Nope") != std::string::npos); + REQUIRE(std::string(e.what()).find("999") != std::string::npos); } REQUIRE(caught); REQUIRE(std::is_base_of::value); diff --git a/tests/test_faultinject.cpp b/tests/test_faultinject.cpp index 383f334..890ae58 100644 --- a/tests/test_faultinject.cpp +++ b/tests/test_faultinject.cpp @@ -6,71 +6,96 @@ // fusa:test REQ-FI-006 // fusa:test REQ-FI-007 // fusa:test REQ-FI-008 - +// +// Rebound (cpp-RCP-FS-03/#86): faultinject::Interceptor now wraps an +// rcp::RequestFn (rcp/adapt.hpp) instead of decorating the retired +// rcp::Controller — see rcp/faultinject.hpp's own header comment. The old +// REQ-FI-008 case asserted zone() passthrough, which has no analog on a +// plain RequestFn; it is replaced below with a case asserting that an +// Interceptor is itself usable wherever an rcp::RequestFn is expected. #include -#include -#include + +#include "rcp/faultinject.hpp" +#include "rcp/mock.hpp" + +#include +#include using namespace rcp; +using namespace std::chrono_literals; + +namespace { + +RequestFn make_mock_fn(std::shared_ptr srv) { + return [srv](const Context&, const acf::AcfMessageInfo& req, + const std::vector& payload, + acf::AcfMessageInfo& out, std::vector& out_payload) { + return srv->dispatch(0, req, payload, out, out_payload); + }; +} -static std::shared_ptr make_mock(Zone z = Zone::FrontLeft) { - return std::make_shared(z); +std::shared_ptr make_server() { + auto srv = std::make_shared(); + srv->advance_to_rcp_configured(); + return srv; } +acf::AcfMessageInfo gpio_read_request() { + return acf::make_standard_request(mock::kGpioByteBusId, 0, false, 0); +} + +} // namespace + // ── No rules ───────────────────────────────────────────────────────────────── TEST_CASE("FaultInject::send passes through when no rules active", "[faultinject][REQ-FI-001]") { - auto inner = make_mock(); - faultinject::Controller fi(inner); - - Command cmd; - cmd.zone = Zone::FrontLeft; - Response resp; - REQUIRE_FALSE(fi.send(Context::background(), cmd, resp)); - REQUIRE(resp.status == ResponseStatus::OK); + faultinject::Interceptor fi(make_mock_fn(make_server())); + auto req = gpio_read_request(); + acf::AcfMessageInfo out; + std::vector out_payload; + REQUIRE_FALSE(fi.send(Context::background(), req, {}, out, out_payload)); + REQUIRE_FALSE(out.err); } // ── Drop ────────────────────────────────────────────────────────────────────── TEST_CASE("FaultInject Drop rule causes send to return an error", "[faultinject][REQ-FI-002]") { - auto inner = make_mock(); - faultinject::Controller fi(inner); + faultinject::Interceptor fi(make_mock_fn(make_server())); faultinject::Rule r; r.type = faultinject::FaultType::Drop; r.count = -1; // infinite fi.add_rule(r); - Command cmd; - cmd.zone = Zone::FrontLeft; - Response resp; - REQUIRE(fi.send(Context::background(), cmd, resp)); + auto req = gpio_read_request(); + acf::AcfMessageInfo out; + std::vector out_payload; + REQUIRE(fi.send(Context::background(), req, {}, out, out_payload)); } // ── Error ───────────────────────────────────────────────────────────────────── -TEST_CASE("FaultInject Error rule returns ResponseStatus::Error", "[faultinject][REQ-FI-003]") { - auto inner = make_mock(); - faultinject::Controller fi(inner); +TEST_CASE("FaultInject Error rule returns a response with err set", "[faultinject][REQ-FI-003]") { + faultinject::Interceptor fi(make_mock_fn(make_server())); faultinject::Rule r; r.type = faultinject::FaultType::Error; r.count = -1; fi.add_rule(r); - Command cmd; - cmd.zone = Zone::FrontLeft; - Response resp; - auto ec_e = fi.send(Context::background(), cmd, resp); - (void)ec_e; - REQUIRE(resp.status == ResponseStatus::Error); + auto req = gpio_read_request(); + acf::AcfMessageInfo out; + std::vector out_payload; + auto ec = fi.send(Context::background(), req, {}, out, out_payload); + REQUIRE_FALSE(ec); + REQUIRE(out.err); + REQUIRE(out.rsp); } // ── Slow ────────────────────────────────────────────────────────────────────── TEST_CASE("FaultInject Slow rule adds latency", "[faultinject][REQ-FI-004]") { - auto inner = make_mock(); - faultinject::Controller fi(inner); + faultinject::Interceptor fi(make_mock_fn(make_server())); faultinject::Rule r; r.type = faultinject::FaultType::Slow; @@ -78,12 +103,12 @@ TEST_CASE("FaultInject Slow rule adds latency", "[faultinject][REQ-FI-004]") { r.count = 1; fi.add_rule(r); - Command cmd; - cmd.zone = Zone::FrontLeft; - Response resp; + auto req = gpio_read_request(); + acf::AcfMessageInfo out; + std::vector out_payload; auto start = std::chrono::steady_clock::now(); - auto ec_slow = fi.send(Context::background(), cmd, resp); + auto ec_slow = fi.send(Context::background(), req, {}, out, out_payload); (void)ec_slow; auto elapsed = std::chrono::steady_clock::now() - start; REQUIRE(elapsed >= std::chrono::milliseconds(20)); @@ -92,63 +117,72 @@ TEST_CASE("FaultInject Slow rule adds latency", "[faultinject][REQ-FI-004]") { // ── Count-based expiry ──────────────────────────────────────────────────────── TEST_CASE("FaultInject rule expires after count sends", "[faultinject][REQ-FI-005]") { - auto inner = make_mock(); - faultinject::Controller fi(inner); + faultinject::Interceptor fi(make_mock_fn(make_server())); faultinject::Rule r; r.type = faultinject::FaultType::Drop; r.count = 2; // fires twice then expires fi.add_rule(r); - Command cmd; - cmd.zone = Zone::FrontLeft; - Response resp; + auto req = gpio_read_request(); + acf::AcfMessageInfo out; + std::vector out_payload; - { auto ec = fi.send(Context::background(), cmd, resp); REQUIRE(ec); } // drop 1 - { auto ec = fi.send(Context::background(), cmd, resp); REQUIRE(ec); } // drop 2 - REQUIRE_FALSE(fi.send(Context::background(), cmd, resp)); // passes + { auto ec = fi.send(Context::background(), req, {}, out, out_payload); REQUIRE(ec); } // drop 1 + { auto ec = fi.send(Context::background(), req, {}, out, out_payload); REQUIRE(ec); } // drop 2 + REQUIRE_FALSE(fi.send(Context::background(), req, {}, out, out_payload)); // passes } // ── clear_rules ─────────────────────────────────────────────────────────────── TEST_CASE("FaultInject::clear_rules removes all active rules", "[faultinject][REQ-FI-006]") { - auto inner = make_mock(); - faultinject::Controller fi(inner); + faultinject::Interceptor fi(make_mock_fn(make_server())); faultinject::Rule r; r.type = faultinject::FaultType::Drop; r.count = -1; fi.add_rule(r); - fi.clear_rules(); // NOLINT(bugprone-unused-return-value) — void return + fi.clear_rules(); - Command cmd; - cmd.zone = Zone::FrontLeft; - Response resp; - REQUIRE_FALSE(fi.send(Context::background(), cmd, resp)); + auto req = gpio_read_request(); + acf::AcfMessageInfo out; + std::vector out_payload; + REQUIRE_FALSE(fi.send(Context::background(), req, {}, out, out_payload)); } // ── Timeout ─────────────────────────────────────────────────────────────────── TEST_CASE("FaultInject Timeout rule returns ErrTimeout", "[faultinject][REQ-FI-007]") { - auto inner = make_mock(); - faultinject::Controller fi(inner); + faultinject::Interceptor fi(make_mock_fn(make_server())); faultinject::Rule r; r.type = faultinject::FaultType::Timeout; r.count = -1; fi.add_rule(r); - Command cmd; - cmd.zone = Zone::FrontLeft; - Response resp; - auto ec = fi.send(Context::background(), cmd, resp); + auto req = gpio_read_request(); + acf::AcfMessageInfo out; + std::vector out_payload; + auto ec = fi.send(Context::background(), req, {}, out, out_payload); REQUIRE(ec == ErrTimeout); } -// ── Zone passthrough ────────────────────────────────────────────────────────── +// ── Usable as an rcp::RequestFn ─────────────────────────────────────────────── + +TEST_CASE("FaultInject::Interceptor is itself usable as an rcp::RequestFn via Adapt()", + "[faultinject][REQ-FI-008]") { + faultinject::Interceptor fi(make_mock_fn(make_server())); + // Interceptor holds a std::mutex, so it is not copyable; Adapt()'s + // RequestFn parameter would otherwise try to copy it into a + // std::function. std::ref keeps this call-by-reference. + auto caller = Adapt(std::ref(fi)); + + relay::Message req; + req.protocol = relay::Protocol::RCP; + req.id = endpoint_id_to_relay_id(mock::kGpioByteBusId); + req.meta["rcp.op"] = "read"; -TEST_CASE("FaultInject::zone returns inner zone", "[faultinject][REQ-FI-008]") { - auto inner = make_mock(Zone::Central); - faultinject::Controller fi(inner); - REQUIRE(fi.zone() == Zone::Central); + auto [resp, ec] = caller->call(relay::Context::with_timeout(1s), req); + REQUIRE_FALSE(ec); + REQUIRE(resp.id == req.id); } diff --git a/tests/test_legacy_mock.cpp b/tests/test_legacy_mock.cpp deleted file mode 100644 index 89ff20a..0000000 --- a/tests/test_legacy_mock.cpp +++ /dev/null @@ -1,467 +0,0 @@ -// fusa:test REQ-CTRL-001 -// fusa:test REQ-CTRL-002 -// fusa:test REQ-CTRL-003 -// fusa:test REQ-CTRL-004 -// fusa:test REQ-CTRL-005 -// fusa:test REQ-CTRL-006 -// fusa:test REQ-CTRL-007 -// fusa:test REQ-CTRL-008 -// fusa:test REQ-CTRL-009 -// fusa:test REQ-CTRL-010 -// fusa:test REQ-CTRL-011 -// fusa:test REQ-CTRL-012 -// fusa:test REQ-CTRL-013 -// fusa:test REQ-CTRL-014 -// fusa:test REQ-CTRL-015 -// fusa:test REQ-CTRL-016 -// fusa:test REQ-CTRL-017 -// fusa:test REQ-CTRL-018 -// fusa:test REQ-CTRL-019 -// fusa:test REQ-CTRL-020 -// fusa:test REQ-CTRL-021 -// fusa:test REQ-CTRL-022 -// fusa:test REQ-CTRL-023 -// fusa:test REQ-CTRL-024 -// fusa:test REQ-CTRL-025 -// fusa:test REQ-CTRL-026 -// fusa:test REQ-CTRL-027 -// fusa:test REQ-REG-001 -// fusa:test REQ-REG-002 -// fusa:test REQ-REG-003 -// fusa:test REQ-REG-004 -// fusa:test REQ-REG-005 -// fusa:test REQ-REG-006 -// fusa:test REQ-REG-007 -// fusa:test REQ-REG-008 -// fusa:test REQ-REG-009 -// fusa:test REQ-REG-010 -// fusa:test REQ-REG-011 -// fusa:test REQ-REG-012 -// fusa:test REQ-REG-013 -// fusa:test REQ-RESP-001 -// fusa:test REQ-RESP-002 -// fusa:test REQ-STAT-001 -// fusa:test REQ-STAT-002 -// fusa:test REQ-STAT-003 -// fusa:test REQ-STAT-004 -// fusa:test REQ-ERR-011 - -// Tests for rcp/legacy_mock.hpp — moved unchanged from tests/test_mock.cpp -// (ROADMAP.md milestone 56, "Test & Simulation Harness Rebuild", v2.12.0) -// when rcp/mock.hpp itself became the new RC Server simulator; only the -// mock:: -> legacy_mock:: namespace rename applies here, same as -// rcp/legacy_wire.hpp's tests/test_legacy_wire.cpp precedent at v2.0.0. See -// tests/test_mock.cpp for the new mock::Server coverage. - -#include -#include - -#include -#include -#include -#include - -using namespace rcp; - -// ── Registry pre-population ─────────────────────────────────────────────────── - -TEST_CASE("NewRegistry pre-populates all five standard zones", "[registry][REQ-REG-001]") { - auto reg = legacy_mock::new_registry(); - for (auto z : {Zone::FrontLeft, Zone::FrontRight, - Zone::RearLeft, Zone::RearRight, - Zone::Central}) { - std::shared_ptr ctrl; - REQUIRE_FALSE(reg->lookup(z, ctrl)); - REQUIRE(ctrl->zone() == z); - } - reg->close(); -} - -TEST_CASE("Registry::register_ctrl returns ErrAlreadyExists for duplicate zone", "[registry][REQ-REG-002]") { - auto reg = legacy_mock::new_registry(); - auto dup = std::make_shared(Zone::FrontLeft); - REQUIRE(reg->register_ctrl(dup) == ErrAlreadyExists); - reg->close(); -} - -TEST_CASE("Registry::deregister removes and closes the controller", "[registry][REQ-REG-003]") { - auto reg = legacy_mock::new_registry(); - REQUIRE_FALSE(reg->deregister(Zone::FrontLeft)); - std::shared_ptr ctrl; - REQUIRE(reg->lookup(Zone::FrontLeft, ctrl) == ErrNotFound); - reg->close(); -} - -TEST_CASE("Registry::lookup returns ErrNotFound for absent zone", "[registry][REQ-REG-004]") { - auto reg = legacy_mock::new_registry(); - REQUIRE_FALSE(reg->deregister(Zone::FrontLeft)); - std::shared_ptr ctrl; - REQUIRE(reg->lookup(Zone::FrontLeft, ctrl) == ErrNotFound); - reg->close(); -} - -TEST_CASE("Registry::close is idempotent", "[registry][REQ-REG-005]") { - auto reg = legacy_mock::new_registry(); - REQUIRE_FALSE(reg->close()); - REQUIRE_FALSE(reg->close()); -} - -TEST_CASE("Registry::controllers returns all registered controllers", "[registry][REQ-REG-006]") { - auto reg = legacy_mock::new_registry(); - auto ctrls = reg->controllers(); - REQUIRE(ctrls.size() == 5); - reg->close(); -} - -TEST_CASE("Registry::register_ctrl returns ErrClosed after close", "[registry][REQ-REG-007]") { - auto reg = legacy_mock::new_registry(); - reg->close(); - auto extra = std::make_shared(Zone::FrontLeft); - REQUIRE(reg->register_ctrl(extra) == ErrClosed); -} - -TEST_CASE("Registry::deregister returns ErrNotFound for never-registered zone", "[registry][REQ-REG-008]") { - auto reg = legacy_mock::new_registry(); - REQUIRE_FALSE(reg->deregister(Zone::FrontLeft)); - REQUIRE(reg->deregister(Zone::FrontLeft) == ErrNotFound); - reg->close(); -} - -TEST_CASE("Registered controller is immediately retrievable via lookup", "[registry][REQ-REG-009]") { - auto reg = legacy_mock::new_registry(); - REQUIRE_FALSE(reg->deregister(Zone::FrontLeft)); - auto nc = std::make_shared(Zone::FrontLeft); - REQUIRE_FALSE(reg->register_ctrl(nc)); - std::shared_ptr got; - REQUIRE_FALSE(reg->lookup(Zone::FrontLeft, got)); - REQUIRE(got.get() == nc.get()); - reg->close(); -} - -TEST_CASE("Registry::close closes all registered controllers", "[registry][REQ-REG-010]") { - auto reg = legacy_mock::new_registry(); - std::shared_ptr ctrl; - REQUIRE_FALSE(reg->lookup(Zone::FrontLeft, ctrl)); - reg->close(); - Response out; - auto ec = ctrl->send(Context::background(), Command{0, Zone::FrontLeft}, out); - REQUIRE(ec == ErrClosed); -} - -TEST_CASE("Registry::lookup on closed registry returns ErrClosed", "[registry][REQ-REG-011][REQ-REG-013]") { - auto reg = legacy_mock::new_registry(); - reg->close(); - std::shared_ptr ctrl; - REQUIRE(reg->lookup(Zone::FrontLeft, ctrl) == ErrClosed); -} - -TEST_CASE("Registry::deregister for already-deregistered zone returns ErrNotFound", "[registry][REQ-REG-012]") { - auto reg = legacy_mock::new_registry(); - REQUIRE_FALSE(reg->deregister(Zone::FrontLeft)); - REQUIRE(reg->deregister(Zone::FrontLeft) == ErrNotFound); - reg->close(); -} - -// ── Controller::send ────────────────────────────────────────────────────────── - -TEST_CASE("Controller::send returns StatusOK with no handler", "[controller][REQ-CTRL-001]") { - legacy_mock::Controller ctrl(Zone::FrontLeft); - Response out; - REQUIRE_FALSE(ctrl.send(Context::background(), Command{1, Zone::FrontLeft}, out)); - REQUIRE(out.status == ResponseStatus::OK); -} - -TEST_CASE("Controller::send dispatches to handler and returns response verbatim", "[controller][REQ-CTRL-002][REQ-CTRL-016]") { - bool called = false; - legacy_mock::Controller ctrl(Zone::FrontLeft, [&](const Command& cmd) -> Response { - called = true; - return Response{cmd.id, cmd.zone, ResponseStatus::Error, {}}; - }); - Response out; - REQUIRE_FALSE(ctrl.send(Context::background(), Command{42, Zone::FrontLeft}, out)); - REQUIRE(called); - REQUIRE(out.status == ResponseStatus::Error); - REQUIRE(out.command_id == 42); -} - -TEST_CASE("Controller::send returns ErrClosed after close", "[controller][REQ-CTRL-003]") { - legacy_mock::Controller ctrl(Zone::FrontLeft); - ctrl.close(); - Response out; - REQUIRE(ctrl.send(Context::background(), Command{1, Zone::FrontLeft}, out) == ErrClosed); -} - -TEST_CASE("Controller::send returns ErrTimeout when ctx is already done", "[controller][REQ-CTRL-004][REQ-CTRL-023]") { - bool handler_called = false; - legacy_mock::Controller ctrl(Zone::FrontLeft, [&](const Command&) -> Response { - handler_called = true; - return {}; - }); - auto past = Context::with_deadline( - std::chrono::steady_clock::now() - std::chrono::seconds(1)); - Response out; - REQUIRE(ctrl.send(past, Command{1, Zone::FrontLeft}, out) == ErrTimeout); - REQUIRE_FALSE(handler_called); -} - -TEST_CASE("Controller::close is idempotent", "[controller][REQ-CTRL-005]") { - legacy_mock::Controller ctrl(Zone::FrontLeft); - REQUIRE_FALSE(ctrl.close()); - REQUIRE_FALSE(ctrl.close()); -} - -TEST_CASE("Controller::subscribe channel closes when controller closes", "[controller][REQ-CTRL-007]") { - auto ctrl = std::make_unique(Zone::FrontLeft); - std::shared_ptr ch; - REQUIRE_FALSE(ctrl->subscribe(Context::background(), ch)); - ctrl->close(); - std::this_thread::sleep_for(std::chrono::milliseconds(30)); - REQUIRE(ch->is_closed()); -} - -TEST_CASE("Controller::subscribe returns ErrClosed after close", "[controller][REQ-CTRL-008]") { - legacy_mock::Controller ctrl(Zone::FrontLeft); - ctrl.close(); - std::shared_ptr ch; - REQUIRE(ctrl.subscribe(Context::background(), ch) == ErrClosed); -} - -TEST_CASE("Controller::subscribe concurrent with close is race-free", "[controller][REQ-CTRL-008]") { - // Regression test: subscribe() and close() used to check `closed_` and - // mutate `subs_` under separate critical sections, so a subscribe() could - // observe "open" and still add its channel to subs_ after close() had - // already run and cleared it — the added channel would only self-heal - // later via its own watcher thread polling `closed_`, not synchronously - // as part of close(). Now both are serialized under the same mutex, so - // whenever a subscribe() call succeeds concurrently with close(), close() - // is guaranteed to see (and synchronously close) that channel itself — - // checkable immediately after both threads join, with no sleep. - for (int iter = 0; iter < 200; ++iter) { - auto ctrl = std::make_unique(Zone::FrontLeft); - - std::vector> channels; - std::mutex channels_mu; - - std::vector subscribers; - for (int i = 0; i < 4; ++i) { - subscribers.emplace_back([&] { - std::shared_ptr ch; - if (!ctrl->subscribe(Context::background(), ch)) { - std::lock_guard lk(channels_mu); - channels.push_back(std::move(ch)); - } - }); - } - std::thread closer([&] { ctrl->close(); }); - - for (auto& t : subscribers) t.join(); - closer.join(); - - // No sleep: with the fix, every channel a successful subscribe() handed - // back is guaranteed closed by the time close() has returned, because - // close() (having won or lost the race under the shared lock) either - // never sees the channel or closes it itself before releasing the lock. - for (auto& ch : channels) { - REQUIRE(ch->is_closed()); - } - } -} - -TEST_CASE("Controller::zone returns declared zone", "[controller][REQ-CTRL-009]") { - legacy_mock::Controller ctrl(Zone::RearRight); - REQUIRE(ctrl.zone() == Zone::RearRight); -} - -TEST_CASE("Status Seq values are strictly increasing", "[controller][REQ-CTRL-010][REQ-STAT-002]") { - legacy_mock::Controller ctrl(Zone::Central); - std::shared_ptr ch; - REQUIRE_FALSE(ctrl.subscribe(Context::background(), ch)); - ctrl.publish({}); - ctrl.publish({}); - ctrl.publish({}); - uint32_t last = 0; - for (int i = 0; i < 3; ++i) { - auto s = ch->recv(); - REQUIRE(s.has_value()); - REQUIRE(s->seq > last); - last = s->seq; - } - ctrl.close(); -} - -TEST_CASE("Subscribe channel is closed when its context expires", "[controller][REQ-CTRL-011]") { - legacy_mock::Controller ctrl(Zone::FrontLeft); - auto ctx = Context::with_timeout(std::chrono::milliseconds(20)); - std::shared_ptr ch; - REQUIRE_FALSE(ctrl.subscribe(ctx, ch)); - std::this_thread::sleep_for(std::chrono::milliseconds(60)); - REQUIRE(ch->is_closed()); - ctrl.close(); -} - -TEST_CASE("Multiple concurrent subscribers each receive published status", "[controller][REQ-CTRL-006][REQ-CTRL-012]") { - legacy_mock::Controller ctrl(Zone::FrontLeft); - constexpr int N = 3; - std::vector> channels(N); - for (auto& ch : channels) REQUIRE_FALSE(ctrl.subscribe(Context::background(), ch)); - ctrl.publish({0x01}); - for (auto& ch : channels) { - auto s = ch->recv(); - REQUIRE(s.has_value()); - REQUIRE(s->zone == Zone::FrontLeft); - REQUIRE(s->payload == std::vector{0x01}); - } - ctrl.close(); -} - -TEST_CASE("Controller::send with CmdNoop returns StatusOK", "[controller][REQ-CTRL-013]") { - legacy_mock::Controller ctrl(Zone::FrontLeft); - Response out; - REQUIRE_FALSE(ctrl.send(Context::background(), Command{1, Zone::FrontLeft, CommandType::Noop}, out)); - REQUIRE(out.status == ResponseStatus::OK); -} - -TEST_CASE("Controller::send with CmdWatchdog returns StatusOK", "[controller][REQ-CTRL-014]") { - legacy_mock::Controller ctrl(Zone::FrontLeft); - Response out; - REQUIRE_FALSE(ctrl.send(Context::background(), Command{1, Zone::FrontLeft, CommandType::Watchdog}, out)); - REQUIRE(out.status == ResponseStatus::OK); -} - -TEST_CASE("Controller::send with CmdReset returns StatusOK", "[controller][REQ-CTRL-015]") { - legacy_mock::Controller ctrl(Zone::FrontLeft); - Response out; - REQUIRE_FALSE(ctrl.send(Context::background(), Command{1, Zone::FrontLeft, CommandType::Reset}, out)); - REQUIRE(out.status == ResponseStatus::OK); -} - -TEST_CASE("Publish on a closed controller does not throw or crash", "[controller][REQ-CTRL-017]") { - legacy_mock::Controller ctrl(Zone::FrontLeft); - ctrl.close(); - REQUIRE_NOTHROW(ctrl.publish({})); -} - -TEST_CASE("Concurrent send calls are data-race free", "[controller][REQ-CTRL-018]") { - legacy_mock::Controller ctrl(Zone::FrontLeft); - constexpr int THREADS = 8, ITERS = 100; - std::vector workers; - workers.reserve(THREADS); - std::atomic ok{0}; - for (int t = 0; t < THREADS; ++t) { - workers.emplace_back([&, t]() { - for (int i = 0; i < ITERS; ++i) { - Response out; - Command cmd{static_cast(t * ITERS + i), - Zone::FrontLeft, CommandType::Get}; - if (!ctrl.send(Context::background(), cmd, out)) ++ok; - } - }); - } - for (auto& w : workers) w.join(); - REQUIRE(ok == THREADS * ITERS); - ctrl.close(); -} - -TEST_CASE("Concurrent publish and subscribe are data-race free", "[controller][REQ-CTRL-019]") { - legacy_mock::Controller ctrl(Zone::FrontLeft); - std::shared_ptr ch; - REQUIRE_FALSE(ctrl.subscribe(Context::background(), ch)); - std::atomic stop{false}; - std::thread publisher([&]() { - while (!stop) ctrl.publish({0xAB}); - }); - std::this_thread::sleep_for(std::chrono::milliseconds(20)); - stop = true; - publisher.join(); - ctrl.close(); -} - -TEST_CASE("Status.Zone is correct for every published update", "[controller][REQ-CTRL-020][REQ-STAT-001]") { - legacy_mock::Controller ctrl(Zone::RearLeft); - std::shared_ptr ch; - REQUIRE_FALSE(ctrl.subscribe(Context::background(), ch)); - ctrl.publish({}); - auto s = ch->recv(); - REQUIRE(s.has_value()); - REQUIRE(s->zone == Zone::RearLeft); - ctrl.close(); -} - -TEST_CASE("Status.Payload matches published payload", "[controller][REQ-CTRL-021][REQ-STAT-004]") { - legacy_mock::Controller ctrl(Zone::FrontLeft); - std::shared_ptr ch; - REQUIRE_FALSE(ctrl.subscribe(Context::background(), ch)); - std::vector payload{0xDE, 0xAD, 0xBE, 0xEF}; - ctrl.publish(payload); - auto s = ch->recv(); - REQUIRE(s.has_value()); - REQUIRE(s->payload == payload); - ctrl.close(); -} - -TEST_CASE("Status.Healthy is true while controller is open", "[controller][REQ-CTRL-022][REQ-STAT-003]") { - legacy_mock::Controller ctrl(Zone::FrontLeft); - std::shared_ptr ch; - REQUIRE_FALSE(ctrl.subscribe(Context::background(), ch)); - ctrl.publish({}); - auto s = ch->recv(); - REQUIRE(s.has_value()); - REQUIRE(s->healthy); - ctrl.close(); -} - -TEST_CASE("Controller::send with empty payload does not crash", "[controller][REQ-CTRL-024]") { - legacy_mock::Controller ctrl(Zone::FrontLeft); - Response out; - REQUIRE_NOTHROW(ctrl.send(Context::background(), Command{1, Zone::FrontLeft}, out)); -} - -TEST_CASE("Controller::send returns ErrZoneMismatch for wrong zone", "[controller][REQ-CTRL-025]") { - legacy_mock::Controller ctrl(Zone::FrontLeft); - Response out; - REQUIRE(ctrl.send(Context::background(), Command{1, Zone::FrontRight}, out) == ErrZoneMismatch); -} - -TEST_CASE("Payload is copied before passing to handler", "[controller][REQ-CTRL-026]") { - std::vector seen_payload; - legacy_mock::Controller ctrl(Zone::FrontLeft, [&](const Command& c) -> Response { - seen_payload = c.payload; - return {c.id, c.zone, ResponseStatus::OK, {}}; - }); - std::vector payload{1, 2, 3}; - Command cmd{1, Zone::FrontLeft}; - cmd.payload = payload; - Response out; - ctrl.send(Context::background(), cmd, out); - payload[0] = 0xFF; - REQUIRE(seen_payload == std::vector{1, 2, 3}); -} - -TEST_CASE("Publish copies payload before delivering to subscribers", "[controller][REQ-CTRL-027]") { - legacy_mock::Controller ctrl(Zone::FrontLeft); - std::shared_ptr ch; - REQUIRE_FALSE(ctrl.subscribe(Context::background(), ch)); - std::vector payload{0xAA, 0xBB}; - ctrl.publish(payload); - payload[0] = 0x00; - auto s = ch->recv(); - REQUIRE(s.has_value()); - REQUIRE(s->payload == std::vector{0xAA, 0xBB}); - ctrl.close(); -} - -// ── Response fields ─────────────────────────────────────────────────────────── - -TEST_CASE("Response.command_id echoes the originating Command.id", "[response][REQ-RESP-001]") { - legacy_mock::Controller ctrl(Zone::FrontLeft); - Response out; - REQUIRE_FALSE(ctrl.send(Context::background(), Command{0xDEADBEEF, Zone::FrontLeft}, out)); - REQUIRE(out.command_id == 0xDEADBEEF); -} - -TEST_CASE("Response.zone identifies the zone that processed the command", "[response][REQ-RESP-002]") { - legacy_mock::Controller ctrl(Zone::RearRight); - Response out; - REQUIRE_FALSE(ctrl.send(Context::background(), Command{1, Zone::RearRight}, out)); - REQUIRE(out.zone == Zone::RearRight); -} diff --git a/tests/test_rcp.cpp b/tests/test_rcp.cpp index 86563c8..0e065f9 100644 --- a/tests/test_rcp.cpp +++ b/tests/test_rcp.cpp @@ -1,6 +1,3 @@ -// fusa:test REQ-PRI-001 -// fusa:test REQ-PRI-002 -// fusa:test REQ-PRI-003 // fusa:test REQ-ERR-001 // fusa:test REQ-ERR-002 // fusa:test REQ-ERR-003 @@ -11,128 +8,28 @@ // fusa:test REQ-ERR-008 // fusa:test REQ-ERR-009 // fusa:test REQ-ERR-010 -// fusa:test REQ-ERR-011 -// fusa:test REQ-CMDSTRUCT-001 -// fusa:test REQ-CMDSTRUCT-002 -// fusa:test REQ-RESP-003 -// fusa:test REQ-STAT-005 - +// fusa:test REQ-LOAN-007 +// +// Rebound (cpp-RCP-FS-04, #87): this file used to certify the retired +// Zone/Priority/CommandType/Command/Response/Status model's exact numeric +// values and zero-value defaults (Zone::FrontLeft==1, CommandType::Set==1, +// Priority ordering, etc). That model is retired (cpp-RCP-FS-01, #84); those +// cases are replaced below with conformance coverage for what rcp.hpp +// actually still defines — the rcp::Errc sentinel category, rcp::Context, +// and rcp::Loan — plus a couple of real assertions about the TC18 surface +// (rcp/avtp.hpp's ByteBusId, rcp/acf.hpp's AcfMessageInfo default value) +// that a reader coming from the old Zone/Command certification tests would +// otherwise have no equivalent for. #include +#include +#include #include -#include -#include +#include #include +#include using namespace rcp; -// ── Zone constants ──────────────────────────────────────────────────────────── - -TEST_CASE("Zone::to_string returns unique non-empty name", "[zone]") { - const std::vector zones{ - Zone::FrontLeft, Zone::FrontRight, - Zone::RearLeft, Zone::RearRight, - Zone::Central, - }; - std::set seen; - for (auto z : zones) { - auto s = to_string(z); - REQUIRE_FALSE(s.empty()); - REQUIRE(seen.find(s) == seen.end()); - seen.insert(s); - } -} - -TEST_CASE("Zone constants have correct numeric values", "[zone]") { - REQUIRE(static_cast(Zone::Unknown) == 0); - REQUIRE(static_cast(Zone::FrontLeft) == 1); - REQUIRE(static_cast(Zone::FrontRight) == 2); - REQUIRE(static_cast(Zone::RearLeft) == 3); - REQUIRE(static_cast(Zone::RearRight) == 4); - REQUIRE(static_cast(Zone::Central) == 5); -} - -TEST_CASE("All Zone constants are distinct", "[zone]") { - const std::vector zones{ - Zone::FrontLeft, Zone::FrontRight, - Zone::RearLeft, Zone::RearRight, - Zone::Central, - }; - for (size_t i = 0; i < zones.size(); ++i) { - for (size_t j = 0; j < zones.size(); ++j) { - if (i != j) REQUIRE(zones[i] != zones[j]); - } - } -} - -// ── Priority constants ──────────────────────────────────────────────────────── - -TEST_CASE("Priority constants have correct ordering", "[priority][REQ-PRI-001][REQ-PRI-002][REQ-PRI-003]") { - REQUIRE(static_cast(Priority::Normal) == 0); - REQUIRE(Priority::High > Priority::Normal); - REQUIRE(Priority::Critical > Priority::High); -} - -// ── CommandType constants ───────────────────────────────────────────────────── - -TEST_CASE("CommandType constants have correct numeric values", "[cmdtype]") { - REQUIRE(static_cast(CommandType::Noop) == 0); - REQUIRE(static_cast(CommandType::Set) == 1); - REQUIRE(static_cast(CommandType::Get) == 2); - REQUIRE(static_cast(CommandType::Reset) == 3); - REQUIRE(static_cast(CommandType::Watchdog) == 4); - REQUIRE(static_cast(CommandType::Sleep) == 5); - REQUIRE(static_cast(CommandType::Wake) == 6); -} - -TEST_CASE("All CommandType constants are distinct", "[cmdtype]") { - const std::vector cmds{ - CommandType::Noop, CommandType::Set, CommandType::Get, - CommandType::Reset, CommandType::Watchdog, - }; - for (size_t i = 0; i < cmds.size(); ++i) { - for (size_t j = 0; j < cmds.size(); ++j) { - if (i != j) REQUIRE(cmds[i] != cmds[j]); - } - } -} - -// ── ResponseStatus constants ────────────────────────────────────────────────── - -TEST_CASE("ResponseStatus::to_string returns unique non-empty name", "[status]") { - const std::vector statuses{ - ResponseStatus::OK, ResponseStatus::Error, - ResponseStatus::Timeout, ResponseStatus::Busy, - ResponseStatus::Unknown, - }; - std::set seen; - for (auto s : statuses) { - auto str = to_string(s); - REQUIRE_FALSE(str.empty()); - REQUIRE(seen.find(str) == seen.end()); - seen.insert(str); - } -} - -TEST_CASE("ResponseStatus constants have correct numeric values", "[status]") { - REQUIRE(static_cast(ResponseStatus::OK) == 0); - REQUIRE(static_cast(ResponseStatus::Error) == 1); - REQUIRE(static_cast(ResponseStatus::Timeout) == 2); - REQUIRE(static_cast(ResponseStatus::Busy) == 3); -} - -TEST_CASE("All ResponseStatus constants are distinct", "[status]") { - const std::vector statuses{ - ResponseStatus::OK, ResponseStatus::Error, - ResponseStatus::Timeout, ResponseStatus::Busy, - ResponseStatus::Unknown, - }; - for (size_t i = 0; i < statuses.size(); ++i) { - for (size_t j = 0; j < statuses.size(); ++j) { - if (i != j) REQUIRE(statuses[i] != statuses[j]); - } - } -} - // ── Sentinel error codes ────────────────────────────────────────────────────── TEST_CASE("Sentinel errors are truthy (non-null)", "[errors][REQ-ERR-001][REQ-ERR-002][REQ-ERR-003][REQ-ERR-004][REQ-ERR-005]") { @@ -141,19 +38,16 @@ TEST_CASE("Sentinel errors are truthy (non-null)", "[errors][REQ-ERR-001][REQ-ER REQUIRE(ErrAlreadyExists == make_error_code(Errc::already_exists)); REQUIRE(ErrTimeout == make_error_code(Errc::timeout)); REQUIRE(ErrBusy == make_error_code(Errc::busy)); - REQUIRE(ErrZoneMismatch == make_error_code(Errc::zone_mismatch)); REQUIRE(ErrClosed); REQUIRE(ErrNotFound); REQUIRE(ErrAlreadyExists); REQUIRE(ErrTimeout); REQUIRE(ErrBusy); - REQUIRE(ErrZoneMismatch); } TEST_CASE("All sentinel errors are mutually distinct", "[errors][REQ-ERR-006]") { const std::vector sentinels{ - ErrClosed, ErrNotFound, ErrAlreadyExists, - ErrTimeout, ErrBusy, ErrZoneMismatch, + ErrClosed, ErrNotFound, ErrAlreadyExists, ErrTimeout, ErrBusy, }; for (size_t i = 0; i < sentinels.size(); ++i) { for (size_t j = 0; j < sentinels.size(); ++j) { @@ -162,44 +56,15 @@ TEST_CASE("All sentinel errors are mutually distinct", "[errors][REQ-ERR-006]") } } -TEST_CASE("Sentinel errors belong to the rcp category", "[errors][REQ-ERR-007][REQ-ERR-008][REQ-ERR-009][REQ-ERR-010][REQ-ERR-011]") { +TEST_CASE("Sentinel errors belong to the rcp category", "[errors][REQ-ERR-007][REQ-ERR-008][REQ-ERR-009][REQ-ERR-010]") { const std::vector sentinels{ - ErrClosed, ErrNotFound, ErrAlreadyExists, - ErrTimeout, ErrBusy, ErrZoneMismatch, + ErrClosed, ErrNotFound, ErrAlreadyExists, ErrTimeout, ErrBusy, }; for (auto& ec : sentinels) { REQUIRE(&ec.category() == &rcp_category()); } } -TEST_CASE("ErrZoneMismatch is distinct from all other sentinels", "[errors][REQ-ERR-011]") { - REQUIRE(ErrZoneMismatch != ErrClosed); - REQUIRE(ErrZoneMismatch != ErrNotFound); - REQUIRE(ErrZoneMismatch != ErrAlreadyExists); - REQUIRE(ErrZoneMismatch != ErrTimeout); - REQUIRE(ErrZoneMismatch != ErrBusy); -} - -// ── Struct zero values ──────────────────────────────────────────────────────── - -TEST_CASE("Zero-value Command has safe defaults", "[command][REQ-CMDSTRUCT-001][REQ-CMDSTRUCT-002]") { - Command cmd; - REQUIRE(cmd.zone == Zone::Unknown); - REQUIRE(cmd.type == CommandType::Noop); - REQUIRE(cmd.priority == Priority::Normal); - REQUIRE(cmd.payload.empty()); -} - -TEST_CASE("Zero-value Response has StatusOK", "[response][REQ-RESP-003]") { - Response r; - REQUIRE(r.status == ResponseStatus::OK); -} - -TEST_CASE("Status with empty payload is valid", "[status][REQ-STAT-005]") { - Status s; - REQUIRE(s.payload.empty()); -} - // ── Context ─────────────────────────────────────────────────────────────────── TEST_CASE("Context::background is never done", "[context]") { @@ -219,3 +84,58 @@ TEST_CASE("Context::with_deadline before now is immediately done", "[context]") auto ctx = Context::with_deadline(past); REQUIRE(ctx.done()); } + +// ── Loan ────────────────────────────────────────────────────────────────────── + +TEST_CASE("Loan invokes its release function exactly once on destruction", + "[loan][REQ-LOAN-007]") { + int released = 0; + { + Loan l({0x01, 0x02}, [&] { ++released; }); + REQUIRE(released == 0); + REQUIRE(l.payload.size() == 2); + } + REQUIRE(released == 1); +} + +TEST_CASE("Loan::ret() releases immediately and destruction does not release again", + "[loan][REQ-LOAN-007]") { + int released = 0; + { + Loan l({}, [&] { ++released; }); + l.ret(); + REQUIRE(released == 1); + } // destructor must not double-release + REQUIRE(released == 1); +} + +TEST_CASE("A default-constructed Loan releases nothing on destruction", "[loan][REQ-LOAN-007]") { + Loan l; // no release function bound — must not crash on destruction + REQUIRE(l.payload.empty()); +} + +// ── TC18 surface: rcp/avtp.hpp's ByteBusId, rcp/acf.hpp's AcfMessageInfo ────── +// A couple of real assertions about the TC18-based model that replaced the +// retired Zone/Command surface above — see rcp/acf.hpp/rcp/avtp.hpp (not +// modified by this change) for the full conformance suites (test_acf.cpp, +// test_avtp.cpp). + +TEST_CASE("avtp::ByteBusId spans the full single-byte endpoint address range", + "[tc18][bytebusid]") { + static_assert(std::is_same::value, + "ByteBusId must be a single byte (RELAY spec §15.5)"); + REQUIRE(static_cast(std::numeric_limits::min()) == 0); + REQUIRE(static_cast(std::numeric_limits::max()) == 255); +} + +TEST_CASE("Zero-value AcfMessageInfo has safe read/request defaults", "[tc18][acf]") { + acf::AcfMessageInfo info; + REQUIRE(info.acf_msg_type == acf::kAcfMsgTypeAbb); + REQUIRE(info.byte_bus_id == 0); + REQUIRE(info.transaction_num == 0); + REQUIRE_FALSE(info.op); // false = read, the non-mutating default + REQUIRE_FALSE(info.rsp); + REQUIRE_FALSE(info.err); + REQUIRE_FALSE(info.ms); + REQUIRE_FALSE(info.evt_ack); +} diff --git a/tests/test_redundancy.cpp b/tests/test_redundancy.cpp index a000090..d7cffc0 100644 --- a/tests/test_redundancy.cpp +++ b/tests/test_redundancy.cpp @@ -6,125 +6,147 @@ // fusa:test REQ-RED-006 // fusa:test REQ-RED-007 // fusa:test REQ-RED-008 +// +// Rebound (cpp-RCP-FS-03/#86): redundancy::RedundantRequestFn now holds a +// pair of rcp::RequestFn (rcp/adapt.hpp) instead of decorating the retired +// rcp::Controller — see rcp/redundancy.hpp's own header comment. The old +// REQ-RED-006/REQ-RED-008 cases asserted Controller::close()/zone() +// passthrough, neither of which has an analog on a plain RequestFn (a +// RequestFn owns no closeable resource of its own, the same reasoning +// rcp/adapt.hpp's own close() note documents); they are replaced below with +// cases covering RedundantRequestFn's own usability as an rcp::RequestFn. #include -#include "rcp/legacy_mock.hpp" +#include "rcp/mock.hpp" #include "rcp/redundancy.hpp" +#include #include using namespace rcp; +using namespace std::chrono_literals; namespace { -// FailController always fails send() with a fixed error code, regardless of the -// context. Used to deterministically trigger redundant failover. -class FailController final : public rcp::Controller { -public: - FailController(Zone z, std::error_code ec) : zone_(z), ec_(ec) {} - Zone zone() const noexcept override { return zone_; } - std::error_code send(const Context&, const Command&, Response&) override { return ec_; } - std::error_code subscribe(const Context&, std::shared_ptr&) override { - return ec_; - } - std::error_code close() override { return {}; } -private: - Zone zone_; - std::error_code ec_; -}; + +RequestFn make_mock_fn(std::shared_ptr srv) { + return [srv](const Context&, const acf::AcfMessageInfo& req, + const std::vector& payload, + acf::AcfMessageInfo& out, std::vector& out_payload) { + return srv->dispatch(0, req, payload, out, out_payload); + }; +} + +std::shared_ptr make_server() { + auto srv = std::make_shared(); + srv->advance_to_rcp_configured(); + return srv; +} + +// fail_fn always fails with a fixed error code, regardless of the request. +// Used to deterministically trigger redundant failover. +RequestFn fail_fn(std::error_code ec) { + return [ec](const Context&, const acf::AcfMessageInfo&, const std::vector&, + acf::AcfMessageInfo&, std::vector&) { return ec; }; +} + +acf::AcfMessageInfo gpio_read_request() { + return acf::make_standard_request(mock::kGpioByteBusId, 0, false, 0); +} + } // namespace TEST_CASE("redundancy: primary succeeds, standby unused", "[redundancy]") { - auto primary = std::make_shared(Zone::FrontLeft); - auto standby = std::make_shared(Zone::FrontLeft); - - auto ctrl = redundancy::new_controller(primary, standby); - REQUIRE(ctrl->is_primary_active()); - - Command cmd; - cmd.zone = Zone::FrontLeft; - cmd.type = CommandType::Get; - Response resp; - REQUIRE_FALSE(ctrl->send(Context{}, cmd, resp)); - REQUIRE(ctrl->is_primary_active()); + redundancy::RedundantRequestFn rr(make_mock_fn(make_server()), make_mock_fn(make_server())); + REQUIRE(rr.is_primary_active()); + + auto req = gpio_read_request(); + acf::AcfMessageInfo out; + std::vector out_payload; + REQUIRE_FALSE(rr.send(Context{}, req, {}, out, out_payload)); + REQUIRE(rr.is_primary_active()); } TEST_CASE("redundancy: manual promote switches to standby", "[redundancy]") { - auto primary = std::make_shared(Zone::FrontLeft); - auto standby = std::make_shared(Zone::FrontLeft); - auto ctrl = redundancy::new_controller(primary, standby); - - ctrl->promote(); - REQUIRE_FALSE(ctrl->is_primary_active()); - - Command cmd; - cmd.zone = Zone::FrontLeft; - cmd.type = CommandType::Get; - Response resp; - REQUIRE_FALSE(ctrl->send(Context{}, cmd, resp)); -} + redundancy::RedundantRequestFn rr(make_mock_fn(make_server()), make_mock_fn(make_server())); -TEST_CASE("redundancy: double promote returns to primary", "[redundancy]") { - auto primary = std::make_shared(Zone::FrontLeft); - auto standby = std::make_shared(Zone::FrontLeft); - auto ctrl = redundancy::new_controller(primary, standby); + rr.promote(); + REQUIRE_FALSE(rr.is_primary_active()); - ctrl->promote(); - ctrl->promote(); - REQUIRE(ctrl->is_primary_active()); + auto req = gpio_read_request(); + acf::AcfMessageInfo out; + std::vector out_payload; + REQUIRE_FALSE(rr.send(Context{}, req, {}, out, out_payload)); } -TEST_CASE("redundancy: zone() returns zone of active controller", "[redundancy][REQ-RED-008]") { - auto primary = std::make_shared(Zone::RearRight); - auto standby = std::make_shared(Zone::RearRight); - auto ctrl = redundancy::new_controller(primary, standby); - REQUIRE(ctrl->zone() == Zone::RearRight); +TEST_CASE("redundancy: double promote returns to primary", "[redundancy]") { + redundancy::RedundantRequestFn rr(make_mock_fn(make_server()), make_mock_fn(make_server())); + + rr.promote(); + rr.promote(); + REQUIRE(rr.is_primary_active()); } TEST_CASE("redundancy: auto-promotes standby on ErrClosed", "[redundancy][REQ-RED-002]") { - auto primary = std::make_shared(Zone::FrontLeft, ErrClosed); - auto standby = std::make_shared(Zone::FrontLeft); - auto ctrl = redundancy::new_controller(primary, standby); - REQUIRE(ctrl->is_primary_active()); - - Command cmd; cmd.zone = Zone::FrontLeft; cmd.type = CommandType::Get; - Response resp; - REQUIRE_FALSE(ctrl->send(Context{}, cmd, resp)); // succeeds via standby - REQUIRE_FALSE(ctrl->is_primary_active()); // standby is now active + redundancy::RedundantRequestFn rr(fail_fn(ErrClosed), make_mock_fn(make_server())); + REQUIRE(rr.is_primary_active()); + + auto req = gpio_read_request(); + acf::AcfMessageInfo out; + std::vector out_payload; + REQUIRE_FALSE(rr.send(Context{}, req, {}, out, out_payload)); // succeeds via standby + REQUIRE_FALSE(rr.is_primary_active()); // standby is now active } TEST_CASE("redundancy: auto-promotes standby on ErrTimeout", "[redundancy][REQ-RED-003]") { - auto primary = std::make_shared(Zone::FrontLeft, ErrTimeout); - auto standby = std::make_shared(Zone::FrontLeft); - auto ctrl = redundancy::new_controller(primary, standby); - - Command cmd; cmd.zone = Zone::FrontLeft; cmd.type = CommandType::Get; - Response resp; - REQUIRE_FALSE(ctrl->send(Context{}, cmd, resp)); - REQUIRE_FALSE(ctrl->is_primary_active()); + redundancy::RedundantRequestFn rr(fail_fn(ErrTimeout), make_mock_fn(make_server())); + + auto req = gpio_read_request(); + acf::AcfMessageInfo out; + std::vector out_payload; + REQUIRE_FALSE(rr.send(Context{}, req, {}, out, out_payload)); + REQUIRE_FALSE(rr.is_primary_active()); } TEST_CASE("redundancy: auto_promote=false disables automatic failover", "[redundancy][REQ-RED-007]") { - auto primary = std::make_shared(Zone::FrontLeft, ErrClosed); - auto standby = std::make_shared(Zone::FrontLeft); redundancy::Config cfg; cfg.auto_promote = false; - auto ctrl = redundancy::new_controller(primary, standby, cfg); + redundancy::RedundantRequestFn rr(fail_fn(ErrClosed), make_mock_fn(make_server()), cfg); - Command cmd; cmd.zone = Zone::FrontLeft; cmd.type = CommandType::Get; - Response resp; - REQUIRE(ctrl->send(Context{}, cmd, resp) == ErrClosed); // error surfaced, no failover - REQUIRE(ctrl->is_primary_active()); // still on primary + auto req = gpio_read_request(); + acf::AcfMessageInfo out; + std::vector out_payload; + REQUIRE(rr.send(Context{}, req, {}, out, out_payload) == ErrClosed); // error surfaced, no failover + REQUIRE(rr.is_primary_active()); // still on primary } -TEST_CASE("redundancy: close() closes both controllers", "[redundancy][REQ-RED-006]") { - auto primary = std::make_shared(Zone::Central); - auto standby = std::make_shared(Zone::Central); - auto ctrl = redundancy::new_controller(primary, standby); - - REQUIRE_FALSE(ctrl->close()); +TEST_CASE("redundancy: still on standby after a second consecutive primary failure", + "[redundancy][REQ-RED-006]") { + redundancy::RedundantRequestFn rr(fail_fn(ErrClosed), make_mock_fn(make_server())); + + auto req = gpio_read_request(); + acf::AcfMessageInfo out; + std::vector out_payload; + REQUIRE_FALSE(rr.send(Context{}, req, {}, out, out_payload)); // promotes to standby + REQUIRE_FALSE(rr.is_primary_active()); + REQUIRE_FALSE(rr.send(Context{}, req, {}, out, out_payload)); // standby still serves + REQUIRE_FALSE(rr.is_primary_active()); +} - Command cmd; cmd.zone = Zone::Central; Response resp; - REQUIRE(primary->send(Context{}, cmd, resp) == ErrClosed); - REQUIRE(standby->send(Context{}, cmd, resp) == ErrClosed); +TEST_CASE("redundancy: RedundantRequestFn is itself usable as an rcp::RequestFn via Adapt()", + "[redundancy][REQ-RED-008]") { + redundancy::RedundantRequestFn rr(make_mock_fn(make_server()), make_mock_fn(make_server())); + // RedundantRequestFn holds a std::mutex, so it is not copyable; + // Adapt()'s RequestFn parameter would otherwise try to copy it into a + // std::function. std::ref keeps this call-by-reference. + auto caller = Adapt(std::ref(rr)); + + relay::Message req; + req.protocol = relay::Protocol::RCP; + req.id = endpoint_id_to_relay_id(mock::kGpioByteBusId); + req.meta["rcp.op"] = "read"; + + auto [resp, ec] = caller->call(relay::Context::with_timeout(1s), req); + REQUIRE_FALSE(ec); + REQUIRE(resp.id == req.id); } diff --git a/tests/test_relay.cpp b/tests/test_relay.cpp index a75fb0a..58e2fd2 100644 --- a/tests/test_relay.cpp +++ b/tests/test_relay.cpp @@ -9,11 +9,16 @@ // Verifies that cpp-RCP satisfies the mandatory RELAY-conformance requirements // as enumerated in Appendix A of the RELAY spec. // -// ROADMAP.md milestone 60 (v2.16.0): the §10.3/§15.7.5 sections below -// (Adapt(), endpoint-id round trips) are entirely rewritten against -// rcp/adapt.hpp's v2.16.0 RequestFn-based redesign — see that header's own -// comment. The general §3/§5/§14/§18.2/§19.4 relay:: conformance sections -// above them are unaffected by this milestone and unchanged. +// cpp-RCP-05/#74: kRelaySpecVersion bumped 1.11 -> 2.0 (the breaking RCP +// revision, RELAY spec §15.5). cpp-RCP-FS-01/#84: the retired +// Zone/Command/Status model — including rcp::StatusChannel, which aliased +// relay::Channel — is gone, so the §18.2 StatusChannel case below is +// removed along with it; rcp::Context's §18.2 alias is unaffected and still +// covered. cpp-RCP-FS-05/#88: endpoint_id_to_relay_id()/ +// relay_id_to_endpoint_id()/response_to_message()/message_to_request() are +// rebound to the plain decimal ByteBusID id RELAY spec §15.7.5 actually +// specifies (no stream_key folded in) — see rcp/adapt.hpp's own header +// comment. #include #include @@ -28,8 +33,8 @@ using namespace std::chrono_literals; // ── §19.4: SpecVersion constant ─────────────────────────────────────────────── -TEST_CASE("relay: kRelaySpecVersion is 1.11", "[relay][conformance]") { - REQUIRE(relay::kRelaySpecVersion == "1.11"); +TEST_CASE("relay: kRelaySpecVersion is 2.0", "[relay][conformance]") { + REQUIRE(relay::kRelaySpecVersion == "2.0"); } // ── §3: Protocol enum ───────────────────────────────────────────────────────── @@ -78,15 +83,13 @@ TEST_CASE("relay: rcp::ErrBusy maps to relay::ErrTimeout", "[relay][conformance] } TEST_CASE("relay: rcp::ErrNotFound maps to relay::ErrNotConnected", "[relay][conformance]") { + // RELAY spec §5.4 (v2.0): "RCP | ErrNotFound | ErrNotConnected | + // Message.ID does not parse to a valid ByteBusID (0-255)". REQUIRE(rcp::ErrNotFound == relay::ErrNotConnected); } -TEST_CASE("relay: rcp::ErrZoneMismatch maps to relay::ErrNotConnected", "[relay][conformance]") { - REQUIRE(rcp::ErrZoneMismatch == relay::ErrNotConnected); -} - TEST_CASE("relay: rcp::ErrAlreadyExists is standalone (no relay sentinel)", "[relay][conformance]") { - // Per RELAY spec §5.4 update: ErrAlreadyExists is not a relay sentinel. + // Per RELAY spec §5.4: ErrAlreadyExists is not a relay sentinel. REQUIRE(rcp::ErrAlreadyExists != relay::ErrClosed); REQUIRE(rcp::ErrAlreadyExists != relay::ErrNotConnected); REQUIRE(rcp::ErrAlreadyExists != relay::ErrTimeout); @@ -102,22 +105,6 @@ TEST_CASE("relay: rcp::Context is relay::Context", "[relay][conformance]") { REQUIRE_FALSE(ctx.done()); } -// ── §18.2: StatusChannel aliased from relay::Channel ───────────────── - -TEST_CASE("relay: rcp::StatusChannel is relay::Channel", "[relay][conformance]") { - static_assert( - std::is_same>::value, - "rcp::StatusChannel must be relay::Channel (§18.2)"); - auto ch = std::make_shared(4); - rcp::Status s; - s.zone = rcp::Zone::FrontLeft; - s.healthy = true; - REQUIRE(ch->push(s)); - auto got = ch->try_recv(); - REQUIRE(got.has_value()); - REQUIRE(got->zone == rcp::Zone::FrontLeft); -} - // ── §14: BackPressurePolicy default is drop_newest ─────────────────────────── TEST_CASE("relay: SubscriberOptions default channel depth is 64", "[relay][conformance]") { @@ -184,7 +171,7 @@ TEST_CASE("relay: Adapt() call() sends a request and returns the response", "[re relay::Message req; req.protocol = relay::Protocol::RCP; - req.id = rcp::endpoint_id_to_relay_id(0, rcp::mock::kGpioByteBusId); + req.id = rcp::endpoint_id_to_relay_id(rcp::mock::kGpioByteBusId); req.meta["rcp.op"] = "read"; auto ctx = relay::Context::with_timeout(1s); @@ -211,7 +198,7 @@ TEST_CASE("relay: Adapt() send() succeeds", "[relay][adapt]") { auto caller = rcp::Adapt(mock_request_fn(make_configured_mock_server())); relay::Message msg; - msg.id = rcp::endpoint_id_to_relay_id(0, rcp::mock::kSpiByteBusId); + msg.id = rcp::endpoint_id_to_relay_id(rcp::mock::kSpiByteBusId); msg.meta["rcp.op"] = "write"; auto ctx = relay::Context::with_timeout(1s); @@ -236,25 +223,44 @@ TEST_CASE("relay: Adapt() close() idempotent", "[relay][adapt]") { } // ── §15.7.5: endpoint_id_to_relay_id / relay_id_to_endpoint_id round-trips ─── +// cpp-RCP-FS-05/#88: Message.ID for RCP is just the decimal ByteBusID string +// (0-255) — the StreamID is not part of it (§8.5: one StreamID per Caller +// instance). -TEST_CASE("relay: server+endpoint id round-trips through relay ID", "[relay][conform]") { - for (auto pair : {std::pair{uint64_t{0}, rcp::avtp::ByteBusId{1}}, - std::pair{uint64_t{0x1122334455667788ULL}, rcp::avtp::ByteBusId{2}}, - std::pair{uint64_t{42}, rcp::avtp::ByteBusId{255}}}) { - auto id = rcp::endpoint_id_to_relay_id(pair.first, pair.second); - uint64_t stream_key = 0; - rcp::avtp::ByteBusId byte_bus_id = 0; - REQUIRE(rcp::relay_id_to_endpoint_id(id, stream_key, byte_bus_id)); - REQUIRE(stream_key == pair.first); - REQUIRE(byte_bus_id == pair.second); +TEST_CASE("relay: byte_bus_id round-trips through relay ID as a plain decimal string", + "[relay][conform]") { + for (rcp::avtp::ByteBusId bus_id : {rcp::avtp::ByteBusId{0}, rcp::avtp::ByteBusId{9}, + rcp::avtp::ByteBusId{255}}) { + auto id = rcp::endpoint_id_to_relay_id(bus_id); + rcp::avtp::ByteBusId decoded = 0; + REQUIRE(rcp::relay_id_to_endpoint_id(id, decoded)); + REQUIRE(decoded == bus_id); } } +TEST_CASE("relay: endpoint_id_to_relay_id(9) is the plain decimal string \"9\" (spec example)", + "[relay][conform]") { + // RELAY spec §15.7.5's own worked example: `ID` | `ByteBusID` | Decimal + // string, e.g. "9". + REQUIRE(rcp::endpoint_id_to_relay_id(9) == "9"); +} + +TEST_CASE("relay: relay_id_to_endpoint_id rejects the pre-#88 \":\" form", + "[relay][conform]") { + rcp::avtp::ByteBusId byte_bus_id = 0; + REQUIRE_FALSE(rcp::relay_id_to_endpoint_id("0000000000000000:1", byte_bus_id)); +} + TEST_CASE("relay: relay_id_to_endpoint_id rejects the pre-v2.16.0 zone-name form", "[relay][conform]") { - uint64_t stream_key = 0; rcp::avtp::ByteBusId byte_bus_id = 0; - REQUIRE_FALSE(rcp::relay_id_to_endpoint_id("FrontLeft", stream_key, byte_bus_id)); + REQUIRE_FALSE(rcp::relay_id_to_endpoint_id("FrontLeft", byte_bus_id)); +} + +TEST_CASE("relay: relay_id_to_endpoint_id rejects a byte_bus_id above 255", + "[relay][conform]") { + rcp::avtp::ByteBusId byte_bus_id = 0; + REQUIRE_FALSE(rcp::relay_id_to_endpoint_id("256", byte_bus_id)); } // ── §15.7.5: response_to_message / message_to_request mapping ──────────────── @@ -273,10 +279,10 @@ TEST_CASE("relay: response_to_message carries the endpoint id, payload, and resp resp.rsp = true; resp.op = false; // ReadResponse - auto msg = rcp::response_to_message(0x2A, resp, {0x01, 0x02}); + auto msg = rcp::response_to_message(resp, {0x01, 0x02}); REQUIRE(static_cast(msg.protocol) == 5); // relay.RCP - REQUIRE(msg.id == "000000000000002a:7"); + REQUIRE(msg.id == "7"); REQUIRE(msg.payload == std::vector{0x01, 0x02}); REQUIRE(msg.meta.at("rcp.err") == "false"); REQUIRE(msg.meta.at("rcp.response_kind") == @@ -285,16 +291,14 @@ TEST_CASE("relay: response_to_message carries the endpoint id, payload, and resp TEST_CASE("relay: message_to_request decodes op/evt_op from meta", "[relay][conformance]") { relay::Message msg; - msg.id = rcp::endpoint_id_to_relay_id(9, 3); + msg.id = rcp::endpoint_id_to_relay_id(3); msg.meta["rcp.op"] = "write"; msg.meta["rcp.evt_op"] = "5"; msg.payload = {0xAA}; - uint64_t stream_key = 0; rcp::acf::AcfMessageInfo req; std::vector payload; - REQUIRE(rcp::message_to_request(msg, stream_key, req, payload)); - REQUIRE(stream_key == 9); + REQUIRE(rcp::message_to_request(msg, req, payload)); REQUIRE(req.byte_bus_id == 3); REQUIRE(req.op == true); REQUIRE(req.evt_op == 5); diff --git a/tooling/zone_manifest_schema.json b/tooling/zone_manifest_schema.json deleted file mode 100644 index a17c383..0000000 --- a/tooling/zone_manifest_schema.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://rcp.example.com/schemas/zone-manifest/v1", - "title": "cpp-RCP Zone Manifest", - "description": "Schema for the zone registry manifest loaded by rcp::config::load()", - "type": "object", - "required": ["zones"], - "properties": { - "version": { - "type": "string", - "description": "Manifest schema version (semver)", - "default": "1.0.0" - }, - "zones": { - "type": "array", - "description": "List of zone definitions", - "minItems": 1, - "items": { - "$ref": "#/definitions/ZoneEntry" - } - } - }, - "additionalProperties": false, - "definitions": { - "ZoneEntry": { - "type": "object", - "required": ["zone"], - "properties": { - "zone": { - "type": "string", - "enum": ["FrontLeft", "FrontRight", "RearLeft", "RearRight", "Central"], - "description": "Zone identifier matching rcp::Zone enum" - }, - "priority": { - "type": "string", - "enum": ["Low", "Normal", "High", "Critical"], - "default": "Normal", - "description": "Default command priority for this zone" - }, - "transport": { - "type": "string", - "enum": ["mock", "udp", "shmem", "tsn", "grpc", "rest", "someip", "can", "dds", "mqtt", "lin", "uds", "doip"], - "default": "mock", - "description": "Transport adapter to instantiate for this zone" - }, - "address": { - "type": "string", - "description": "Transport-specific address (IP:port, socket path, etc.)" - }, - "extra": { - "type": "object", - "description": "Transport-specific extra configuration", - "additionalProperties": true - } - }, - "additionalProperties": false - } - } -}