From 8701f38edf6544395a1b9deeecdf1ff14c14d031 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marius=20B=C3=B6rschig?= Date: Tue, 1 Sep 2026 18:41:31 +0200 Subject: [PATCH 1/5] dashboard: get rid of oatpp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Marius Börschig --- .github/workflows/build-macos.yml | 1 - .github/workflows/build-mingw64.yml | 2 +- .github/workflows/linux-asan.yml | 3 +- .github/workflows/linux-tsan.yml | 3 +- .github/workflows/linux-ubsan.yml | 3 +- .github/workflows/sil-kit-ci.yml | 2 +- .gitmodules | 3 - CMakePresets.json | 4 +- SilKit/ci/Jenkinsfile | 6 +- SilKit/source/config/BasicYamlWriter.hpp | 130 +++++ SilKit/source/config/CMakeLists.txt | 1 + SilKit/source/config/YamlWriter.hpp | 110 +--- SilKit/source/core/internal/internal_fwd.hpp | 2 + .../internal/traits/SilKitLoggingTraits.hpp | 2 + SilKit/source/dashboard/CMakeLists.txt | 90 ++- SilKit/source/dashboard/DashboardInstance.cpp | 18 +- SilKit/source/dashboard/DashboardInstance.hpp | 6 +- SilKit/source/dashboard/IRestClient.hpp | 7 + SilKit/source/dashboard/OatppHeaders.cpp | 5 - SilKit/source/dashboard/OatppHeaders.hpp | 50 -- .../dashboard/client/DashboardComponents.hpp | 47 -- .../dashboard/client/DashboardPaths.hpp | 33 ++ .../dashboard/client/DashboardRetryPolicy.cpp | 50 -- .../dashboard/client/DashboardRetryPolicy.hpp | 31 - .../client/DashboardSystemApiClient.hpp | 39 -- .../client/DashboardSystemServiceClient.cpp | 69 +-- .../client/DashboardSystemServiceClient.hpp | 27 +- .../client/IDashboardSystemServiceClient.hpp | 21 +- .../client/Mocks/MockBodyDecoder.hpp | 27 - .../Mocks/MockDashboardSystemApiClient.hpp | 43 -- .../MockDashboardSystemServiceClient.hpp | 15 +- .../client/Mocks/MockInputStream.hpp | 18 - .../client/Mocks/MockObjectMapper.hpp | 27 - .../Test_DashboardSystemServiceClient.cpp | 253 ++++---- SilKit/source/dashboard/dto/BulkUpdateDto.hpp | 115 ++-- SilKit/source/dashboard/dto/DataSpecDto.hpp | 31 +- .../source/dashboard/dto/MatchingLabelDto.hpp | 47 +- SilKit/source/dashboard/dto/MetricsDto.hpp | 62 +- .../dashboard/dto/ParticipantStatusDto.hpp | 97 ++-- SilKit/source/dashboard/dto/RpcSpecDto.hpp | 31 +- SilKit/source/dashboard/dto/ServiceDto.hpp | 34 -- .../dto/SimulationConfigurationDto.hpp | 17 +- .../dto/SimulationCreationRequestDto.hpp | 24 +- .../dto/SimulationCreationResponseDto.hpp | 28 - .../source/dashboard/dto/SystemStatusDto.hpp | 82 ++- .../source/dashboard/http/AsioHttpClient.cpp | 540 ++++++++++++++++++ .../source/dashboard/http/AsioHttpClient.hpp | 62 ++ .../source/dashboard/http/FakeHttpServer.hpp | 178 ++++++ .../dashboard/http/HttpResponseParser.cpp | 274 +++++++++ .../dashboard/http/HttpResponseParser.hpp | 53 ++ .../source/dashboard/http/HttpRetryPolicy.hpp | 30 + SilKit/source/dashboard/http/IHttpClient.hpp | 51 ++ .../dashboard/http/Mocks/MockHttpClient.hpp | 21 + .../dashboard/http/RetryingHttpClient.cpp | 74 +++ .../dashboard/http/RetryingHttpClient.hpp | 47 ++ .../dashboard/http/Test_AsioHttpClient.cpp | 225 ++++++++ .../http/Test_HttpResponseParser.cpp | 186 ++++++ .../http/Test_RetryingHttpClient.cpp | 185 ++++++ .../source/dashboard/json/DashboardJson.cpp | 50 ++ .../source/dashboard/json/DashboardJson.hpp | 39 ++ .../dashboard/json/DashboardJsonWriter.cpp | 208 +++++++ .../dashboard/json/DashboardJsonWriter.hpp | 128 +++++ .../json/Test_DashboardJsonWriter.cpp | 407 +++++++++++++ ...OatppMapper.cpp => DashboardDtoMapper.cpp} | 319 +++++------ .../dashboard/service/DashboardDtoMapper.hpp | 41 ++ .../dashboard/service/DashboardRestClient.cpp | 110 ++-- .../dashboard/service/DashboardRestClient.hpp | 31 +- .../dashboard/service/IDashboardDtoMapper.hpp | 39 ++ .../service/ISilKitToOatppMapper.hpp | 53 -- .../service/Mocks/MockDashboardDtoMapper.hpp | 25 + .../service/Mocks/MockSilKitToOatppMapper.hpp | 38 -- .../dashboard/service/SilKitToOatppMapper.hpp | 43 -- ...Mapper.cpp => Test_DashboardDtoMapper.cpp} | 240 +++----- .../service/Test_DashboardRestClient.cpp | 136 +++-- .../service/Test_DashboardShutdown.cpp | 144 +++++ ThirdParty/CMakeLists.txt | 37 -- ThirdParty/LICENSES.rst | 208 ------- ThirdParty/oatpp | 1 - docs/changelog/versions/latest.md | 12 + docs/licenses/license.rst | 208 ------- 80 files changed, 4147 insertions(+), 2012 deletions(-) create mode 100644 SilKit/source/config/BasicYamlWriter.hpp delete mode 100644 SilKit/source/dashboard/OatppHeaders.cpp delete mode 100644 SilKit/source/dashboard/OatppHeaders.hpp delete mode 100644 SilKit/source/dashboard/client/DashboardComponents.hpp create mode 100644 SilKit/source/dashboard/client/DashboardPaths.hpp delete mode 100644 SilKit/source/dashboard/client/DashboardRetryPolicy.cpp delete mode 100644 SilKit/source/dashboard/client/DashboardRetryPolicy.hpp delete mode 100644 SilKit/source/dashboard/client/DashboardSystemApiClient.hpp delete mode 100644 SilKit/source/dashboard/client/Mocks/MockBodyDecoder.hpp delete mode 100644 SilKit/source/dashboard/client/Mocks/MockDashboardSystemApiClient.hpp delete mode 100644 SilKit/source/dashboard/client/Mocks/MockInputStream.hpp delete mode 100644 SilKit/source/dashboard/client/Mocks/MockObjectMapper.hpp delete mode 100644 SilKit/source/dashboard/dto/ServiceDto.hpp delete mode 100644 SilKit/source/dashboard/dto/SimulationCreationResponseDto.hpp create mode 100644 SilKit/source/dashboard/http/AsioHttpClient.cpp create mode 100644 SilKit/source/dashboard/http/AsioHttpClient.hpp create mode 100644 SilKit/source/dashboard/http/FakeHttpServer.hpp create mode 100644 SilKit/source/dashboard/http/HttpResponseParser.cpp create mode 100644 SilKit/source/dashboard/http/HttpResponseParser.hpp create mode 100644 SilKit/source/dashboard/http/HttpRetryPolicy.hpp create mode 100644 SilKit/source/dashboard/http/IHttpClient.hpp create mode 100644 SilKit/source/dashboard/http/Mocks/MockHttpClient.hpp create mode 100644 SilKit/source/dashboard/http/RetryingHttpClient.cpp create mode 100644 SilKit/source/dashboard/http/RetryingHttpClient.hpp create mode 100644 SilKit/source/dashboard/http/Test_AsioHttpClient.cpp create mode 100644 SilKit/source/dashboard/http/Test_HttpResponseParser.cpp create mode 100644 SilKit/source/dashboard/http/Test_RetryingHttpClient.cpp create mode 100644 SilKit/source/dashboard/json/DashboardJson.cpp create mode 100644 SilKit/source/dashboard/json/DashboardJson.hpp create mode 100644 SilKit/source/dashboard/json/DashboardJsonWriter.cpp create mode 100644 SilKit/source/dashboard/json/DashboardJsonWriter.hpp create mode 100644 SilKit/source/dashboard/json/Test_DashboardJsonWriter.cpp rename SilKit/source/dashboard/service/{SilKitToOatppMapper.cpp => DashboardDtoMapper.cpp} (51%) create mode 100644 SilKit/source/dashboard/service/DashboardDtoMapper.hpp create mode 100644 SilKit/source/dashboard/service/IDashboardDtoMapper.hpp delete mode 100644 SilKit/source/dashboard/service/ISilKitToOatppMapper.hpp create mode 100644 SilKit/source/dashboard/service/Mocks/MockDashboardDtoMapper.hpp delete mode 100644 SilKit/source/dashboard/service/Mocks/MockSilKitToOatppMapper.hpp delete mode 100644 SilKit/source/dashboard/service/SilKitToOatppMapper.hpp rename SilKit/source/dashboard/service/{Test_DashboardSilKitToOatppMapper.cpp => Test_DashboardDtoMapper.cpp} (61%) create mode 100644 SilKit/source/dashboard/service/Test_DashboardShutdown.cpp delete mode 160000 ThirdParty/oatpp diff --git a/.github/workflows/build-macos.yml b/.github/workflows/build-macos.yml index 08ce8e5b8..0e4ca5a3b 100644 --- a/.github/workflows/build-macos.yml +++ b/.github/workflows/build-macos.yml @@ -26,5 +26,4 @@ jobs: - uses: ./.github/actions/build-cmake-preset with: preset-name: release - cmake-args: "-DCMAKE_POLICY_VERSION_MINIMUM=3.5" artifact-label: ${{ github.job }} diff --git a/.github/workflows/build-mingw64.yml b/.github/workflows/build-mingw64.yml index 67a279177..a09a7babf 100644 --- a/.github/workflows/build-mingw64.yml +++ b/.github/workflows/build-mingw64.yml @@ -40,6 +40,6 @@ jobs: with: preset-name: release artifact-label: ${{ github.job }}-${{ matrix.builds.arch }} - cmake-args: -D SILKIT_BUILD_DOCS=OFF -D SILKIT_BUILD_DASHBOARD=OFF + cmake-args: -D SILKIT_BUILD_DOCS=OFF extra-path: "${{ matrix.builds.bin }}:" shell: C:\shells\msys2bash.cmd {0} diff --git a/.github/workflows/linux-asan.yml b/.github/workflows/linux-asan.yml index 44fde35cd..73e2a51e5 100644 --- a/.github/workflows/linux-asan.yml +++ b/.github/workflows/linux-asan.yml @@ -22,8 +22,7 @@ jobs: - uses: ./.github/actions/build-cmake-preset with: preset-name: relwithdebinfo - cmake-args: "-D SILKIT_BUILD_DASHBOARD=OFF \ - -DCMAKE_C_COMPILER=clang-18 \ + cmake-args: "-DCMAKE_C_COMPILER=clang-18 \ -DCMAKE_CXX_COMPILER=clang++-18 \ -DCMAKE_CXX_FLAGS='-fsanitize=address -fno-omit-frame-pointer' \ -DCMAKE_CXX_FLAGS_RELWITHDEBINFO='-Og -g3'" diff --git a/.github/workflows/linux-tsan.yml b/.github/workflows/linux-tsan.yml index 1de439e5c..672b13ef9 100644 --- a/.github/workflows/linux-tsan.yml +++ b/.github/workflows/linux-tsan.yml @@ -24,8 +24,7 @@ jobs: - uses: ./.github/actions/build-cmake-preset with: preset-name: relwithdebinfo - cmake-args: "-D SILKIT_BUILD_DASHBOARD=OFF \ - -DCMAKE_C_COMPILER=clang-18 \ + cmake-args: "-DCMAKE_C_COMPILER=clang-18 \ -DCMAKE_CXX_COMPILER=clang++-18 \ -DCMAKE_CXX_FLAGS='-fsanitize=thread -fno-omit-frame-pointer' \ -DCMAKE_CXX_FLAGS_RELWITHDEBINFO='-Og -g3'" diff --git a/.github/workflows/linux-ubsan.yml b/.github/workflows/linux-ubsan.yml index 314ffb337..238c0a3e1 100644 --- a/.github/workflows/linux-ubsan.yml +++ b/.github/workflows/linux-ubsan.yml @@ -23,8 +23,7 @@ jobs: - uses: ./.github/actions/build-cmake-preset with: preset-name: relwithdebinfo - cmake-args: "-D SILKIT_BUILD_DASHBOARD=OFF \ - -DCMAKE_C_COMPILER=clang-18 \ + cmake-args: "-DCMAKE_C_COMPILER=clang-18 \ -DCMAKE_CXX_COMPILER=clang++-18 \ -DCMAKE_CXX_FLAGS='-fsanitize=undefined -fno-omit-frame-pointer' \ -DCMAKE_CXX_FLAGS_RELWITHDEBINFO='-Og -g3'" diff --git a/.github/workflows/sil-kit-ci.yml b/.github/workflows/sil-kit-ci.yml index 0ca1f7b7b..fd97c81da 100644 --- a/.github/workflows/sil-kit-ci.yml +++ b/.github/workflows/sil-kit-ci.yml @@ -84,7 +84,7 @@ jobs: run: | mkdir _o export CC=clang && export CXX=clang++ - cmake -S . -B build_tidy -GNinja -DSILKIT_BUILD_DASHBOARD=OFF -DSILKIT_BUILD_DEMOS=ON -DCMAKE_EXPORT_COMPILE_COMMANDS=ON -DCMAKE_BUILD_TYPE=RelWithDebInfo + cmake -S . -B build_tidy -GNinja -DSILKIT_BUILD_DEMOS=ON -DCMAKE_EXPORT_COMPILE_COMMANDS=ON -DCMAKE_BUILD_TYPE=RelWithDebInfo python3 ./SilKit/ci/silkit_clang_tidy.py build_tidy/ _o/ shell: bash diff --git a/.gitmodules b/.gitmodules index 4ef270bd5..6b71280e7 100644 --- a/.gitmodules +++ b/.gitmodules @@ -10,6 +10,3 @@ [submodule "ThirdParty/googletest"] path = ThirdParty/googletest url = https://github.com/google/googletest -[submodule "ThirdParty/oatpp"] - path = ThirdParty/oatpp - url = https://github.com/oatpp/oatpp.git diff --git a/CMakePresets.json b/CMakePresets.json index 8fecb2b8f..e66f7f85c 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -25,8 +25,7 @@ "SILKIT_BUILD_UTILITIES": "ON", "SILKIT_INSTALL_SOURCE": "ON", "SILKIT_PACKAGE_SYMBOLS": "ON", - "SILKIT_WARNINGS_AS_ERRORS": "ON", - "CMAKE_POLICY_VERSION_MINIMUM": "3.5" + "SILKIT_WARNINGS_AS_ERRORS": "ON" }, "architecture": { "value": "x64", @@ -44,7 +43,6 @@ "SILKIT_WARNINGS_AS_ERRORS": "ON", "SILKIT_PACKAGE_SYMBOLS": "OFF", "SILKIT_INSTALL_SOURCE": "OFF", - "CMAKE_POLICY_VERSION_MINIMUM": "3.5", "CMAKE_EXPORT_COMPILE_COMMANDS": "ON" }, diff --git a/SilKit/ci/Jenkinsfile b/SilKit/ci/Jenkinsfile index 83aa024fd..68e5bd4eb 100755 --- a/SilKit/ci/Jenkinsfile +++ b/SilKit/ci/Jenkinsfile @@ -102,7 +102,7 @@ def buildConfigs = [ DockerImage: "silkit-ubuntu", DockerBuildArgs: "--build-arg UBUNTU_VERSION=22.04", PublishArtifacts: false, - CmakeArgs: "-D SILKIT_ENABLE_THREADSAN=ON -D SILKIT_BUILD_DASHBOARD=OFF", + CmakeArgs: "-D SILKIT_ENABLE_THREADSAN=ON", CmakePreset: "clang14-release", TestDebug: true, ] @@ -112,7 +112,7 @@ def buildConfigs = [ DockerImage: "silkit-ubuntu", DockerBuildArgs: "--build-arg UBUNTU_VERSION=22.04", PublishArtifacts: false, - CmakeArgs: "-D SILKIT_ENABLE_ASAN=ON -D SILKIT_BUILD_DASHBOARD=OFF", + CmakeArgs: "-D SILKIT_ENABLE_ASAN=ON", CmakePreset: "clang14-release", TestDebug: true, ] @@ -122,7 +122,7 @@ def buildConfigs = [ DockerImage: "silkit-ubuntu", DockerBuildArgs: "--build-arg UBUNTU_VERSION=22.04", PublishArtifacts: false, - CmakeArgs: "-D SILKIT_ENABLE_UBSAN=ON -D SILKIT_BUILD_DASHBOARD=OFF", + CmakeArgs: "-D SILKIT_ENABLE_UBSAN=ON", CmakePreset: "clang14-release", TestDebug: true, ] diff --git a/SilKit/source/config/BasicYamlWriter.hpp b/SilKit/source/config/BasicYamlWriter.hpp new file mode 100644 index 000000000..f86138816 --- /dev/null +++ b/SilKit/source/config/BasicYamlWriter.hpp @@ -0,0 +1,130 @@ +#pragma once +// SPDX-FileCopyrightText: 2025 Vector Informatik GmbH +// +// SPDX-License-Identifier: MIT + +// Generic ryml tree-writing machinery, split out of YamlWriter.hpp so that consumers which only +// need the CRTP base (e.g. the dashboard JSON writer) do not have to pull in the whole of +// ParticipantConfiguration.hpp. + +#include +#include +#include +#include + +#include "rapidyaml.hpp" + +#include "silkit/participant/exception.hpp" + +namespace VSilKit { + +template +struct BasicYamlWriter +{ + ryml::NodeRef node; + +public: + BasicYamlWriter(ryml::NodeRef node_) + : node(node_) + { + } + +public: + template + void OptionalWrite(const std::optional& val, const std::string& name) + { + if (val.has_value()) + { + WriteKeyValue(name, val.value()); + } + } + + template + void OptionalWrite(const std::vector& val, const std::string& name) + { + if (!val.empty()) + { + WriteKeyValue(name, val); + } + } + + void OptionalWrite(const std::string& val, const std::string& name) + { + if (!val.empty()) + { + WriteKeyValue(name, val); + } + } + + template + void NonDefaultWrite(const T& val, const std::string& name, const T& defaultValue) + { + if (!(val == defaultValue)) + { + WriteKeyValue(name, val); + } + } + + template + void WriteKeyValue(const std::string& name, const T& val) + { + if (!node.is_map()) + { + throw SilKit::ConfigurationError("Parse error: trying to access child of something not a map"); + } + + auto writer = MakeImpl(node.append_child() << ryml::key(name)); + writer.Write(val); + } + + template + void Write(const T& val) + { + node << val; + } + + template + void Write(const std::vector& val) + { + node |= ryml::SEQ; + for (auto&& el : val) + { + auto writer = MakeImpl(node.append_child()); + writer.Write(el); + } + } + +protected: + void MakeMap() + { + node |= ryml::MAP; + } + + auto MakeConfigurationError(const char* message) const -> SilKit::ConfigurationError + { + std::ostringstream s; + + s << "error writing configuration: " << message; + + return SilKit::ConfigurationError{s.str()}; + } + +protected: + auto MakeImpl(ryml::NodeRef node_) const -> Impl + { + return Impl{node_}; + } + +private: + auto AsImpl() -> Impl& + { + return static_cast(*this); + } + + auto AsImpl() const -> const Impl& + { + return static_cast(*this); + } +}; + +} // namespace VSilKit diff --git a/SilKit/source/config/CMakeLists.txt b/SilKit/source/config/CMakeLists.txt index 1a877a489..da63a15db 100644 --- a/SilKit/source/config/CMakeLists.txt +++ b/SilKit/source/config/CMakeLists.txt @@ -16,6 +16,7 @@ add_library(O_SilKit_Config OBJECT YamlParser.cpp YamlReader.hpp YamlReader.cpp + BasicYamlWriter.hpp YamlWriter.hpp YamlWriter.cpp diff --git a/SilKit/source/config/YamlWriter.hpp b/SilKit/source/config/YamlWriter.hpp index 0fb05c8ff..06dfd616a 100644 --- a/SilKit/source/config/YamlWriter.hpp +++ b/SilKit/source/config/YamlWriter.hpp @@ -12,119 +12,11 @@ #include "rapidyaml.hpp" +#include "config/BasicYamlWriter.hpp" #include "config/ParticipantConfiguration.hpp" namespace VSilKit { -template -struct BasicYamlWriter -{ - ryml::NodeRef node; - -public: - BasicYamlWriter(ryml::NodeRef node_) - : node(node_) - { - } - -public: - template - void OptionalWrite(const std::optional& val, const std::string& name) - { - if (val.has_value()) - { - WriteKeyValue(name, val.value()); - } - } - - template - void OptionalWrite(const std::vector& val, const std::string& name) - { - if (!val.empty()) - { - WriteKeyValue(name, val); - } - } - - void OptionalWrite(const std::string& val, const std::string& name) - { - if (!val.empty()) - { - WriteKeyValue(name, val); - } - } - - template - void NonDefaultWrite(const T& val, const std::string& name, const T& defaultValue) - { - if (!(val == defaultValue)) - { - WriteKeyValue(name, val); - } - } - - template - void WriteKeyValue(const std::string& name, const T& val) - { - if (!node.is_map()) - { - throw SilKit::ConfigurationError("Parse error: trying to access child of something not a map"); - } - - auto writer = MakeImpl(node.append_child() << ryml::key(name)); - writer.Write(val); - } - - template - void Write(const T& val) - { - node << val; - } - - template - void Write(const std::vector& val) - { - node |= ryml::SEQ; - for (auto&& el : val) - { - auto writer = MakeImpl(node.append_child()); - writer.Write(el); - } - } - -protected: - void MakeMap() - { - node |= ryml::MAP; - } - - auto MakeConfigurationError(const char* message) const -> SilKit::ConfigurationError - { - std::ostringstream s; - - s << "error writing configuration: " << message; - - return SilKit::ConfigurationError{s.str()}; - } - -protected: - auto MakeImpl(ryml::NodeRef node_) const -> Impl - { - return Impl{node_}; - } - -private: - auto AsImpl() -> Impl& - { - return static_cast(*this); - } - - auto AsImpl() const -> const Impl& - { - return static_cast(*this); - } -}; - struct YamlWriter : BasicYamlWriter { using BasicYamlWriter::BasicYamlWriter; diff --git a/SilKit/source/core/internal/internal_fwd.hpp b/SilKit/source/core/internal/internal_fwd.hpp index 0c6323129..ff0182513 100644 --- a/SilKit/source/core/internal/internal_fwd.hpp +++ b/SilKit/source/core/internal/internal_fwd.hpp @@ -10,6 +10,8 @@ class SystemStateTracker; class ConnectPeer; class MetricsProcessor; class AsioGenericRawByteStream; +class AsioHttpClient; +class RetryingHttpClient; } // namespace VSilKit namespace SilKit { namespace Tracing { diff --git a/SilKit/source/core/internal/traits/SilKitLoggingTraits.hpp b/SilKit/source/core/internal/traits/SilKitLoggingTraits.hpp index 729a5b617..75eedfe21 100644 --- a/SilKit/source/core/internal/traits/SilKitLoggingTraits.hpp +++ b/SilKit/source/core/internal/traits/SilKitLoggingTraits.hpp @@ -81,6 +81,8 @@ DefineSilKitLoggingTrait_Topic(VSilKit::AsioGenericRawByteStream, SilKit::Servic DefineSilKitLoggingTrait_Topic(SilKit::Dashboard::DashboardRestClient, SilKit::Services::Logging::Topic::Dashboard); DefineSilKitLoggingTrait_Topic(SilKit::Dashboard::DashboardSystemServiceClient, SilKit::Services::Logging::Topic::Dashboard); DefineSilKitLoggingTrait_Topic(SilKit::Dashboard::DashboardInstance, SilKit::Services::Logging::Topic::Dashboard); +DefineSilKitLoggingTrait_Topic(VSilKit::AsioHttpClient, SilKit::Services::Logging::Topic::Dashboard); +DefineSilKitLoggingTrait_Topic(VSilKit::RetryingHttpClient, SilKit::Services::Logging::Topic::Dashboard); DefineSilKitLoggingTrait_Topic(VSilKit::MetricsProcessor, SilKit::Services::Logging::Topic::Metrics); diff --git a/SilKit/source/dashboard/CMakeLists.txt b/SilKit/source/dashboard/CMakeLists.txt index 8235c4219..88f8fa188 100755 --- a/SilKit/source/dashboard/CMakeLists.txt +++ b/SilKit/source/dashboard/CMakeLists.txt @@ -23,36 +23,46 @@ if(SILKIT_BUILD_DASHBOARD) endif () add_library(O_SilKit_Dashboard STATIC - client/DashboardComponents.hpp - client/DashboardSystemApiClient.hpp + client/DashboardPaths.hpp client/DashboardSystemServiceClient.cpp client/DashboardSystemServiceClient.hpp - client/DashboardRetryPolicy.cpp - client/DashboardRetryPolicy.hpp client/IDashboardSystemServiceClient.hpp + http/AsioHttpClient.cpp + http/AsioHttpClient.hpp + http/FakeHttpServer.hpp + http/HttpResponseParser.cpp + http/HttpResponseParser.hpp + http/HttpRetryPolicy.hpp + http/IHttpClient.hpp + http/RetryingHttpClient.cpp + http/RetryingHttpClient.hpp + + dto/BulkUpdateDto.hpp dto/DataSpecDto.hpp dto/MatchingLabelDto.hpp + dto/MetricsDto.hpp dto/ParticipantStatusDto.hpp dto/RpcSpecDto.hpp - dto/ServiceDto.hpp dto/SimulationConfigurationDto.hpp dto/SimulationCreationRequestDto.hpp - dto/SimulationCreationResponseDto.hpp dto/SystemStatusDto.hpp - service/ISilKitToOatppMapper.hpp + json/DashboardJson.cpp + json/DashboardJson.hpp + json/DashboardJsonWriter.cpp + json/DashboardJsonWriter.hpp + + service/IDashboardDtoMapper.hpp service/DashboardRestClient.cpp service/DashboardRestClient.hpp - service/SilKitToOatppMapper.cpp - service/SilKitToOatppMapper.hpp + service/DashboardDtoMapper.cpp + service/DashboardDtoMapper.hpp LockedQueue.hpp SilKitEvent.hpp DashboardBulkUpdate.hpp - OatppHeaders.cpp - OatppHeaders.hpp DashboardInstance.cpp CreateDashboardInstance.cpp @@ -61,9 +71,20 @@ if(SILKIT_BUILD_DASHBOARD) PRIVATE I_SilKit PRIVATE S_SilKitImpl PRIVATE Threads::Threads - PRIVATE oatpp ) + target_compile_definitions(O_SilKit_Dashboard + PRIVATE ASIO_STANDALONE + ) + + if (MSVC) + target_compile_options(O_SilKit_Dashboard PRIVATE "/bigobj") + endif() + + if(MINGW) + target_link_libraries(O_SilKit_Dashboard PUBLIC -lwsock32 -lws2_32) #windows socket/ wsa + endif() + silkit_target_clean_compileflags(O_SilKit_Dashboard) silkit_dashboard_target_compile_flags(O_SilKit_Dashboard) @@ -78,22 +99,60 @@ if(SILKIT_BUILD_DASHBOARD) I_SilKit ) + add_silkit_test_to_executable(SilKitDashboardTests + SOURCES service/Test_DashboardShutdown.cpp + LIBS + S_SilKitImpl + O_SilKit_Dashboard + I_SilKit + ) + add_silkit_test_to_executable(SilKitDashboardTests SOURCES service/Test_DashboardRestClient.cpp LIBS S_SilKitImpl O_SilKit_Dashboard I_SilKit - oatpp ) add_silkit_test_to_executable(SilKitDashboardTests - SOURCES service/Test_DashboardSilKitToOatppMapper.cpp + SOURCES service/Test_DashboardDtoMapper.cpp + LIBS + S_SilKitImpl + O_SilKit_Dashboard + I_SilKit + ) + + add_silkit_test_to_executable(SilKitDashboardTests + SOURCES json/Test_DashboardJsonWriter.cpp + LIBS + S_SilKitImpl + O_SilKit_Dashboard + I_SilKit + ) + + add_silkit_test_to_executable(SilKitDashboardTests + SOURCES http/Test_HttpResponseParser.cpp + LIBS + S_SilKitImpl + O_SilKit_Dashboard + I_SilKit + ) + + add_silkit_test_to_executable(SilKitDashboardTests + SOURCES http/Test_RetryingHttpClient.cpp + LIBS + S_SilKitImpl + O_SilKit_Dashboard + I_SilKit + ) + + add_silkit_test_to_executable(SilKitDashboardTests + SOURCES http/Test_AsioHttpClient.cpp LIBS S_SilKitImpl O_SilKit_Dashboard I_SilKit - oatpp ) add_silkit_test_to_executable(SilKitDashboardTests @@ -102,7 +161,6 @@ if(SILKIT_BUILD_DASHBOARD) S_SilKitImpl O_SilKit_Dashboard I_SilKit - oatpp ) else() diff --git a/SilKit/source/dashboard/DashboardInstance.cpp b/SilKit/source/dashboard/DashboardInstance.cpp index 79d6e1266..22aacb4ea 100644 --- a/SilKit/source/dashboard/DashboardInstance.cpp +++ b/SilKit/source/dashboard/DashboardInstance.cpp @@ -5,7 +5,7 @@ #include "dashboard/DashboardInstance.hpp" #include "dashboard/SilKitEvent.hpp" #include "dashboard/LockedQueue.hpp" -#include "dashboard/service/SilKitToOatppMapper.hpp" +#include "dashboard/service/DashboardDtoMapper.hpp" #include "dashboard/service/DashboardRestClient.hpp" @@ -53,10 +53,26 @@ DashboardInstance::~DashboardInstance() _silKitEventQueue.Stop(); + /* The worker may be blocked in an HTTP request. Give it a grace period to finish flushing, then + * abort the transport so shutdown stays bounded even when the dashboard server accepts + * connections but never answers. std::thread has no timed join, hence the watchdog. */ + std::promise workerFinished; + auto workerFinishedFuture = workerFinished.get_future(); + auto watchdog = std::async(std::launch::async, [this, &workerFinishedFuture] { + if (workerFinishedFuture.wait_for(_shutdownGracePeriod) == std::future_status::timeout + && _dashboardRestClient != nullptr) + { + _dashboardRestClient->Abort(); + } + }); + if (_eventQueueWorkerThread.joinable()) { _eventQueueWorkerThread.join(); } + + workerFinished.set_value(); + watchdog.wait(); } auto DashboardInstance::GetRegistryEventListener() -> SilKit::Core::IRegistryEventListener* diff --git a/SilKit/source/dashboard/DashboardInstance.hpp b/SilKit/source/dashboard/DashboardInstance.hpp index c7e3798aa..c63c4e821 100644 --- a/SilKit/source/dashboard/DashboardInstance.hpp +++ b/SilKit/source/dashboard/DashboardInstance.hpp @@ -9,8 +9,6 @@ #include "services/logging/LoggerMessage.hpp" -#include "dashboard/client/DashboardSystemApiClient.hpp" -#include "dashboard/service/ISilKitToOatppMapper.hpp" #include "services/orchestration/SystemStateTracker.hpp" #include "dashboard/IRestClient.hpp" @@ -18,6 +16,7 @@ #include "dashboard/SilKitEvent.hpp" #include +#include #include #include #include @@ -80,6 +79,9 @@ class DashboardInstance final std::shared_ptr _dashboardRestClient; LockedQueue _silKitEventQueue; + /// How long the destructor lets an in-flight dashboard request finish before aborting it. + std::chrono::milliseconds _shutdownGracePeriod{5000}; + std::thread _eventQueueWorkerThread; std::promise _eventQueueWorkerThreadAbort; diff --git a/SilKit/source/dashboard/IRestClient.hpp b/SilKit/source/dashboard/IRestClient.hpp index f4d98ec03..a7e3fbd18 100644 --- a/SilKit/source/dashboard/IRestClient.hpp +++ b/SilKit/source/dashboard/IRestClient.hpp @@ -27,6 +27,13 @@ class IRestClient virtual void OnMetricsUpdate(uint64_t simulationId, const std::string& origin, const VSilKit::MetricsUpdate& metricsUpdate) = 0; virtual bool IsBulkUpdateSupported() = 0; + + /*! Unblock any in-flight request and make all further ones fail fast. Idempotent. + * + * Needed on shutdown: a dashboard server that accepts connections but never answers would + * otherwise keep the registry's dashboard worker thread from ever finishing. + */ + virtual void Abort() = 0; }; } // namespace VSilKit diff --git a/SilKit/source/dashboard/OatppHeaders.cpp b/SilKit/source/dashboard/OatppHeaders.cpp deleted file mode 100644 index ad6386b8b..000000000 --- a/SilKit/source/dashboard/OatppHeaders.cpp +++ /dev/null @@ -1,5 +0,0 @@ -// SPDX-FileCopyrightText: 2023 Vector Informatik GmbH -// -// SPDX-License-Identifier: MIT - -#include "dashboard/OatppHeaders.hpp" diff --git a/SilKit/source/dashboard/OatppHeaders.hpp b/SilKit/source/dashboard/OatppHeaders.hpp deleted file mode 100644 index a4adcdc22..000000000 --- a/SilKit/source/dashboard/OatppHeaders.hpp +++ /dev/null @@ -1,50 +0,0 @@ -// SPDX-FileCopyrightText: 2023 Vector Informatik GmbH -// -// SPDX-License-Identifier: MIT - -#pragma once - - -#ifdef _MSC_VER -#pragma warning(push) -#pragma warning(disable : 4121) -#pragma warning(disable : 4244) -#pragma warning(disable : 4389) -#endif - -#ifdef __GNUC__ -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Wsign-compare" -#endif - - -#include "oatpp/core/Types.hpp" -#include "oatpp/core/data/stream/Stream.hpp" -#include "oatpp/core/macro/codegen.hpp" -#include "oatpp/core/macro/component.hpp" - -#include "oatpp/network/ConnectionPool.hpp" -#include "oatpp/network/ConnectionProvider.hpp" -#include "oatpp/network/Server.hpp" -#include "oatpp/network/tcp/client/ConnectionProvider.hpp" -#include "oatpp/network/tcp/server/ConnectionProvider.hpp" - -#include "oatpp/parser/json/mapping/ObjectMapper.hpp" - -#include "oatpp/web/client/ApiClient.hpp" -#include "oatpp/web/client/HttpRequestExecutor.hpp" -#include "oatpp/web/client/RetryPolicy.hpp" -#include "oatpp/web/protocol/http/Http.hpp" -#include "oatpp/web/protocol/http/incoming/BodyDecoder.hpp" -#include "oatpp/web/server/HttpConnectionHandler.hpp" -#include "oatpp/web/server/HttpRouter.hpp" -#include "oatpp/web/server/api/ApiController.hpp" - - -#ifdef __GNUC__ -#pragma GCC diagnostic pop -#endif - -#ifdef _MSC_VER -#pragma warning(pop) -#endif diff --git a/SilKit/source/dashboard/client/DashboardComponents.hpp b/SilKit/source/dashboard/client/DashboardComponents.hpp deleted file mode 100644 index de40c764e..000000000 --- a/SilKit/source/dashboard/client/DashboardComponents.hpp +++ /dev/null @@ -1,47 +0,0 @@ -// SPDX-FileCopyrightText: 2022 Vector Informatik GmbH -// -// SPDX-License-Identifier: MIT - -#pragma once - -#include -#include -#include - -#include "dashboard/OatppHeaders.hpp" - -namespace SilKit { -namespace Dashboard { - -class DashboardComponents -{ -private: - std::string _host; - uint16_t _port; - -public: - DashboardComponents(const std::string& host, uint16_t port) - : _host(host) - , _port(port) - { - } - -public: - OATPP_CREATE_COMPONENT(std::shared_ptr, clientConnectionProvider) - ("DashboardComponents_clientConnectionProvider", - [this]() -> std::shared_ptr { - auto connectionProvider = oatpp::network::tcp::client::ConnectionProvider::createShared({_host, _port}); - return oatpp::network::ClientConnectionPool::createShared(connectionProvider, 5, std::chrono::seconds(10), - std::chrono::seconds(5)); - }()); - - OATPP_CREATE_COMPONENT(std::shared_ptr, apiObjectMapper) - ("DashboardComponents_apiObjectMapper", [] { - auto objectMapper = oatpp::parser::json::mapping::ObjectMapper::createShared(); - objectMapper->getDeserializer()->getConfig()->allowUnknownFields = false; - return objectMapper; - }()); -}; - -} // namespace Dashboard -} // namespace SilKit diff --git a/SilKit/source/dashboard/client/DashboardPaths.hpp b/SilKit/source/dashboard/client/DashboardPaths.hpp new file mode 100644 index 000000000..51c4f504c --- /dev/null +++ b/SilKit/source/dashboard/client/DashboardPaths.hpp @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: 2026 Vector Informatik GmbH +// +// SPDX-License-Identifier: MIT + +#pragma once + +#include +#include + +namespace SilKit { +namespace Dashboard { +namespace Paths { + +// No leading '/': the transport prepends it, as the previous oatpp ApiClient did. + +inline auto CreateSimulation() -> std::string +{ + return "system-service/v1.0/simulations"; +} + +inline auto UpdateSimulation(uint64_t simulationId) -> std::string +{ + return "system-service/v1.1/simulations/" + std::to_string(simulationId); +} + +inline auto UpdateSimulationMetrics(uint64_t simulationId) -> std::string +{ + return "system-service/v1.1/simulations/" + std::to_string(simulationId) + "/metrics"; +} + +} // namespace Paths +} // namespace Dashboard +} // namespace SilKit diff --git a/SilKit/source/dashboard/client/DashboardRetryPolicy.cpp b/SilKit/source/dashboard/client/DashboardRetryPolicy.cpp deleted file mode 100644 index 009d3e8ec..000000000 --- a/SilKit/source/dashboard/client/DashboardRetryPolicy.cpp +++ /dev/null @@ -1,50 +0,0 @@ -// SPDX-FileCopyrightText: 2022 Vector Informatik GmbH -// -// SPDX-License-Identifier: MIT - -#include "dashboard/client/DashboardRetryPolicy.hpp" - -#include "silkit/SilKitMacros.hpp" - -namespace SilKit { -namespace Dashboard { - -DashboardRetryPolicy::DashboardRetryPolicy(std::size_t retryCount) - : _retryCount(retryCount) -{ -} - -void DashboardRetryPolicy::AbortAllRetries() -{ - _abortRetries = true; -} - -bool DashboardRetryPolicy::canRetry(const Context& context) -{ - if (_abortRetries) - return false; - if (_retryCount == InfiniteRetries) - return true; - return context.attempt < static_cast(_retryCount); -} - -bool DashboardRetryPolicy::retryOnResponse(v_int32 responseStatusCode, const Context& context) -{ - SILKIT_UNUSED_ARG(context); - if (_abortRetries) - return false; - if (responseStatusCode == 503) - return true; - return false; -} - -v_int64 DashboardRetryPolicy::waitForMicroseconds(const Context& context) -{ - SILKIT_UNUSED_ARG(context); - if (_abortRetries) - return 0; - return std::chrono::duration_cast(_defaultSleep).count(); -} - -} // namespace Dashboard -} // namespace SilKit diff --git a/SilKit/source/dashboard/client/DashboardRetryPolicy.hpp b/SilKit/source/dashboard/client/DashboardRetryPolicy.hpp deleted file mode 100644 index 66f041612..000000000 --- a/SilKit/source/dashboard/client/DashboardRetryPolicy.hpp +++ /dev/null @@ -1,31 +0,0 @@ -// SPDX-FileCopyrightText: 2022 Vector Informatik GmbH -// -// SPDX-License-Identifier: MIT - -#pragma once - -#include - -#include "dashboard/OatppHeaders.hpp" - -namespace SilKit { -namespace Dashboard { -class DashboardRetryPolicy : public oatpp::web::client::RetryPolicy -{ -public: - DashboardRetryPolicy(std::size_t retryCount = InfiniteRetries); - -public: - constexpr static std::size_t InfiniteRetries{0}; - - std::chrono::milliseconds _defaultSleep{300}; - std::size_t _retryCount{0}; - std::atomic _abortRetries{false}; - - void AbortAllRetries(); - bool canRetry(const Context& context) override; - bool retryOnResponse(v_int32 responseStatusCode, const Context& context) override; - v_int64 waitForMicroseconds(const Context& context) override; -}; -} // namespace Dashboard -} // namespace SilKit diff --git a/SilKit/source/dashboard/client/DashboardSystemApiClient.hpp b/SilKit/source/dashboard/client/DashboardSystemApiClient.hpp deleted file mode 100644 index fd01e6c8e..000000000 --- a/SilKit/source/dashboard/client/DashboardSystemApiClient.hpp +++ /dev/null @@ -1,39 +0,0 @@ -// SPDX-FileCopyrightText: 2022 Vector Informatik GmbH -// -// SPDX-License-Identifier: MIT - -#pragma once - -#include "dashboard/OatppHeaders.hpp" - -#include "dashboard/dto/SimulationCreationRequestDto.hpp" -#include "dashboard/dto/BulkUpdateDto.hpp" -#include "dashboard/dto/MetricsDto.hpp" - -#include OATPP_CODEGEN_BEGIN(ApiClient) - -namespace SilKit { -namespace Dashboard { - -class DashboardSystemApiClient : public oatpp::web::client::ApiClient -{ - API_CLIENT_INIT(DashboardSystemApiClient) - - // notify a simulation has been started - // get a simulationId in return that can be used to send additional data - API_CALL("POST", "system-service/v1.0/simulations", createSimulation, - BODY_DTO(Object, simulation)) - - // bulk update of a simulation - API_CALL("POST", "system-service/v1.1/simulations/{simulationId}", updateSimulation, PATH(UInt64, simulationId), - BODY_DTO(Object, simulation)) - - // bulk update of simulation metrics - API_CALL("POST", "system-service/v1.1/simulations/{simulationId}/metrics", updateSimulationMetrics, - PATH(UInt64, simulationId), BODY_DTO(Object, simulation)) -}; - -} // namespace Dashboard -} // namespace SilKit - -#include OATPP_CODEGEN_END(ApiClient) diff --git a/SilKit/source/dashboard/client/DashboardSystemServiceClient.cpp b/SilKit/source/dashboard/client/DashboardSystemServiceClient.cpp index cf34d2a59..1cc0a10d3 100644 --- a/SilKit/source/dashboard/client/DashboardSystemServiceClient.cpp +++ b/SilKit/source/dashboard/client/DashboardSystemServiceClient.cpp @@ -4,80 +4,81 @@ #include "dashboard/client/DashboardSystemServiceClient.hpp" -#include "services/logging/LoggerMessage.hpp" +#include -#include OATPP_CODEGEN_BEGIN(ApiClient) +#include "dashboard/client/DashboardPaths.hpp" +#include "dashboard/json/DashboardJson.hpp" +#include "services/logging/LoggerMessage.hpp" -using namespace std::chrono_literals; using SilKit::Services::Logging::Level; -using SilKit::Services::Logging::Topic; using SilKit::Services::Logging::LoggerMessage; +using SilKit::Services::Logging::Topic; namespace SilKit { namespace Dashboard { -DashboardSystemServiceClient::DashboardSystemServiceClient( - Services::Logging::ILoggerInternal* logger, std::shared_ptr dashboardSystemApiClient, - std::shared_ptr objectMapper) +DashboardSystemServiceClient::DashboardSystemServiceClient(Services::Logging::ILoggerInternal* logger, + std::shared_ptr httpClient) : _logger(logger) - , _dashboardSystemApiClient(dashboardSystemApiClient) - , _objectMapper(objectMapper) + , _httpClient(std::move(httpClient)) { } DashboardSystemServiceClient::~DashboardSystemServiceClient() {} -void DashboardSystemServiceClient::UpdateSimulation(oatpp::UInt64 simulationId, - oatpp::Object bulkSimulation) +auto DashboardSystemServiceClient::CreateSimulation(const SimulationCreationRequestDto& simulation) + -> std::optional { - auto response = _dashboardSystemApiClient->updateSimulation(simulationId, bulkSimulation); - Log(response, "updating simulation"); + const auto result = _httpClient->Post(Paths::CreateSimulation(), ToJson(simulation)); + Log(result, "creating simulation"); + if (!result.transportError && result.statusCode == 201) + { + return ParseSimulationCreationResponse(result.body); + } + return std::nullopt; } -oatpp::Object DashboardSystemServiceClient::CreateSimulation( - oatpp::Object simulation) +void DashboardSystemServiceClient::UpdateSimulation(uint64_t simulationId, const BulkSimulationDto& bulkSimulation) { - auto response = _dashboardSystemApiClient->createSimulation(simulation); - Log(response, "creating simulation"); - if (response && response->getStatusCode() == 201) - { - return response->readBodyToDto>(_objectMapper); - } - return nullptr; + const auto result = _httpClient->Post(Paths::UpdateSimulation(simulationId), ToJson(bulkSimulation)); + Log(result, "updating simulation"); } -void DashboardSystemServiceClient::UpdateSimulationMetrics(oatpp::UInt64 simulationId, - oatpp::Object metrics) +void DashboardSystemServiceClient::UpdateSimulationMetrics(uint64_t simulationId, const MetricsUpdateDto& metrics) { - auto response = _dashboardSystemApiClient->updateSimulationMetrics(simulationId, metrics); - Log(response, "updating simulation metrics"); + const auto result = _httpClient->Post(Paths::UpdateSimulationMetrics(simulationId), ToJson(metrics)); + Log(result, "updating simulation metrics"); } +auto DashboardSystemServiceClient::CheckBulkUpdateSupported() -> bool +{ + // Deliberately unlogged: the previous implementation probed through the raw api client, which + // did not log either, and a failure here is reported by the caller instead. + const auto result = _httpClient->Post(Paths::UpdateSimulation(0), ToJson(BulkSimulationDto{})); + return !result.transportError && 200 <= result.statusCode && result.statusCode < 300; +} -void DashboardSystemServiceClient::Log(std::shared_ptr response, - const std::string& message) +void DashboardSystemServiceClient::Log(const VSilKit::HttpResult& result, const std::string& message) { - if (!response) + if (result.transportError) { _logger->MakeMessage(Level::Error, TopicOf(*this)) .SetMessage("Dashboard: {} server unavailable", message) .Dispatch(); } - else if (response->getStatusCode() >= 400) + else if (result.statusCode >= 400) { _logger->MakeMessage(Level::Error, TopicOf(*this)) - .SetMessage("Dashboard: {} returned {}", message, response->getStatusCode()) + .SetMessage("Dashboard: {} returned {}", message, result.statusCode) .Dispatch(); } else { _logger->MakeMessage(Level::Debug, TopicOf(*this)) - .SetMessage("Dashboard: {} returned {}", message, response->getStatusCode()) + .SetMessage("Dashboard: {} returned {}", message, result.statusCode) .Dispatch(); } } } // namespace Dashboard } // namespace SilKit - -#include OATPP_CODEGEN_END(ApiClient) diff --git a/SilKit/source/dashboard/client/DashboardSystemServiceClient.hpp b/SilKit/source/dashboard/client/DashboardSystemServiceClient.hpp index e0e72dc4f..2ce2e5175 100644 --- a/SilKit/source/dashboard/client/DashboardSystemServiceClient.hpp +++ b/SilKit/source/dashboard/client/DashboardSystemServiceClient.hpp @@ -4,14 +4,13 @@ #pragma once -#include "dashboard/client/IDashboardSystemServiceClient.hpp" - #include +#include +#include "dashboard/client/IDashboardSystemServiceClient.hpp" +#include "dashboard/http/IHttpClient.hpp" #include "services/logging/ILoggerInternal.hpp" -#include "dashboard/client/DashboardSystemApiClient.hpp" - namespace SilKit { namespace Dashboard { @@ -19,23 +18,19 @@ class DashboardSystemServiceClient : public IDashboardSystemServiceClient { public: DashboardSystemServiceClient(Services::Logging::ILoggerInternal* logger, - std::shared_ptr dashboardSystemApiClient, - std::shared_ptr objectMapper); - ~DashboardSystemServiceClient(); + std::shared_ptr httpClient); + ~DashboardSystemServiceClient() override; -public: - oatpp::Object CreateSimulation( - oatpp::Object simulation) override; - void UpdateSimulation(oatpp::UInt64 simulationId, oatpp::Object bulkSimulation) override; - void UpdateSimulationMetrics(oatpp::UInt64 simulationId, oatpp::Object metrics) override; + auto CreateSimulation(const SimulationCreationRequestDto& simulation) -> std::optional override; + void UpdateSimulation(uint64_t simulationId, const BulkSimulationDto& bulkSimulation) override; + void UpdateSimulationMetrics(uint64_t simulationId, const MetricsUpdateDto& metrics) override; + auto CheckBulkUpdateSupported() -> bool override; private: - void Log(std::shared_ptr response, const std::string& message); + void Log(const VSilKit::HttpResult& result, const std::string& message); -private: Services::Logging::ILoggerInternal* _logger; - std::shared_ptr _dashboardSystemApiClient; - std::shared_ptr _objectMapper; + std::shared_ptr _httpClient; }; } // namespace Dashboard diff --git a/SilKit/source/dashboard/client/IDashboardSystemServiceClient.hpp b/SilKit/source/dashboard/client/IDashboardSystemServiceClient.hpp index d043111ff..cc9818007 100644 --- a/SilKit/source/dashboard/client/IDashboardSystemServiceClient.hpp +++ b/SilKit/source/dashboard/client/IDashboardSystemServiceClient.hpp @@ -4,10 +4,12 @@ #pragma once -#include "dashboard/dto/SimulationCreationRequestDto.hpp" -#include "dashboard/dto/SimulationCreationResponseDto.hpp" +#include +#include + #include "dashboard/dto/BulkUpdateDto.hpp" #include "dashboard/dto/MetricsDto.hpp" +#include "dashboard/dto/SimulationCreationRequestDto.hpp" namespace SilKit { namespace Dashboard { @@ -17,11 +19,18 @@ class IDashboardSystemServiceClient public: virtual ~IDashboardSystemServiceClient() = default; - virtual oatpp::Object CreateSimulation( - oatpp::Object simulation) = 0; + /*! Register a new simulation and return the id the dashboard assigned to it. + * + * std::nullopt means no id was obtained - a non-201 response, a transport failure, or a body + * that could not be parsed. + */ + virtual auto CreateSimulation(const SimulationCreationRequestDto& simulation) -> std::optional = 0; + + virtual void UpdateSimulation(uint64_t simulationId, const BulkSimulationDto& bulkSimulation) = 0; + virtual void UpdateSimulationMetrics(uint64_t simulationId, const MetricsUpdateDto& metrics) = 0; - virtual void UpdateSimulation(oatpp::UInt64 simulationId, oatpp::Object bulkSimulation) = 0; - virtual void UpdateSimulationMetrics(oatpp::UInt64 simulationId, oatpp::Object metrics) = 0; + //! Probe whether the dashboard service supports the bulk-update endpoint. + virtual auto CheckBulkUpdateSupported() -> bool = 0; }; } // namespace Dashboard diff --git a/SilKit/source/dashboard/client/Mocks/MockBodyDecoder.hpp b/SilKit/source/dashboard/client/Mocks/MockBodyDecoder.hpp deleted file mode 100644 index b185726b4..000000000 --- a/SilKit/source/dashboard/client/Mocks/MockBodyDecoder.hpp +++ /dev/null @@ -1,27 +0,0 @@ -// SPDX-FileCopyrightText: 2022 Vector Informatik GmbH -// -// SPDX-License-Identifier: MIT - -#pragma once - -#include "gmock/gmock-function-mocker.h" - -#include "dashboard/OatppHeaders.hpp" - -namespace SilKit { -namespace Dashboard { -class MockBodyDecoder : public oatpp::web::protocol::http::incoming::BodyDecoder -{ -public: - MOCK_METHOD(void, decode, - (const oatpp::web::protocol::http::Headers&, oatpp::data::stream::InputStream*, - oatpp::data ::stream::WriteCallback*, oatpp::data::stream::IOStream*), - (const, override)); - MOCK_METHOD(oatpp::async::CoroutineStarter, decodeAsync, - (const oatpp::web::protocol::http::Headers&, const std::shared_ptr&, - const std::shared_ptr&, - const std::shared_ptr&), - (const, override)); -}; -} // namespace Dashboard -} // namespace SilKit diff --git a/SilKit/source/dashboard/client/Mocks/MockDashboardSystemApiClient.hpp b/SilKit/source/dashboard/client/Mocks/MockDashboardSystemApiClient.hpp deleted file mode 100644 index 17e4ab389..000000000 --- a/SilKit/source/dashboard/client/Mocks/MockDashboardSystemApiClient.hpp +++ /dev/null @@ -1,43 +0,0 @@ -// SPDX-FileCopyrightText: 2022 Vector Informatik GmbH -// -// SPDX-License-Identifier: MIT - -#pragma once - -#include "gmock/gmock-function-mocker.h" - -#include "dashboard/client/DashboardSystemApiClient.hpp" - -namespace SilKit { -namespace Dashboard { -class MockDashboardSystemApiClient : public DashboardSystemApiClient -{ - using RequestExecutor = oatpp::web::client::RequestExecutor; - using ObjectMapper = oatpp::data::mapping::ObjectMapper; - -public: - MockDashboardSystemApiClient(const std::shared_ptr& objectMapper) - : DashboardSystemApiClient(std::shared_ptr(nullptr), objectMapper) - { - } - - MOCK_METHOD(std::shared_ptr, getConnection, (), (override)); - MOCK_METHOD(oatpp::async::CoroutineStarterForResult&>, - getConnectionAsync, (), (override)); - MOCK_METHOD(std::shared_ptr, executeRequest, - (const oatpp::String&, const StringTemplate&, const Headers&, - (const std::unordered_map&), - (const std::unordered_map&), - const std::shared_ptr&, - const std::shared_ptr&), - (override)); - MOCK_METHOD(oatpp::async::CoroutineStarterForResult&>, executeRequestAsync, - (const oatpp::String&, const StringTemplate&, const Headers&, - (const std::unordered_map&), - (const std::unordered_map&), - const std::shared_ptr&, - const std::shared_ptr&), - (override)); -}; -} // namespace Dashboard -} // namespace SilKit diff --git a/SilKit/source/dashboard/client/Mocks/MockDashboardSystemServiceClient.hpp b/SilKit/source/dashboard/client/Mocks/MockDashboardSystemServiceClient.hpp index 31f6ea4da..6571dc050 100644 --- a/SilKit/source/dashboard/client/Mocks/MockDashboardSystemServiceClient.hpp +++ b/SilKit/source/dashboard/client/Mocks/MockDashboardSystemServiceClient.hpp @@ -4,21 +4,22 @@ #pragma once -#include "gmock/gmock-function-mocker.h" +#include "gmock/gmock.h" #include "dashboard/client/IDashboardSystemServiceClient.hpp" namespace SilKit { namespace Dashboard { + class MockDashboardSystemServiceClient : public IDashboardSystemServiceClient { public: - MOCK_METHOD(oatpp::Object, CreateSimulation, - (oatpp::Object), (override)); - - MOCK_METHOD(void, UpdateSimulation, (oatpp::UInt64, oatpp::Object), (override)); - - MOCK_METHOD(void, UpdateSimulationMetrics, (oatpp::UInt64, oatpp::Object), (override)); + MOCK_METHOD(std::optional, CreateSimulation, (const SimulationCreationRequestDto& simulation), + (override)); + MOCK_METHOD(void, UpdateSimulation, (uint64_t simulationId, const BulkSimulationDto& bulkSimulation), (override)); + MOCK_METHOD(void, UpdateSimulationMetrics, (uint64_t simulationId, const MetricsUpdateDto& metrics), (override)); + MOCK_METHOD(bool, CheckBulkUpdateSupported, (), (override)); }; + } // namespace Dashboard } // namespace SilKit diff --git a/SilKit/source/dashboard/client/Mocks/MockInputStream.hpp b/SilKit/source/dashboard/client/Mocks/MockInputStream.hpp deleted file mode 100644 index 174ae20cb..000000000 --- a/SilKit/source/dashboard/client/Mocks/MockInputStream.hpp +++ /dev/null @@ -1,18 +0,0 @@ -// SPDX-FileCopyrightText: 2022 Vector Informatik GmbH -// -// SPDX-License-Identifier: MIT - -#pragma once - -#include "dashboard/OatppHeaders.hpp" - -struct MockInputStream : public oatpp::data::stream::InputStream -{ - MOCK_METHOD(void, setInputStreamIOMode, (oatpp::data::stream::IOMode), (override)); - - MOCK_METHOD(oatpp::data::stream::IOMode, getInputStreamIOMode, (), (override)); - - MOCK_METHOD(oatpp::data::stream::Context&, getInputStreamContext, (), (override)); - - MOCK_METHOD(oatpp::v_io_size, read, (void*, v_buff_size, oatpp::async::Action&), (override)); -}; \ No newline at end of file diff --git a/SilKit/source/dashboard/client/Mocks/MockObjectMapper.hpp b/SilKit/source/dashboard/client/Mocks/MockObjectMapper.hpp deleted file mode 100644 index b243ad352..000000000 --- a/SilKit/source/dashboard/client/Mocks/MockObjectMapper.hpp +++ /dev/null @@ -1,27 +0,0 @@ -// SPDX-FileCopyrightText: 2022 Vector Informatik GmbH -// -// SPDX-License-Identifier: MIT - -#pragma once - -#include "gmock/gmock-function-mocker.h" - -#include "dashboard/OatppHeaders.hpp" - -namespace SilKit { -namespace Dashboard { -class MockObjectMapper : public oatpp::data::mapping::ObjectMapper -{ -public: - explicit MockObjectMapper(const Info& info) - : ObjectMapper(info) - { - } - - virtual ~MockObjectMapper() = default; - - MOCK_METHOD(void, write, (oatpp::data::stream::ConsistentOutputStream*, const oatpp::Void&), (const, override)); - MOCK_METHOD(oatpp::Void, read, (oatpp::parser::Caret&, const oatpp::Type* const), (const, override)); -}; -} // namespace Dashboard -} // namespace SilKit diff --git a/SilKit/source/dashboard/client/Test_DashboardSystemServiceClient.cpp b/SilKit/source/dashboard/client/Test_DashboardSystemServiceClient.cpp index 64ca506ab..60c457d14 100644 --- a/SilKit/source/dashboard/client/Test_DashboardSystemServiceClient.cpp +++ b/SilKit/source/dashboard/client/Test_DashboardSystemServiceClient.cpp @@ -2,140 +2,197 @@ // // SPDX-License-Identifier: MIT -#ifdef _MSC_VER -#pragma warning(push) -#pragma warning(disable : 4702) -#endif - #include "gmock/gmock.h" #include "gtest/gtest.h" -#ifdef _MSC_VER -#pragma warning(pop) -#endif - #include "core/mock/participant/MockParticipant.hpp" +#include "dashboard/client/DashboardPaths.hpp" #include "dashboard/client/DashboardSystemServiceClient.hpp" -#include "Mocks/MockBodyDecoder.hpp" -#include "Mocks/MockDashboardSystemApiClient.hpp" -#include "Mocks/MockInputStream.hpp" -#include "dashboard/client/Mocks/MockBodyDecoder.hpp" -#include "dashboard/client/Mocks/MockDashboardSystemApiClient.hpp" -#include "dashboard/client/Mocks/MockInputStream.hpp" -#include "dashboard/client/Mocks/MockObjectMapper.hpp" - -using namespace oatpp::web; -using namespace oatpp::data::mapping; -using namespace protocol::http; +#include "dashboard/http/Mocks/MockHttpClient.hpp" +#include "dashboard/json/DashboardJson.hpp" + using namespace testing; +using VSilKit::HttpResult; +using VSilKit::MockHttpClient; namespace SilKit { namespace Dashboard { +namespace { + +auto Responded(int statusCode, std::string body = {}) -> HttpResult +{ + return HttpResult{false, statusCode, std::move(body)}; +} + +auto Unavailable() -> HttpResult +{ + return HttpResult{}; +} class Test_DashboardSystemServiceClient : public Test { public: void SetUp() override { - _mockObjectMapper = std::make_shared(_info); - _objectMapper = std::static_pointer_cast(_mockObjectMapper); - _mockDashboardSystemApiClient = std::make_shared>(_objectMapper); - _mockBodyDecoder = std::make_shared>(); - _nullInput = std::make_shared(); - + _mockHttpClient = std::make_shared>(); EXPECT_CALL(_dummyLogger, GetLogLevel).WillRepeatedly(Return(Services::Logging::Level::Debug)); } - std::shared_ptr CreateService() + auto CreateService() -> std::shared_ptr { - return std::make_shared(&_dummyLogger, _mockDashboardSystemApiClient, - _objectMapper); + return std::make_shared(&_dummyLogger, _mockHttpClient); } - void SetupExecuteRequest( - Status status, - const std::function& pathParams)>& OnRequest) + void ExpectLog(Services::Logging::Level level, const std::string& message) { - std::shared_ptr response = - oatpp::web::protocol::http::incoming::Response::createShared(status.code, status.description, Headers(), - _nullInput, _mockBodyDecoder); - EXPECT_CALL(*_mockDashboardSystemApiClient, executeRequest) - .WillOnce(DoAll(WithArgs<0, 1, 3>([OnRequest](auto currentMethod, auto pathTemplate, auto map) { - OnRequest(currentMethod, pathTemplate, map); - }), - Return(response))); + EXPECT_CALL(_dummyLogger, ProcessLoggerMessage(Services::Logging::ALoggerMessageWith(level, message))); } Core::Tests::MockLogger _dummyLogger; - std::shared_ptr> _mockDashboardSystemApiClient; - std::shared_ptr _mockObjectMapper; - std::shared_ptr _objectMapper; - std::shared_ptr _nullInput; - std::shared_ptr> _mockBodyDecoder; - ObjectMapper::Info _info = "application/json"; + std::shared_ptr> _mockHttpClient; }; +// --- CreateSimulation ------------------------------------------------------------------------- + TEST_F(Test_DashboardSystemServiceClient, CreateSimulation_Success) { - // Arrange - EXPECT_CALL(*_mockObjectMapper, write); - oatpp::String actualPath; - oatpp::String actualMethod; - SetupExecuteRequest(Status::CODE_201, - [&actualPath, &actualMethod](auto currentMethod, auto pathTemplate, auto map) { - actualMethod = currentMethod; - actualPath = pathTemplate.format(map); - }); - EXPECT_CALL(*_mockBodyDecoder, decode); - auto expectedResponse = SimulationCreationResponseDto::createShared(); - expectedResponse->id = 123; - EXPECT_CALL(*_mockObjectMapper, read).WillOnce(Return(expectedResponse)); - EXPECT_CALL(_dummyLogger, ProcessLoggerMessage(Services::Logging::ALoggerMessageWith( - Services::Logging::Level::Debug, "Dashboard: creating simulation returned 201"))); - - // Act - oatpp::Object response; - { - const auto service = CreateService(); - auto request = SimulationCreationRequestDto::createShared(); - response = service->CreateSimulation(request); - } + EXPECT_CALL(*_mockHttpClient, Post("system-service/v1.0/simulations", _)) + .WillOnce(Return(Responded(201, R"({"id":123})"))); + ExpectLog(Services::Logging::Level::Debug, "Dashboard: creating simulation returned 201"); - // Assert - ASSERT_EQ(response, expectedResponse); - ASSERT_STREQ(actualMethod->c_str(), "POST"); - ASSERT_STREQ(actualPath->c_str(), "system-service/v1.0/simulations"); + const auto service = CreateService(); + const auto simulationId = service->CreateSimulation(SimulationCreationRequestDto{}); + + ASSERT_TRUE(simulationId.has_value()); + EXPECT_EQ(*simulationId, 123u); } -TEST_F(Test_DashboardSystemServiceClient, CreateSimulation_Failure) +TEST_F(Test_DashboardSystemServiceClient, CreateSimulation_ServerError) { - // Arrange - EXPECT_CALL(*_mockObjectMapper, write); - oatpp::String actualPath; - oatpp::String actualMethod; - SetupExecuteRequest(Status::CODE_500, - [&actualPath, &actualMethod](auto currentMethod, auto pathTemplate, auto map) { - actualMethod = currentMethod; - actualPath = pathTemplate.format(map); - }); - EXPECT_CALL(_dummyLogger, ProcessLoggerMessage(Services::Logging::ALoggerMessageWith( - Services::Logging::Level::Error, "Dashboard: creating simulation returned 500"))); - - // Act - oatpp::Object response; - { - const auto service = CreateService(); - auto request = SimulationCreationRequestDto::createShared(); - service->CreateSimulation(request); - } + EXPECT_CALL(*_mockHttpClient, Post("system-service/v1.0/simulations", _)).WillOnce(Return(Responded(500))); + ExpectLog(Services::Logging::Level::Error, "Dashboard: creating simulation returned 500"); + + const auto service = CreateService(); + + EXPECT_FALSE(service->CreateSimulation(SimulationCreationRequestDto{}).has_value()); +} + +TEST_F(Test_DashboardSystemServiceClient, CreateSimulation_TransportFailure_LogsServerUnavailable) +{ + EXPECT_CALL(*_mockHttpClient, Post(_, _)).WillOnce(Return(Unavailable())); + ExpectLog(Services::Logging::Level::Error, "Dashboard: creating simulation server unavailable"); + + const auto service = CreateService(); + + EXPECT_FALSE(service->CreateSimulation(SimulationCreationRequestDto{}).has_value()); +} + +// A 201 whose body we cannot read is treated as "no id", not as a hard failure. +TEST_F(Test_DashboardSystemServiceClient, CreateSimulation_UnparsableBody) +{ + EXPECT_CALL(*_mockHttpClient, Post(_, _)).WillOnce(Return(Responded(201, "not json"))); + ExpectLog(Services::Logging::Level::Debug, "Dashboard: creating simulation returned 201"); + + const auto service = CreateService(); + + EXPECT_FALSE(service->CreateSimulation(SimulationCreationRequestDto{}).has_value()); +} + +TEST_F(Test_DashboardSystemServiceClient, CreateSimulation_SendsTheSerializedRequest) +{ + SimulationCreationRequestDto request{}; + request.started = 17; + request.configuration.connectUri = "silkit://localhost:8500"; + + std::string actualBody; + EXPECT_CALL(*_mockHttpClient, Post(Paths::CreateSimulation(), _)) + .WillOnce(DoAll(SaveArg<1>(&actualBody), Return(Responded(201, R"({"id":1})")))); + ExpectLog(Services::Logging::Level::Debug, "Dashboard: creating simulation returned 201"); + + const auto service = CreateService(); + service->CreateSimulation(request); + + EXPECT_EQ(actualBody, ToJson(request)); + EXPECT_EQ(actualBody, + "{\"started\": 17,\"configuration\": {\"connectUri\": \"silkit://localhost:8500\"}}"); +} + +// --- UpdateSimulation ------------------------------------------------------------------------- + +TEST_F(Test_DashboardSystemServiceClient, UpdateSimulation_UsesTheSimulationIdInThePath) +{ + std::string actualBody; + EXPECT_CALL(*_mockHttpClient, Post("system-service/v1.1/simulations/456", _)) + .WillOnce(DoAll(SaveArg<1>(&actualBody), Return(Responded(200)))); + ExpectLog(Services::Logging::Level::Debug, "Dashboard: updating simulation returned 200"); + + const auto service = CreateService(); + service->UpdateSimulation(456, BulkSimulationDto{}); + + EXPECT_EQ(actualBody, ToJson(BulkSimulationDto{})); +} + +TEST_F(Test_DashboardSystemServiceClient, UpdateSimulation_TransportFailure) +{ + EXPECT_CALL(*_mockHttpClient, Post(_, _)).WillOnce(Return(Unavailable())); + ExpectLog(Services::Logging::Level::Error, "Dashboard: updating simulation server unavailable"); + + const auto service = CreateService(); + service->UpdateSimulation(1, BulkSimulationDto{}); +} + +// --- UpdateSimulationMetrics ------------------------------------------------------------------ + +TEST_F(Test_DashboardSystemServiceClient, UpdateSimulationMetrics_UsesTheMetricsPath) +{ + std::string actualBody; + EXPECT_CALL(*_mockHttpClient, Post("system-service/v1.1/simulations/789/metrics", _)) + .WillOnce(DoAll(SaveArg<1>(&actualBody), Return(Responded(200)))); + ExpectLog(Services::Logging::Level::Debug, "Dashboard: updating simulation metrics returned 200"); + + const auto service = CreateService(); + service->UpdateSimulationMetrics(789, MetricsUpdateDto{}); + + EXPECT_EQ(actualBody, ToJson(MetricsUpdateDto{})); +} + +// --- CheckBulkUpdateSupported ----------------------------------------------------------------- + +/*! Probes the bulk endpoint with simulation id 0 and an empty payload, and deliberately does not + * log: the previous implementation issued this request through the raw api client, bypassing the + * logging wrapper, and the caller reports the outcome instead. + */ +TEST_F(Test_DashboardSystemServiceClient, CheckBulkUpdateSupported_SuccessStatusMeansSupported) +{ + std::string actualBody; + EXPECT_CALL(*_mockHttpClient, Post("system-service/v1.1/simulations/0", _)) + .WillOnce(DoAll(SaveArg<1>(&actualBody), Return(Responded(200)))); + + const auto service = CreateService(); + + EXPECT_TRUE(service->CheckBulkUpdateSupported()); + EXPECT_EQ(actualBody, "{\"stopped\": null,\"system\": {\"statuses\": []},\"participants\": []}"); +} + +TEST_F(Test_DashboardSystemServiceClient, CheckBulkUpdateSupported_NonSuccessStatusMeansUnsupported) +{ + EXPECT_CALL(*_mockHttpClient, Post(_, _)).WillOnce(Return(Responded(404))); + + const auto service = CreateService(); + + EXPECT_FALSE(service->CheckBulkUpdateSupported()); +} + +TEST_F(Test_DashboardSystemServiceClient, CheckBulkUpdateSupported_TransportFailureMeansUnsupported) +{ + EXPECT_CALL(*_mockHttpClient, Post(_, _)).WillOnce(Return(Unavailable())); + + const auto service = CreateService(); - // Assert - ASSERT_TRUE(response == nullptr); - ASSERT_STREQ(actualMethod->c_str(), "POST"); - ASSERT_STREQ(actualPath->c_str(), "system-service/v1.0/simulations"); + EXPECT_FALSE(service->CheckBulkUpdateSupported()); } +} // namespace } // namespace Dashboard } // namespace SilKit diff --git a/SilKit/source/dashboard/dto/BulkUpdateDto.hpp b/SilKit/source/dashboard/dto/BulkUpdateDto.hpp index 51af4705c..8f0339bb8 100644 --- a/SilKit/source/dashboard/dto/BulkUpdateDto.hpp +++ b/SilKit/source/dashboard/dto/BulkUpdateDto.hpp @@ -4,98 +4,85 @@ #pragma once -#include "dashboard/dto/RpcSpecDto.hpp" +#include +#include +#include +#include + #include "dashboard/dto/DataSpecDto.hpp" #include "dashboard/dto/ParticipantStatusDto.hpp" +#include "dashboard/dto/RpcSpecDto.hpp" #include "dashboard/dto/SystemStatusDto.hpp" -#include OATPP_CODEGEN_BEGIN(DTO) +// NB: field declaration order below fixes the order of the emitted JSON keys, so do not reorder +// members without checking Test_DashboardJsonWriter. namespace SilKit { namespace Dashboard { -class BulkSystemDto : public oatpp::DTO +struct BulkSystemDto { - DTO_INIT(BulkSystemDto, DTO) - - DTO_FIELD(Vector>, statuses) = Vector>::createShared(); + std::vector statuses; }; -class BulkControllerDto : public oatpp::DTO +struct BulkControllerDto { - DTO_INIT(BulkControllerDto, DTO) - - DTO_FIELD(UInt64, id); - DTO_FIELD(String, name); - DTO_FIELD(String, networkName); + uint64_t id{}; + std::string name; + std::string networkName; }; -class BulkDataServiceDto : public oatpp::DTO +struct BulkDataServiceDto { - DTO_INIT(BulkDataServiceDto, DTO) - - DTO_FIELD(UInt64, id); - DTO_FIELD(String, name); - DTO_FIELD(String, networkName); - DTO_FIELD(Object, spec) = Object::createShared(); + uint64_t id{}; + std::string name; + std::string networkName; + DataSpecDto spec; }; -class BulkRpcServiceDto : public oatpp::DTO +struct BulkRpcServiceDto { - DTO_INIT(BulkRpcServiceDto, DTO) - - DTO_FIELD(UInt64, id); - DTO_FIELD(String, name); - DTO_FIELD(String, networkName); - DTO_FIELD(Object, spec) = Object::createShared(); + uint64_t id{}; + std::string name; + std::string networkName; + RpcSpecDto spec; }; -class BulkServiceInternalDto : public oatpp::DTO +struct BulkServiceInternalDto { - DTO_INIT(BulkServiceInternalDto, DTO) - - DTO_FIELD(UInt64, id); - DTO_FIELD(String, name); - DTO_FIELD(String, networkName); - DTO_FIELD(UInt64, parentId); + uint64_t id{}; + std::string name; + std::string networkName; + uint64_t parentId{}; }; -class BulkParticipantDto : public oatpp::DTO +struct BulkParticipantDto { - DTO_INIT(BulkParticipantDto, DTO) - - DTO_FIELD(String, name); - DTO_FIELD(Vector>, statuses) = Vector>::createShared(); - DTO_FIELD(Vector>, canControllers) = Vector>::createShared(); - DTO_FIELD(Vector>, - ethernetControllers) = Vector>::createShared(); - DTO_FIELD(Vector>, - flexrayControllers) = Vector>::createShared(); - DTO_FIELD(Vector>, linControllers) = Vector>::createShared(); - DTO_FIELD(Vector>, dataPublishers) = Vector>::createShared(); - DTO_FIELD(Vector>, dataSubscribers) = Vector>::createShared(); - DTO_FIELD(Vector>, - dataSubscriberInternals) = Vector>::createShared(); - DTO_FIELD(Vector>, rpcClients) = Vector>::createShared(); - DTO_FIELD(Vector>, rpcServers) = Vector>::createShared(); - DTO_FIELD(Vector>, - rpcServerInternals) = Vector>::createShared(); - DTO_FIELD(Vector, canNetworks) = Vector::createShared(); - DTO_FIELD(Vector, ethernetNetworks) = Vector::createShared(); - DTO_FIELD(Vector, flexrayNetworks) = Vector::createShared(); - DTO_FIELD(Vector, linNetworks) = Vector::createShared(); + std::string name; + std::vector statuses; + std::vector canControllers; + std::vector ethernetControllers; + std::vector flexrayControllers; + std::vector linControllers; + std::vector dataPublishers; + std::vector dataSubscribers; + std::vector dataSubscriberInternals; + std::vector rpcClients; + std::vector rpcServers; + std::vector rpcServerInternals; + std::vector canNetworks; + std::vector ethernetNetworks; + std::vector flexrayNetworks; + std::vector linNetworks; }; -class BulkSimulationDto : public oatpp::DTO +struct BulkSimulationDto { - DTO_INIT(BulkSimulationDto, DTO) - - DTO_FIELD(Int64, stopped); - DTO_FIELD(Object, system) = Object::createShared(); - DTO_FIELD(Vector>, participants) = Vector>::createShared(); + //! Absent until the simulation stops; emitted as JSON null while unset. + std::optional stopped; + BulkSystemDto system; + std::vector participants; }; } // namespace Dashboard } // namespace SilKit - -#include OATPP_CODEGEN_END(DTO) diff --git a/SilKit/source/dashboard/dto/DataSpecDto.hpp b/SilKit/source/dashboard/dto/DataSpecDto.hpp index fb4671245..1bc163493 100644 --- a/SilKit/source/dashboard/dto/DataSpecDto.hpp +++ b/SilKit/source/dashboard/dto/DataSpecDto.hpp @@ -4,37 +4,20 @@ #pragma once -#include "dashboard/dto/MatchingLabelDto.hpp" +#include +#include -#include OATPP_CODEGEN_BEGIN(DTO) +#include "dashboard/dto/MatchingLabelDto.hpp" namespace SilKit { namespace Dashboard { -class DataSpecDto : public oatpp::DTO +struct DataSpecDto { - DTO_INIT(DataSpecDto, DTO) - - DTO_FIELD_INFO(topic) - { - info->description = "Topic"; - } - DTO_FIELD(String, topic); - - DTO_FIELD_INFO(mediaType) - { - info->description = "Media type"; - } - DTO_FIELD(String, mediaType); - - DTO_FIELD_INFO(labels) - { - info->description = "Labels"; - } - DTO_FIELD(Vector>, labels); + std::string topic; + std::string mediaType; + std::vector labels; }; } // namespace Dashboard } // namespace SilKit - -#include OATPP_CODEGEN_END(DTO) \ No newline at end of file diff --git a/SilKit/source/dashboard/dto/MatchingLabelDto.hpp b/SilKit/source/dashboard/dto/MatchingLabelDto.hpp index 83bf9285a..9cd7d42fd 100644 --- a/SilKit/source/dashboard/dto/MatchingLabelDto.hpp +++ b/SilKit/source/dashboard/dto/MatchingLabelDto.hpp @@ -4,41 +4,40 @@ #pragma once -#include "dashboard/OatppHeaders.hpp" +#include +#include +#include -#include OATPP_CODEGEN_BEGIN(DTO) +#include "silkit/participant/exception.hpp" namespace SilKit { namespace Dashboard { -ENUM(LabelKind, v_int32, // - VALUE(Optional, 1, "optional"), // - VALUE(Mandatory, 2, "mandatory")) - -class MatchingLabelDto : public oatpp::DTO +//! Wire representation of SilKit::Services::MatchingLabel::Kind. Serialized as its name. +enum class LabelKind : int32_t { - DTO_INIT(MatchingLabelDto, DTO) - - DTO_FIELD_INFO(key) - { - info->description = "Key of the label"; - } - DTO_FIELD(String, key); + Optional = 1, + Mandatory = 2, +}; - DTO_FIELD_INFO(value) +inline auto ToStringView(LabelKind kind) -> std::string_view +{ + switch (kind) { - info->description = "Value of the label"; + case LabelKind::Optional: + return "optional"; + case LabelKind::Mandatory: + return "mandatory"; } - DTO_FIELD(String, value); + throw SilKitError{"Dashboard: invalid LabelKind"}; +} - DTO_FIELD_INFO(kind) - { - info->description = "Kind of the label"; - } - DTO_FIELD(Enum::AsString, kind); +struct MatchingLabelDto +{ + std::string key; + std::string value; + LabelKind kind{LabelKind::Optional}; }; } // namespace Dashboard } // namespace SilKit - -#include OATPP_CODEGEN_END(DTO) \ No newline at end of file diff --git a/SilKit/source/dashboard/dto/MetricsDto.hpp b/SilKit/source/dashboard/dto/MetricsDto.hpp index 7c214f87e..5e7bcd726 100644 --- a/SilKit/source/dashboard/dto/MetricsDto.hpp +++ b/SilKit/source/dashboard/dto/MetricsDto.hpp @@ -4,53 +4,41 @@ #pragma once - -#include OATPP_CODEGEN_BEGIN(DTO) +#include +#include +#include namespace SilKit { namespace Dashboard { -class MetricDataDto : public oatpp::DTO -{ - DTO_INIT(MetricDataDto, DTO) - - DTO_FIELD(Int64, ts); - DTO_FIELD(String, pn); - DTO_FIELD(Vector, mn) = Vector::createShared(); -}; - -class AttributeDataDto : public MetricDataDto -{ - DTO_INIT(AttributeDataDto, MetricDataDto) - - DTO_FIELD(String, mv); -}; - -class CounterDataDto : public MetricDataDto -{ - DTO_INIT(CounterDataDto, MetricDataDto) - - DTO_FIELD(Int64, mv); -}; - -class StatisticDataDto : public MetricDataDto +/*! One metric sample: timestamp, participant name, split metric name, and the value. + * + * The three concrete metric kinds differ only in the type of `mv`, so they share this template. + * Field order matches what the previous oatpp DTOs emitted (base fields first, then `mv`). + */ +template +struct MetricDataDto { - DTO_INIT(StatisticDataDto, MetricDataDto) - - DTO_FIELD(Vector, mv) = Vector::createShared(); + //! Timestamp. + int64_t ts{}; + //! Participant name. + std::string pn; + //! Metric name, split on '/'. + std::vector mn; + //! Metric value. + MetricValueT mv{}; }; +using AttributeDataDto = MetricDataDto; +using CounterDataDto = MetricDataDto; +using StatisticDataDto = MetricDataDto>; -class MetricsUpdateDto : public oatpp::DTO +struct MetricsUpdateDto { - DTO_INIT(MetricsUpdateDto, oatpp::DTO) - - DTO_FIELD(Vector>, attributes) = Vector>::createShared(); - DTO_FIELD(Vector>, counters) = Vector>::createShared(); - DTO_FIELD(Vector>, statistics) = Vector>::createShared(); + std::vector attributes; + std::vector counters; + std::vector statistics; }; } // namespace Dashboard } // namespace SilKit - -#include OATPP_CODEGEN_END(DTO) diff --git a/SilKit/source/dashboard/dto/ParticipantStatusDto.hpp b/SilKit/source/dashboard/dto/ParticipantStatusDto.hpp index 1cc05d480..180d4c588 100644 --- a/SilKit/source/dashboard/dto/ParticipantStatusDto.hpp +++ b/SilKit/source/dashboard/dto/ParticipantStatusDto.hpp @@ -4,53 +4,78 @@ #pragma once -#include "dashboard/OatppHeaders.hpp" +#include +#include +#include -#include OATPP_CODEGEN_BEGIN(DTO) +#include "silkit/participant/exception.hpp" namespace SilKit { namespace Dashboard { -ENUM(ParticipantState, v_int32, // - VALUE(Unknown, -1, "unknown"), // - VALUE(Invalid, 0, "invalid"), // - VALUE(ServicesCreated, 10, "servicescreated"), // - VALUE(CommunicationInitializing, 20, "communicationinitializing"), // - VALUE(CommunicationInitialized, 30, "communicationinitialized"), // - VALUE(ReadyToRun, 40, "readytorun"), // - VALUE(Running, 50, "running"), // - VALUE(Paused, 60, "paused"), // - VALUE(Stopping, 70, "stopping"), // - VALUE(Stopped, 80, "stopped"), // - VALUE(Error, 90, "error"), // - VALUE(ShuttingDown, 100, "shuttingdown"), // - VALUE(Shutdown, 110, "shutdown"), // - VALUE(Aborting, 120, "aborting")) - -class ParticipantStatusDto : public oatpp::DTO +//! Wire representation of SilKit::Services::Orchestration::ParticipantState. Serialized as its name. +enum class ParticipantState : int32_t { - DTO_INIT(ParticipantStatusDto, DTO) - - DTO_FIELD_INFO(state) - { - info->description = "Name of the state"; - } - DTO_FIELD(Enum::AsString, state); + Unknown = -1, + Invalid = 0, + ServicesCreated = 10, + CommunicationInitializing = 20, + CommunicationInitialized = 30, + ReadyToRun = 40, + Running = 50, + Paused = 60, + Stopping = 70, + Stopped = 80, + Error = 90, + ShuttingDown = 100, + Shutdown = 110, + Aborting = 120, +}; - DTO_FIELD_INFO(enterReason) +inline auto ToStringView(ParticipantState state) -> std::string_view +{ + switch (state) { - info->description = "Reason for entering the state"; + case ParticipantState::Unknown: + return "unknown"; + case ParticipantState::Invalid: + return "invalid"; + case ParticipantState::ServicesCreated: + return "servicescreated"; + case ParticipantState::CommunicationInitializing: + return "communicationinitializing"; + case ParticipantState::CommunicationInitialized: + return "communicationinitialized"; + case ParticipantState::ReadyToRun: + return "readytorun"; + case ParticipantState::Running: + return "running"; + case ParticipantState::Paused: + return "paused"; + case ParticipantState::Stopping: + return "stopping"; + case ParticipantState::Stopped: + return "stopped"; + case ParticipantState::Error: + return "error"; + case ParticipantState::ShuttingDown: + return "shuttingdown"; + case ParticipantState::Shutdown: + return "shutdown"; + case ParticipantState::Aborting: + return "aborting"; } - DTO_FIELD(String, enterReason); + throw SilKitError{"Dashboard: invalid ParticipantState"}; +} - DTO_FIELD_INFO(enterTime) - { - info->description = "Time when state got entered"; - } - DTO_FIELD(UInt64, enterTime); +struct ParticipantStatusDto +{ + ParticipantState state{ParticipantState::Invalid}; + //! Reason for entering the state. + std::string enterReason; + //! Time when the state was entered. + uint64_t enterTime{}; }; } // namespace Dashboard } // namespace SilKit - -#include OATPP_CODEGEN_END(DTO) \ No newline at end of file diff --git a/SilKit/source/dashboard/dto/RpcSpecDto.hpp b/SilKit/source/dashboard/dto/RpcSpecDto.hpp index 5149aa938..99959ff94 100644 --- a/SilKit/source/dashboard/dto/RpcSpecDto.hpp +++ b/SilKit/source/dashboard/dto/RpcSpecDto.hpp @@ -4,37 +4,20 @@ #pragma once -#include "dashboard/dto/MatchingLabelDto.hpp" +#include +#include -#include OATPP_CODEGEN_BEGIN(DTO) +#include "dashboard/dto/MatchingLabelDto.hpp" namespace SilKit { namespace Dashboard { -class RpcSpecDto : public oatpp::DTO +struct RpcSpecDto { - DTO_INIT(RpcSpecDto, DTO) - - DTO_FIELD_INFO(functionName) - { - info->description = "Function name"; - } - DTO_FIELD(String, functionName); - - DTO_FIELD_INFO(mediaType) - { - info->description = "Media type"; - } - DTO_FIELD(String, mediaType); - - DTO_FIELD_INFO(labels) - { - info->description = "Labels"; - } - DTO_FIELD(Vector>, labels); + std::string functionName; + std::string mediaType; + std::vector labels; }; } // namespace Dashboard } // namespace SilKit - -#include OATPP_CODEGEN_END(DTO) \ No newline at end of file diff --git a/SilKit/source/dashboard/dto/ServiceDto.hpp b/SilKit/source/dashboard/dto/ServiceDto.hpp deleted file mode 100644 index c51110165..000000000 --- a/SilKit/source/dashboard/dto/ServiceDto.hpp +++ /dev/null @@ -1,34 +0,0 @@ -// SPDX-FileCopyrightText: 2022 Vector Informatik GmbH -// -// SPDX-License-Identifier: MIT - -#pragma once - -#include "dashboard/OatppHeaders.hpp" - -#include OATPP_CODEGEN_BEGIN(DTO) - -namespace SilKit { -namespace Dashboard { - -class ServiceDto : public oatpp::DTO -{ - DTO_INIT(ServiceDto, DTO) - - DTO_FIELD_INFO(name) - { - info->description = "Name of the service"; - } - DTO_FIELD(String, name); - - DTO_FIELD_INFO(networkName) - { - info->description = "Name of the network"; - } - DTO_FIELD(String, networkName); -}; - -} // namespace Dashboard -} // namespace SilKit - -#include OATPP_CODEGEN_END(DTO) \ No newline at end of file diff --git a/SilKit/source/dashboard/dto/SimulationConfigurationDto.hpp b/SilKit/source/dashboard/dto/SimulationConfigurationDto.hpp index 67da154d6..ffee03c82 100644 --- a/SilKit/source/dashboard/dto/SimulationConfigurationDto.hpp +++ b/SilKit/source/dashboard/dto/SimulationConfigurationDto.hpp @@ -4,25 +4,16 @@ #pragma once -#include "dashboard/OatppHeaders.hpp" - -#include OATPP_CODEGEN_BEGIN(DTO) +#include namespace SilKit { namespace Dashboard { -class SimulationConfigurationDto : public oatpp::DTO +struct SimulationConfigurationDto { - DTO_INIT(SimulationConfigurationDto, DTO) - - DTO_FIELD_INFO(connectUri) - { - info->description = "Connect URI"; - } - DTO_FIELD(String, connectUri); + //! Connect URI of the simulation. + std::string connectUri; }; } // namespace Dashboard } // namespace SilKit - -#include OATPP_CODEGEN_END(DTO) \ No newline at end of file diff --git a/SilKit/source/dashboard/dto/SimulationCreationRequestDto.hpp b/SilKit/source/dashboard/dto/SimulationCreationRequestDto.hpp index c34814007..b6098a31c 100644 --- a/SilKit/source/dashboard/dto/SimulationCreationRequestDto.hpp +++ b/SilKit/source/dashboard/dto/SimulationCreationRequestDto.hpp @@ -4,31 +4,19 @@ #pragma once -#include "dashboard/dto/SimulationConfigurationDto.hpp" +#include -#include OATPP_CODEGEN_BEGIN(DTO) +#include "dashboard/dto/SimulationConfigurationDto.hpp" namespace SilKit { namespace Dashboard { -class SimulationCreationRequestDto : public oatpp::DTO +struct SimulationCreationRequestDto { - DTO_INIT(SimulationCreationRequestDto, DTO) - - DTO_FIELD_INFO(started) - { - info->description = "Time when simulation started"; - } - DTO_FIELD(UInt64, started); - - DTO_FIELD_INFO(configuration) - { - info->description = "Configuration of the simulation"; - } - DTO_FIELD(Object, configuration); + //! Time when the simulation started. + uint64_t started{}; + SimulationConfigurationDto configuration; }; } // namespace Dashboard } // namespace SilKit - -#include OATPP_CODEGEN_END(DTO) \ No newline at end of file diff --git a/SilKit/source/dashboard/dto/SimulationCreationResponseDto.hpp b/SilKit/source/dashboard/dto/SimulationCreationResponseDto.hpp deleted file mode 100644 index 1fd8033e3..000000000 --- a/SilKit/source/dashboard/dto/SimulationCreationResponseDto.hpp +++ /dev/null @@ -1,28 +0,0 @@ -// SPDX-FileCopyrightText: 2022 Vector Informatik GmbH -// -// SPDX-License-Identifier: MIT - -#pragma once - -#include "dashboard/OatppHeaders.hpp" - -#include OATPP_CODEGEN_BEGIN(DTO) - -namespace SilKit { -namespace Dashboard { - -class SimulationCreationResponseDto : public oatpp::DTO -{ - DTO_INIT(SimulationCreationResponseDto, DTO) - - DTO_FIELD_INFO(id) - { - info->description = "Id"; - } - DTO_FIELD(UInt64, id); -}; - -} // namespace Dashboard -} // namespace SilKit - -#include OATPP_CODEGEN_END(DTO) \ No newline at end of file diff --git a/SilKit/source/dashboard/dto/SystemStatusDto.hpp b/SilKit/source/dashboard/dto/SystemStatusDto.hpp index 13155f405..060e9305a 100644 --- a/SilKit/source/dashboard/dto/SystemStatusDto.hpp +++ b/SilKit/source/dashboard/dto/SystemStatusDto.hpp @@ -4,41 +4,73 @@ #pragma once -#include "dashboard/OatppHeaders.hpp" +#include +#include -#include OATPP_CODEGEN_BEGIN(DTO) +#include "silkit/participant/exception.hpp" namespace SilKit { namespace Dashboard { -ENUM(SystemState, v_int32, // - VALUE(Unknown, -1, "unknown"), // - VALUE(Invalid, 0, "invalid"), // - VALUE(ServicesCreated, 10, "servicescreated"), // - VALUE(CommunicationInitializing, 20, "communicationinitializing"), // - VALUE(CommunicationInitialized, 30, "communicationinitialized"), // - VALUE(ReadyToRun, 40, "readytorun"), // - VALUE(Running, 50, "running"), // - VALUE(Paused, 60, "paused"), // - VALUE(Stopping, 70, "stopping"), // - VALUE(Stopped, 80, "stopped"), // - VALUE(Error, 90, "error"), // - VALUE(ShuttingDown, 100, "shuttingdown"), // - VALUE(Shutdown, 110, "shutdown"), // - VALUE(Aborting, 120, "aborting")) - -class SystemStatusDto : public oatpp::DTO +//! Wire representation of SilKit::Services::Orchestration::SystemState. Serialized as its name. +enum class SystemState : int32_t { - DTO_INIT(SystemStatusDto, DTO) + Unknown = -1, + Invalid = 0, + ServicesCreated = 10, + CommunicationInitializing = 20, + CommunicationInitialized = 30, + ReadyToRun = 40, + Running = 50, + Paused = 60, + Stopping = 70, + Stopped = 80, + Error = 90, + ShuttingDown = 100, + Shutdown = 110, + Aborting = 120, +}; - DTO_FIELD_INFO(state) +inline auto ToStringView(SystemState state) -> std::string_view +{ + switch (state) { - info->description = "Name of the state"; + case SystemState::Unknown: + return "unknown"; + case SystemState::Invalid: + return "invalid"; + case SystemState::ServicesCreated: + return "servicescreated"; + case SystemState::CommunicationInitializing: + return "communicationinitializing"; + case SystemState::CommunicationInitialized: + return "communicationinitialized"; + case SystemState::ReadyToRun: + return "readytorun"; + case SystemState::Running: + return "running"; + case SystemState::Paused: + return "paused"; + case SystemState::Stopping: + return "stopping"; + case SystemState::Stopped: + return "stopped"; + case SystemState::Error: + return "error"; + case SystemState::ShuttingDown: + return "shuttingdown"; + case SystemState::Shutdown: + return "shutdown"; + case SystemState::Aborting: + return "aborting"; } - DTO_FIELD(Enum::AsString, state); + throw SilKitError{"Dashboard: invalid SystemState"}; +} + +struct SystemStatusDto +{ + SystemState state{SystemState::Invalid}; }; } // namespace Dashboard } // namespace SilKit - -#include OATPP_CODEGEN_END(DTO) \ No newline at end of file diff --git a/SilKit/source/dashboard/http/AsioHttpClient.cpp b/SilKit/source/dashboard/http/AsioHttpClient.cpp new file mode 100644 index 000000000..59fb61332 --- /dev/null +++ b/SilKit/source/dashboard/http/AsioHttpClient.cpp @@ -0,0 +1,540 @@ +// SPDX-FileCopyrightText: 2026 Vector Informatik GmbH +// +// SPDX-License-Identifier: MIT + +#include "dashboard/http/AsioHttpClient.hpp" + +#include +#include +#include + +#include "asio/connect.hpp" +#include "asio/io_context.hpp" +#include "asio/ip/tcp.hpp" +#include "asio/post.hpp" +#include "asio/read.hpp" +#include "asio/read_until.hpp" +#include "asio/streambuf.hpp" +#include "asio/write.hpp" + +#include "core/internal/traits/SilKitLoggingTraits.hpp" +#include "services/logging/LoggerMessage.hpp" + +using SilKit::Services::Logging::Level; +using SilKit::Services::Logging::LoggerMessage; + +namespace VSilKit { + +namespace { + +//! Sentinel no asio operation ever completes with, so it doubles as "still pending". +const std::error_code kPending = asio::error::would_block; + +//! How often the deadline loop wakes to notice an Abort(); also bounds abort latency. +constexpr auto kPollInterval = std::chrono::milliseconds{50}; + +constexpr size_t kMaxHeadSize = 64 * 1024; + +} // namespace + +struct AsioHttpClient::Impl +{ + SilKit::Services::Logging::ILoggerInternal* logger; + std::string host; + uint16_t port; + std::string hostHeader; + AsioHttpClientTimeouts timeouts; + + asio::io_context ioContext{1}; + std::optional socket; + asio::streambuf readBuffer; + std::vector endpoints; + std::chrono::steady_clock::time_point lastUse{}; + std::atomic aborted{false}; + + Impl(SilKit::Services::Logging::ILoggerInternal* logger_, std::string host_, uint16_t port_, + AsioHttpClientTimeouts timeouts_) + : logger{logger_} + , host{std::move(host_)} + , port{port_} + , hostHeader{host + ":" + std::to_string(port_)} + , timeouts{timeouts_} + { + } + + void Log(Level level, const std::string& message) const + { + if (logger == nullptr) + { + return; + } + logger->MakeMessage(level, SilKit::Core::SilKitTopicTrait::Topic()) + .SetMessage("Dashboard: {}", message) + .Dispatch(); + } + + void DropSocket() + { + if (socket.has_value()) + { + std::error_code ignored; + socket->cancel(ignored); + socket->close(ignored); + socket.reset(); + } + readBuffer.consume(readBuffer.size()); + } + + /*! Drive one async operation to completion under a deadline. + * + * The call site is synchronous, so instead of a timer we run the io_context in short slices and + * check the deadline and the abort flag between them. + */ + template + auto RunWithDeadline(Initiate&& initiate, std::chrono::milliseconds timeout) -> std::error_code + { + auto opError = kPending; + initiate([&opError](const std::error_code& ec) { opError = ec; }); + + ioContext.restart(); + const auto deadline = std::chrono::steady_clock::now() + timeout; + + while (opError == kPending) + { + if (aborted.load(std::memory_order_acquire)) + { + return DrainAndFail(asio::error::operation_aborted); + } + const auto remaining = deadline - std::chrono::steady_clock::now(); + if (remaining <= std::chrono::steady_clock::duration::zero()) + { + return DrainAndFail(asio::error::timed_out); + } + ioContext.run_one_for(std::min(remaining, kPollInterval)); + } + return opError; + } + + /*! Cancel the pending operation and let its handler run. + * + * Without draining, the handler would later write through a dangling reference to the caller's + * stack-allocated error_code. + */ + auto DrainAndFail(std::error_code reason) -> std::error_code + { + DropSocket(); + ioContext.restart(); + ioContext.run(); + return reason; + } + + auto Resolve() -> std::error_code + { + if (!endpoints.empty()) + { + return {}; + } + // Resolved once and cached: the dashboard URI never changes, and a hung getaddrinfo cannot + // be reliably cancelled, so we want to be exposed to it at most once. + asio::ip::tcp::resolver resolver{ioContext}; + asio::ip::tcp::resolver::results_type results; + const auto ec = RunWithDeadline( + [&](auto handler) { + resolver.async_resolve(host, std::to_string(port), + [handler, &results](const std::error_code& e, + asio::ip::tcp::resolver::results_type r) { + if (!e) + { + results = std::move(r); + } + handler(e); + }); + }, + timeouts.connect); + if (ec) + { + return ec; + } + for (const auto& entry : results) + { + endpoints.push_back(entry.endpoint()); + } + if (endpoints.empty()) + { + return asio::error::host_not_found; + } + return {}; + } + + //! Ensures a usable socket. `reused` reports whether an existing connection was kept. + auto EnsureConnected(bool& reused) -> std::error_code + { + reused = false; + + if (socket.has_value()) + { + const auto age = std::chrono::steady_clock::now() - lastUse; + if (age > timeouts.idle) + { + DropSocket(); // likely already closed by the server's idle timeout + } + else + { + reused = true; + return {}; + } + } + + if (const auto ec = Resolve()) + { + return ec; + } + + socket.emplace(ioContext); + const auto ec = RunWithDeadline( + [&](auto handler) { + asio::async_connect(*socket, endpoints, + [handler](const std::error_code& e, const asio::ip::tcp::endpoint&) { + handler(e); + }); + }, + timeouts.connect); + if (ec) + { + DropSocket(); + return ec; + } + + std::error_code ignored; + socket->set_option(asio::ip::tcp::no_delay{true}, ignored); // small request/response exchanges + + return {}; + } + + auto BuildRequest(const std::string& path, const std::string& body) const -> std::string + { + // The same header set oatpp sent, so the dashboard sees an equivalent request. + std::string request; + request.reserve(body.size() + 256); + request += "POST /"; + request += path; + request += " HTTP/1.1\r\nHost: "; + request += hostHeader; + request += "\r\nConnection: keep-alive\r\nContent-Type: application/json\r\nContent-Length: "; + request += std::to_string(body.size()); + request += "\r\n\r\n"; + request += body; + return request; + } + + auto WriteAll(const std::string& request) -> std::error_code + { + return RunWithDeadline( + [&](auto handler) { + asio::async_write(*socket, asio::buffer(request), + [handler](const std::error_code& e, size_t) { handler(e); }); + }, + timeouts.write); + } + + //! Reads up to and including the blank line terminating the response head. + auto ReadHead(std::string& head) -> std::error_code + { + size_t headSize = 0; + const auto ec = RunWithDeadline( + [&](auto handler) { + asio::async_read_until(*socket, readBuffer, "\r\n\r\n", + [handler, &headSize](const std::error_code& e, size_t n) { + headSize = n; + handler(e); + }); + }, + timeouts.read); + if (ec) + { + return ec; + } + if (headSize > kMaxHeadSize) + { + return asio::error::message_size; + } + const auto* data = asio::buffer_cast(readBuffer.data()); + head.assign(data, headSize); + readBuffer.consume(headSize); + return {}; + } + + //! Reads exactly `count` bytes of body, serving from the buffer first. + auto ReadExactly(uint64_t count, std::string& out) -> std::error_code + { + if (count > kMaxHttpBodySize) + { + return asio::error::message_size; + } + const auto wanted = static_cast(count); + if (readBuffer.size() < wanted) + { + const auto missing = wanted - readBuffer.size(); + const auto ec = RunWithDeadline( + [&](auto handler) { + asio::async_read(*socket, readBuffer, asio::transfer_exactly(missing), + [handler](const std::error_code& e, size_t) { handler(e); }); + }, + timeouts.read); + if (ec) + { + return ec; + } + } + const auto* data = asio::buffer_cast(readBuffer.data()); + out.append(data, wanted); + readBuffer.consume(wanted); + return {}; + } + + //! Reads one CRLF-terminated line, serving from the buffer first. + auto ReadLine(std::string& line) -> std::error_code + { + size_t lineSize = 0; + const auto ec = RunWithDeadline( + [&](auto handler) { + asio::async_read_until(*socket, readBuffer, "\r\n", + [handler, &lineSize](const std::error_code& e, size_t n) { + lineSize = n; + handler(e); + }); + }, + timeouts.read); + if (ec) + { + return ec; + } + const auto* data = asio::buffer_cast(readBuffer.data()); + line.assign(data, lineSize >= 2 ? lineSize - 2 : 0); // strip CRLF + readBuffer.consume(lineSize); + return {}; + } + + auto ReadChunkedBody(std::string& out) -> std::error_code + { + for (;;) + { + std::string sizeLine; + if (const auto ec = ReadLine(sizeLine)) + { + return ec; + } + uint64_t chunkSize = 0; + if (!ParseChunkSize(sizeLine, chunkSize)) + { + return asio::error::invalid_argument; + } + if (chunkSize == 0) + { + break; + } + if (out.size() + chunkSize > kMaxHttpBodySize) + { + return asio::error::message_size; + } + if (const auto ec = ReadExactly(chunkSize, out)) + { + return ec; + } + std::string crlf; + if (const auto ec = ReadLine(crlf)) // the CRLF after the chunk data + { + return ec; + } + if (!crlf.empty()) + { + return asio::error::invalid_argument; + } + } + // Consume trailers up to the terminating blank line. + for (;;) + { + std::string trailer; + if (const auto ec = ReadLine(trailer)) + { + return ec; + } + if (trailer.empty()) + { + return {}; + } + } + } + + auto ReadUntilClose(std::string& out) -> std::error_code + { + const auto ec = RunWithDeadline( + [&](auto handler) { + asio::async_read(*socket, readBuffer, + [handler](const std::error_code& e, size_t) { handler(e); }); + }, + timeouts.read); + if (ec && ec != asio::error::eof) + { + return ec; + } + if (readBuffer.size() > kMaxHttpBodySize) + { + return asio::error::message_size; + } + const auto* data = asio::buffer_cast(readBuffer.data()); + out.append(data, readBuffer.size()); + readBuffer.consume(readBuffer.size()); + return {}; + } + + //! Reads a full response. `receivedAnything` distinguishes a stale keep-alive from a real error. + auto ReadResponse(ResponseHead& head, HttpResult& result, bool& receivedAnything) -> std::error_code + { + for (;;) // loop to skip 1xx interim responses + { + std::string rawHead; + if (const auto ec = ReadHead(rawHead)) + { + return ec; + } + receivedAnything = true; + + if (!ParseResponseHead(rawHead, head)) + { + return asio::error::invalid_argument; + } + if (!head.interim) + { + break; + } + } + + switch (head.framing) + { + case HttpBodyFraming::None: + break; + case HttpBodyFraming::ContentLength: + if (const auto ec = ReadExactly(head.contentLength, result.body)) + { + return ec; + } + break; + case HttpBodyFraming::Chunked: + if (const auto ec = ReadChunkedBody(result.body)) + { + return ec; + } + break; + case HttpBodyFraming::UntilClose: + if (const auto ec = ReadUntilClose(result.body)) + { + return ec; + } + // The framing relies on the close, so the socket cannot be reused. + head.connectionClose = true; + break; + } + return {}; + } +}; + +AsioHttpClient::AsioHttpClient(SilKit::Services::Logging::ILoggerInternal* logger, std::string host, uint16_t port, + AsioHttpClientTimeouts timeouts) + : _impl{std::make_unique(logger, std::move(host), port, timeouts)} +{ +} + +AsioHttpClient::~AsioHttpClient() +{ + _impl->aborted.store(true, std::memory_order_release); + _impl->DropSocket(); +} + +auto AsioHttpClient::Post(const std::string& path, const std::string& jsonBody) -> HttpResult +try +{ + if (_impl->aborted.load(std::memory_order_acquire)) + { + return HttpResult{}; + } + + // Two passes at most: a reused socket may have been closed by the server's idle timeout + // between requests, which must not surface as "server unavailable". + for (int attempt = 0; attempt < 2; ++attempt) + { + bool reused = false; + if (const auto ec = _impl->EnsureConnected(reused)) + { + _impl->Log(Level::Debug, "connect to " + _impl->hostHeader + " failed: " + ec.message()); + return HttpResult{}; + } + + const auto request = _impl->BuildRequest(path, jsonBody); + if (const auto ec = _impl->WriteAll(request)) + { + _impl->DropSocket(); + if (reused && attempt == 0 && !_impl->aborted.load(std::memory_order_acquire)) + { + continue; // stale keep-alive: reconnect and try once more + } + _impl->Log(Level::Debug, "sending request failed: " + ec.message()); + return HttpResult{}; + } + + ResponseHead head{}; + HttpResult result{}; + bool receivedAnything = false; + if (const auto ec = _impl->ReadResponse(head, result, receivedAnything)) + { + _impl->DropSocket(); + if (reused && !receivedAnything && attempt == 0 && !_impl->aborted.load(std::memory_order_acquire)) + { + continue; // stale keep-alive + } + _impl->Log(Level::Debug, "reading response failed: " + ec.message()); + return HttpResult{}; + } + + if (head.connectionClose) + { + _impl->DropSocket(); + } + else + { + _impl->lastUse = std::chrono::steady_clock::now(); + } + + result.transportError = false; + result.statusCode = head.statusCode; + return result; + } + return HttpResult{}; +} +catch (const std::exception& e) +{ + // IHttpClient::Post must never throw; the dashboard worker treats this as "server unavailable". + _impl->DropSocket(); + _impl->Log(Level::Debug, std::string{"HTTP request failed: "} + e.what()); + return HttpResult{}; +} +catch (...) +{ + _impl->DropSocket(); + return HttpResult{}; +} + +void AsioHttpClient::Reset() +{ + _impl->DropSocket(); +} + +void AsioHttpClient::Abort() +{ + _impl->aborted.store(true, std::memory_order_release); + // asio sockets are not thread-safe, so the close must happen on the io_context; posting it also + // wakes a run_one_for() that is currently blocked. + asio::post(_impl->ioContext, [impl = _impl.get()] { impl->DropSocket(); }); +} + +} // namespace VSilKit diff --git a/SilKit/source/dashboard/http/AsioHttpClient.hpp b/SilKit/source/dashboard/http/AsioHttpClient.hpp new file mode 100644 index 000000000..8984d3c59 --- /dev/null +++ b/SilKit/source/dashboard/http/AsioHttpClient.hpp @@ -0,0 +1,62 @@ +// SPDX-FileCopyrightText: 2026 Vector Informatik GmbH +// +// SPDX-License-Identifier: MIT + +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "dashboard/http/HttpResponseParser.hpp" +#include "dashboard/http/IHttpClient.hpp" + +#include "services/logging/ILoggerInternal.hpp" + +// asio types are kept out of this header so that the 22k-line asio headers are confined to the +// single translation unit that implements the client. +namespace asio { +class io_context; +} + +namespace VSilKit { + +struct AsioHttpClientTimeouts +{ + std::chrono::milliseconds connect{5000}; + std::chrono::milliseconds write{5000}; + std::chrono::milliseconds read{30000}; + //! A cached connection older than this is dropped rather than reused. + std::chrono::milliseconds idle{10000}; +}; + +/*! A blocking HTTP/1.1 client over standalone asio, sized for the dashboard's three POSTs. + * + * Only one thread (the dashboard's event-queue worker) ever calls Post(), so a single keep-alive + * socket replaces the five-connection pool oatpp used. Unlike oatpp, every step has a deadline: + * oatpp set no socket timeouts at all, which let an unresponsive dashboard stall the registry. + */ +class AsioHttpClient final : public IHttpClient +{ +public: + AsioHttpClient(SilKit::Services::Logging::ILoggerInternal* logger, std::string host, uint16_t port, + AsioHttpClientTimeouts timeouts = {}); + ~AsioHttpClient() override; + + AsioHttpClient(const AsioHttpClient&) = delete; + auto operator=(const AsioHttpClient&) -> AsioHttpClient& = delete; + + auto Post(const std::string& path, const std::string& jsonBody) -> HttpResult override; + void Reset() override; + void Abort() override; + +private: + struct Impl; + std::unique_ptr _impl; +}; + +} // namespace VSilKit diff --git a/SilKit/source/dashboard/http/FakeHttpServer.hpp b/SilKit/source/dashboard/http/FakeHttpServer.hpp new file mode 100644 index 000000000..aa6db1d67 --- /dev/null +++ b/SilKit/source/dashboard/http/FakeHttpServer.hpp @@ -0,0 +1,178 @@ +// SPDX-FileCopyrightText: 2026 Vector Informatik GmbH +// +// SPDX-License-Identifier: MIT + +#pragma once + +// Test-only helper: a scripted HTTP server on an ephemeral loopback port. + +#include +#include +#include +#include +#include +#include +#include + +#include "asio/io_context.hpp" +#include "asio/ip/tcp.hpp" +#include "asio/read.hpp" +#include "asio/read_until.hpp" +#include "asio/streambuf.hpp" +#include "asio/write.hpp" + +namespace VSilKit { +namespace Tests { + +/*! A minimal scripted HTTP server for driving the dashboard's HTTP client. + * + * The handler receives the full request (head plus body) and returns the raw bytes to reply with. + * Returning an empty string makes the server go silent instead, which is how the timeout and abort + * cases are driven. + */ +class FakeHttpServer +{ +public: + using Handler = std::function; + + explicit FakeHttpServer(Handler handler, bool closeAfterReply = false) + : _handler{std::move(handler)} + , _closeAfterReply{closeAfterReply} + , _acceptor{_ioContext, asio::ip::tcp::endpoint{asio::ip::make_address("127.0.0.1"), 0}} + { + _port = _acceptor.local_endpoint().port(); + _thread = std::thread{[this] { Serve(); }}; + } + + ~FakeHttpServer() + { + _stop = true; + std::error_code ignored; + _acceptor.close(ignored); + if (_thread.joinable()) + { + _thread.join(); + } + } + + FakeHttpServer(const FakeHttpServer&) = delete; + auto operator=(const FakeHttpServer&) -> FakeHttpServer& = delete; + + auto Port() const -> uint16_t + { + return _port; + } + + auto AcceptCount() const -> int + { + return _acceptCount.load(); + } + + auto Requests() const -> std::vector + { + std::lock_guard lock{_mutex}; + return _requests; + } + + //! Convenience: a well-formed response with a Content-Length body. + static auto MakeReply(int statusCode, const std::string& body, + const std::string& extraHeaders = {}) -> std::string + { + return "HTTP/1.1 " + std::to_string(statusCode) + " Status\r\n" + extraHeaders + "Content-Length: " + + std::to_string(body.size()) + "\r\n\r\n" + body; + } + + //! Convenience: a handler that always answers with the same bytes. + static auto Always(std::string reply) -> Handler + { + return [reply = std::move(reply)](const std::string&) { return reply; }; + } + +private: + void Serve() + { + while (!_stop) + { + asio::ip::tcp::socket socket{_ioContext}; + std::error_code ec; + _acceptor.accept(socket, ec); + if (ec) + { + return; // the destructor closed the acceptor + } + ++_acceptCount; + ServeConnection(socket); + } + } + + void ServeConnection(asio::ip::tcp::socket& socket) + { + for (;;) + { + std::error_code ec; + asio::streambuf buffer; + const auto headSize = asio::read_until(socket, buffer, "\r\n\r\n", ec); + if (ec || headSize == 0) + { + return; + } + + std::string request{asio::buffer_cast(buffer.data()), headSize}; + buffer.consume(headSize); + + size_t contentLength = 0; + const auto pos = request.find("Content-Length: "); + if (pos != std::string::npos) + { + contentLength = static_cast(std::stoul(request.substr(pos + 16))); + } + if (contentLength > 0) + { + if (buffer.size() < contentLength) + { + asio::read(socket, buffer, asio::transfer_exactly(contentLength - buffer.size()), ec); + if (ec) + { + return; + } + } + request.append(asio::buffer_cast(buffer.data()), contentLength); + } + + { + std::lock_guard lock{_mutex}; + _requests.push_back(request); + } + + const auto reply = _handler(request); + if (reply.empty()) + { + // Go silent: the client must hit its read deadline, or be aborted. + while (!_stop) + { + std::this_thread::sleep_for(std::chrono::milliseconds{10}); + } + return; + } + asio::write(socket, asio::buffer(reply), ec); + if (ec || _closeAfterReply) + { + return; + } + } + } + + Handler _handler; + bool _closeAfterReply; + asio::io_context _ioContext{1}; + asio::ip::tcp::acceptor _acceptor; + uint16_t _port{0}; + std::atomic _stop{false}; + std::atomic _acceptCount{0}; + mutable std::mutex _mutex; + std::vector _requests; + std::thread _thread; +}; + +} // namespace Tests +} // namespace VSilKit diff --git a/SilKit/source/dashboard/http/HttpResponseParser.cpp b/SilKit/source/dashboard/http/HttpResponseParser.cpp new file mode 100644 index 000000000..c3664ef46 --- /dev/null +++ b/SilKit/source/dashboard/http/HttpResponseParser.cpp @@ -0,0 +1,274 @@ +// SPDX-FileCopyrightText: 2026 Vector Informatik GmbH +// +// SPDX-License-Identifier: MIT + +#include "dashboard/http/HttpResponseParser.hpp" + +#include +#include + +namespace VSilKit { + +namespace { + +auto ToLower(char c) -> char +{ + return (c >= 'A' && c <= 'Z') ? static_cast(c - 'A' + 'a') : c; +} + +auto IEquals(std::string_view a, std::string_view b) -> bool +{ + return a.size() == b.size() + && std::equal(a.begin(), a.end(), b.begin(), [](char x, char y) { return ToLower(x) == ToLower(y); }); +} + +auto TrimOws(std::string_view s) -> std::string_view +{ + const auto isOws = [](char c) { return c == ' ' || c == '\t'; }; + while (!s.empty() && isOws(s.front())) + { + s.remove_prefix(1); + } + while (!s.empty() && isOws(s.back())) + { + s.remove_suffix(1); + } + return s; +} + +//! Pop the next line, accepting both CRLF and bare LF. Returns false when nothing is left. +auto NextLine(std::string_view& rest, std::string_view& line) -> bool +{ + if (rest.empty()) + { + return false; + } + const auto lf = rest.find('\n'); + if (lf == std::string_view::npos) + { + line = rest; + rest = {}; + return true; + } + line = rest.substr(0, lf); + rest.remove_prefix(lf + 1); + if (!line.empty() && line.back() == '\r') + { + line.remove_suffix(1); + } + return true; +} + +auto ParseDecimal(std::string_view s, uint64_t& value) -> bool +{ + if (s.empty()) + { + return false; + } + uint64_t result = 0; + for (const char c : s) + { + if (c < '0' || c > '9') + { + return false; + } + const auto digit = static_cast(c - '0'); + if (result > (std::numeric_limits::max() - digit) / 10) + { + return false; // overflow + } + result = result * 10 + digit; + } + value = result; + return true; +} + +//! "HTTP/1.1 201 Created" -> 201. Strict: the version and a three-digit code are both required. +auto ParseStatusLine(std::string_view line, int& statusCode) -> bool +{ + constexpr std::string_view prefix = "HTTP/1."; + if (line.size() < prefix.size() + 1 || line.substr(0, prefix.size()) != prefix) + { + return false; + } + line.remove_prefix(prefix.size()); + if (line.empty() || line.front() < '0' || line.front() > '9') + { + return false; // minor version digit + } + line.remove_prefix(1); + if (line.empty() || line.front() != ' ') + { + return false; + } + line.remove_prefix(1); + if (line.size() < 3) + { + return false; + } + const auto code = line.substr(0, 3); + if (!std::all_of(code.begin(), code.end(), [](char c) { return c >= '0' && c <= '9'; })) + { + return false; + } + // Anything after the code must be absent or a space followed by the reason phrase. + if (line.size() > 3 && line[3] != ' ') + { + return false; + } + statusCode = (code[0] - '0') * 100 + (code[1] - '0') * 10 + (code[2] - '0'); + return true; +} + +} // namespace + +auto ParseResponseHead(std::string_view head, ResponseHead& out) -> bool +{ + out = ResponseHead{}; + + std::string_view rest = head; + std::string_view line; + if (!NextLine(rest, line) || !ParseStatusLine(line, out.statusCode)) + { + return false; + } + + bool chunked = false; + bool haveContentLength = false; + uint64_t contentLength = 0; + + while (NextLine(rest, line)) + { + if (line.empty()) + { + break; // end of head + } + // An obs-fold continuation belongs to the previous field; deprecated and never emitted by + // real servers, so skip it rather than failing. + if (line.front() == ' ' || line.front() == '\t') + { + continue; + } + const auto colon = line.find(':'); + if (colon == std::string_view::npos) + { + return false; // not a header field + } + const auto name = TrimOws(line.substr(0, colon)); + const auto value = TrimOws(line.substr(colon + 1)); + + if (IEquals(name, "content-length")) + { + uint64_t parsed = 0; + if (!ParseDecimal(value, parsed)) + { + return false; + } + if (haveContentLength && parsed != contentLength) + { + return false; // conflicting duplicates (RFC 7230 3.3.2) + } + haveContentLength = true; + contentLength = parsed; + } + else if (IEquals(name, "transfer-encoding")) + { + // We only ever need to recognise chunked; any other coding we cannot decode. + if (IEquals(value, "chunked")) + { + chunked = true; + } + else if (!value.empty() && !IEquals(value, "identity")) + { + return false; + } + } + else if (IEquals(name, "connection")) + { + if (IEquals(value, "close")) + { + out.connectionClose = true; + } + } + } + + if (out.statusCode >= 100 && out.statusCode < 200) + { + out.interim = true; + out.framing = HttpBodyFraming::None; + return true; + } + + if (out.statusCode == 204 || out.statusCode == 304) + { + out.framing = HttpBodyFraming::None; + return true; + } + + if (chunked) + { + // Chunked wins; Content-Length must be ignored (RFC 7230 3.3.3). + out.framing = HttpBodyFraming::Chunked; + return true; + } + + if (haveContentLength) + { + if (contentLength > kMaxHttpBodySize) + { + return false; + } + out.framing = HttpBodyFraming::ContentLength; + out.contentLength = contentLength; + return true; + } + + out.framing = HttpBodyFraming::UntilClose; + return true; +} + +auto ParseChunkSize(std::string_view line, uint64_t& size) -> bool +{ + // Strip any chunk extensions. + const auto semi = line.find(';'); + if (semi != std::string_view::npos) + { + line = line.substr(0, semi); + } + line = TrimOws(line); + if (line.empty()) + { + return false; + } + + uint64_t result = 0; + for (const char c : line) + { + uint64_t digit = 0; + if (c >= '0' && c <= '9') + { + digit = static_cast(c - '0'); + } + else if (c >= 'a' && c <= 'f') + { + digit = static_cast(c - 'a' + 10); + } + else if (c >= 'A' && c <= 'F') + { + digit = static_cast(c - 'A' + 10); + } + else + { + return false; + } + if (result > (std::numeric_limits::max() >> 4)) + { + return false; // overflow + } + result = (result << 4) | digit; + } + size = result; + return true; +} + +} // namespace VSilKit diff --git a/SilKit/source/dashboard/http/HttpResponseParser.hpp b/SilKit/source/dashboard/http/HttpResponseParser.hpp new file mode 100644 index 000000000..61eafeb48 --- /dev/null +++ b/SilKit/source/dashboard/http/HttpResponseParser.hpp @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: 2026 Vector Informatik GmbH +// +// SPDX-License-Identifier: MIT + +#pragma once + +#include +#include +#include + +namespace VSilKit { + +//! Upper bound on a response body we are willing to buffer. +constexpr uint64_t kMaxHttpBodySize = 1u << 20; // 1 MiB + +//! How the body of a response is framed. +enum class HttpBodyFraming +{ + None, //!< 1xx / 204 / 304: no body at all + ContentLength, //!< exactly ResponseHead::contentLength bytes follow + Chunked, //!< RFC 7230 chunked transfer coding + UntilClose, //!< neither header present: read until the peer closes +}; + +struct ResponseHead +{ + int statusCode{0}; + HttpBodyFraming framing{HttpBodyFraming::UntilClose}; + uint64_t contentLength{0}; + bool connectionClose{false}; + //! True for a 1xx interim response, which the caller must skip and read another head. + bool interim{false}; +}; + +/*! Parse a complete response head. + * + * `head` is everything up to and including the terminating blank line. Strict about the status + * line and about body framing; lenient about everything else (unknown headers are ignored, header + * names are case-insensitive, bare LF line endings are accepted, obs-fold continuation lines are + * skipped rather than rejected). + * + * Returns false if the head is malformed, in which case `out` is unspecified and the connection + * must be closed rather than reused. + */ +auto ParseResponseHead(std::string_view head, ResponseHead& out) -> bool; + +/*! Parse a chunk-size line, e.g. "1a3" or "1a3;ext=val" (without the trailing CRLF). + * + * Returns false on a missing or non-hexadecimal size, or on overflow. + */ +auto ParseChunkSize(std::string_view line, uint64_t& size) -> bool; + +} // namespace VSilKit diff --git a/SilKit/source/dashboard/http/HttpRetryPolicy.hpp b/SilKit/source/dashboard/http/HttpRetryPolicy.hpp new file mode 100644 index 000000000..a4922337b --- /dev/null +++ b/SilKit/source/dashboard/http/HttpRetryPolicy.hpp @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: 2026 Vector Informatik GmbH +// +// SPDX-License-Identifier: MIT + +#pragma once + +#include +#include + +namespace VSilKit { + +/*! Retry behaviour for dashboard requests. + * + * The defaults reproduce the oatpp RetryPolicy that this replaces (see + * oatpp/web/client/RequestExecutor.cpp): at most three attempts, a fixed 300 ms backoff, and + * retries only on HTTP 503 or on a transport failure. Note that a 503 on the *last* attempt is + * returned to the caller rather than turned into a transport error - oatpp behaved the same way. + */ +struct HttpRetryPolicy +{ + std::size_t maxAttempts{3}; + std::chrono::milliseconds backoff{300}; + + auto ShouldRetry(int statusCode) const -> bool + { + return statusCode == 503; + } +}; + +} // namespace VSilKit diff --git a/SilKit/source/dashboard/http/IHttpClient.hpp b/SilKit/source/dashboard/http/IHttpClient.hpp new file mode 100644 index 000000000..b5003a1e3 --- /dev/null +++ b/SilKit/source/dashboard/http/IHttpClient.hpp @@ -0,0 +1,51 @@ +// SPDX-FileCopyrightText: 2026 Vector Informatik GmbH +// +// SPDX-License-Identifier: MIT + +#pragma once + +#include +#include + +namespace VSilKit { + +//! Outcome of a single HTTP exchange. +struct HttpResult +{ + //! True when no HTTP response was obtained at all (DNS, connect, write, read or parse failure, + //! or an aborted client). Equivalent to oatpp's null response, which the dashboard logged as + //! "server unavailable". + bool transportError{true}; + + //! HTTP status code; only meaningful when transportError is false. + int statusCode{0}; + + //! Response body; only populated when transportError is false. + std::string body; +}; + +/*! A minimal blocking HTTP client, sufficient for the dashboard's three POST endpoints. + * + * Implementations must never throw out of Post(): every failure is reported as + * HttpResult::transportError. Post() is expected to be called from a single thread; Abort() may be + * called concurrently from another. + */ +struct IHttpClient +{ + virtual ~IHttpClient() = default; + + //! POST a JSON body. `path` must not have a leading '/'. + virtual auto Post(const std::string& path, const std::string& jsonBody) -> HttpResult = 0; + + //! Drop any cached connection. Equivalent to oatpp's invalidateConnection. + virtual void Reset() = 0; + + /*! Unblock any in-flight Post() and make all subsequent calls fail fast. + * + * Idempotent, and safe to call from a thread other than the one in Post(). Used on shutdown so + * a dashboard server that accepts connections but never answers cannot stall the registry. + */ + virtual void Abort() = 0; +}; + +} // namespace VSilKit diff --git a/SilKit/source/dashboard/http/Mocks/MockHttpClient.hpp b/SilKit/source/dashboard/http/Mocks/MockHttpClient.hpp new file mode 100644 index 000000000..bd2350be3 --- /dev/null +++ b/SilKit/source/dashboard/http/Mocks/MockHttpClient.hpp @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: 2026 Vector Informatik GmbH +// +// SPDX-License-Identifier: MIT + +#pragma once + +#include "gmock/gmock.h" + +#include "dashboard/http/IHttpClient.hpp" + +namespace VSilKit { + +class MockHttpClient : public IHttpClient +{ +public: + MOCK_METHOD(HttpResult, Post, (const std::string& path, const std::string& jsonBody), (override)); + MOCK_METHOD(void, Reset, (), (override)); + MOCK_METHOD(void, Abort, (), (override)); +}; + +} // namespace VSilKit diff --git a/SilKit/source/dashboard/http/RetryingHttpClient.cpp b/SilKit/source/dashboard/http/RetryingHttpClient.cpp new file mode 100644 index 000000000..6e9821cac --- /dev/null +++ b/SilKit/source/dashboard/http/RetryingHttpClient.cpp @@ -0,0 +1,74 @@ +// SPDX-FileCopyrightText: 2026 Vector Informatik GmbH +// +// SPDX-License-Identifier: MIT + +#include "dashboard/http/RetryingHttpClient.hpp" + +#include + +namespace VSilKit { + +RetryingHttpClient::RetryingHttpClient(std::shared_ptr inner, HttpRetryPolicy policy) + : _inner(std::move(inner)) + , _policy(policy) +{ +} + +auto RetryingHttpClient::Post(const std::string& path, const std::string& jsonBody) -> HttpResult +{ + for (std::size_t attempt = 1;; ++attempt) + { + if (_aborted.load(std::memory_order_acquire)) + { + return HttpResult{}; + } + + auto result = _inner->Post(path, jsonBody); + + const bool mayRetry = !_aborted.load(std::memory_order_acquire) && attempt < _policy.maxAttempts; + + if (!result.transportError) + { + // A retryable status on the final attempt is handed back to the caller, matching oatpp. + if (!_policy.ShouldRetry(result.statusCode) || !mayRetry) + { + return result; + } + } + else if (!mayRetry) + { + return HttpResult{}; + } + + _inner->Reset(); + if (!SleepInterruptible(_policy.backoff)) + { + return HttpResult{}; + } + } +} + +void RetryingHttpClient::Reset() +{ + _inner->Reset(); +} + +void RetryingHttpClient::Abort() +{ + { + std::lock_guard lock{_mutex}; + _aborted.store(true, std::memory_order_release); + } + _abortCv.notify_all(); + _inner->Abort(); +} + +auto RetryingHttpClient::SleepInterruptible(std::chrono::milliseconds duration) -> bool +{ + std::unique_lock lock{_mutex}; + const bool aborted = + _abortCv.wait_for(lock, duration, [this] { return _aborted.load(std::memory_order_acquire); }); + return !aborted; +} + +} // namespace VSilKit diff --git a/SilKit/source/dashboard/http/RetryingHttpClient.hpp b/SilKit/source/dashboard/http/RetryingHttpClient.hpp new file mode 100644 index 000000000..3f0ade6a3 --- /dev/null +++ b/SilKit/source/dashboard/http/RetryingHttpClient.hpp @@ -0,0 +1,47 @@ +// SPDX-FileCopyrightText: 2026 Vector Informatik GmbH +// +// SPDX-License-Identifier: MIT + +#pragma once + +#include +#include +#include +#include +#include + +#include "dashboard/http/HttpRetryPolicy.hpp" +#include "dashboard/http/IHttpClient.hpp" + +namespace VSilKit { + +/*! Adds retries on top of another IHttpClient. + * + * Keeping the policy in a decorator leaves the transport free of policy and makes the retry logic + * testable without sockets. + * + * Unlike the oatpp policy it replaces, the backoff wait is interruptible: Abort() wakes it + * immediately instead of letting the 300 ms elapse, so shutdown is not delayed. + */ +class RetryingHttpClient final : public IHttpClient +{ +public: + RetryingHttpClient(std::shared_ptr inner, HttpRetryPolicy policy = {}); + + auto Post(const std::string& path, const std::string& jsonBody) -> HttpResult override; + void Reset() override; + void Abort() override; + +private: + //! Returns false if the wait was cut short by Abort(). + auto SleepInterruptible(std::chrono::milliseconds duration) -> bool; + + std::shared_ptr _inner; + HttpRetryPolicy _policy; + + std::mutex _mutex; + std::condition_variable _abortCv; + std::atomic _aborted{false}; +}; + +} // namespace VSilKit diff --git a/SilKit/source/dashboard/http/Test_AsioHttpClient.cpp b/SilKit/source/dashboard/http/Test_AsioHttpClient.cpp new file mode 100644 index 000000000..754be0f10 --- /dev/null +++ b/SilKit/source/dashboard/http/Test_AsioHttpClient.cpp @@ -0,0 +1,225 @@ +// SPDX-FileCopyrightText: 2026 Vector Informatik GmbH +// +// SPDX-License-Identifier: MIT + +#include "dashboard/http/AsioHttpClient.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "dashboard/http/FakeHttpServer.hpp" + +#include "gtest/gtest.h" + +#include "dashboard/http/RetryingHttpClient.hpp" + +namespace VSilKit { +namespace { + +using namespace std::chrono_literals; + +// Timing assertions are deliberately loose: CI machines are slow and oversubscribed. Each one only +// has to distinguish "bounded" from "hung", not measure latency. +constexpr auto kGenerousBound = 5s; + +using VSilKit::Tests::FakeHttpServer; + +auto Reply(int statusCode, const std::string& body, const std::string& extraHeaders = {}) -> std::string +{ + return FakeHttpServer::MakeReply(statusCode, body, extraHeaders); +} + +auto AlwaysReply(std::string reply) -> FakeHttpServer::Handler +{ + return FakeHttpServer::Always(std::move(reply)); +} + +class Test_AsioHttpClient : public testing::Test +{ +public: + static auto FastTimeouts() -> AsioHttpClientTimeouts + { + AsioHttpClientTimeouts timeouts{}; + timeouts.connect = 1s; + timeouts.write = 1s; + timeouts.read = 500ms; + return timeouts; + } +}; + +TEST_F(Test_AsioHttpClient, Post_SendsExactlyTheExpectedRequestBytes) +{ + FakeHttpServer server{AlwaysReply(Reply(201, R"({"id":42})"))}; + AsioHttpClient client{nullptr, "127.0.0.1", server.Port()}; + + const auto result = client.Post("system-service/v1.0/simulations", R"({"started":1})"); + + ASSERT_FALSE(result.transportError); + EXPECT_EQ(result.statusCode, 201); + EXPECT_EQ(result.body, R"({"id":42})"); + + const auto requests = server.Requests(); + ASSERT_EQ(requests.size(), 1u); + const std::string expected = "POST /system-service/v1.0/simulations HTTP/1.1\r\n" + "Host: 127.0.0.1:" + + std::to_string(server.Port()) + + "\r\n" + "Connection: keep-alive\r\n" + "Content-Type: application/json\r\n" + "Content-Length: 13\r\n" + "\r\n" + R"({"started":1})"; + EXPECT_EQ(requests[0], expected); +} + +TEST_F(Test_AsioHttpClient, Post_ReusesASingleConnection) +{ + FakeHttpServer server{AlwaysReply(Reply(200, "{}"))}; + AsioHttpClient client{nullptr, "127.0.0.1", server.Port()}; + + EXPECT_FALSE(client.Post("a", "{}").transportError); + EXPECT_FALSE(client.Post("b", "{}").transportError); + + EXPECT_EQ(server.AcceptCount(), 1) << "the keep-alive connection should be reused"; + EXPECT_EQ(server.Requests().size(), 2u); +} + +TEST_F(Test_AsioHttpClient, Post_ReconnectsWhenTheServerClosesTheConnection) +{ + FakeHttpServer server{AlwaysReply(Reply(200, "{}", "Connection: close\r\n")), true}; + AsioHttpClient client{nullptr, "127.0.0.1", server.Port()}; + + EXPECT_FALSE(client.Post("a", "{}").transportError); + EXPECT_FALSE(client.Post("b", "{}").transportError); + + EXPECT_EQ(server.AcceptCount(), 2); +} + +// A response with no body must still leave the connection usable, or the two status-only endpoints +// would force a reconnect on every bulk update. +TEST_F(Test_AsioHttpClient, Post_HandlesABodylessResponseAndKeepsTheConnection) +{ + FakeHttpServer server{AlwaysReply("HTTP/1.1 204 No Content\r\n\r\n")}; + AsioHttpClient client{nullptr, "127.0.0.1", server.Port()}; + + const auto first = client.Post("a", "{}"); + const auto second = client.Post("b", "{}"); + + ASSERT_FALSE(first.transportError); + EXPECT_EQ(first.statusCode, 204); + ASSERT_FALSE(second.transportError); + EXPECT_EQ(second.statusCode, 204); + EXPECT_EQ(server.AcceptCount(), 1); +} + +TEST_F(Test_AsioHttpClient, Post_ReassemblesAChunkedResponseBody) +{ + const std::string chunked = std::string{"HTTP/1.1 201 Created\r\nTransfer-Encoding: chunked\r\n\r\n"} + + "5\r\n" + R"({"id")" + "\r\n" + "4\r\n" + R"(:42})" + "\r\n" + "0\r\n\r\n"; + FakeHttpServer server{AlwaysReply(chunked)}; + AsioHttpClient client{nullptr, "127.0.0.1", server.Port()}; + + const auto result = client.Post("a", "{}"); + + ASSERT_FALSE(result.transportError); + EXPECT_EQ(result.statusCode, 201); + EXPECT_EQ(result.body, R"({"id":42})"); +} + +TEST_F(Test_AsioHttpClient, Post_SkipsAnInterimResponse) +{ + FakeHttpServer server{AlwaysReply(std::string{"HTTP/1.1 100 Continue\r\n\r\n"} + Reply(201, R"({"id":7})"))}; + AsioHttpClient client{nullptr, "127.0.0.1", server.Port()}; + + const auto result = client.Post("a", "{}"); + + ASSERT_FALSE(result.transportError); + EXPECT_EQ(result.statusCode, 201); + EXPECT_EQ(result.body, R"({"id":7})"); +} + +TEST_F(Test_AsioHttpClient, Post_ReportsATransportErrorForAMalformedResponse) +{ + FakeHttpServer server{AlwaysReply("this is not an HTTP response\r\n\r\n")}; + AsioHttpClient client{nullptr, "127.0.0.1", server.Port(), FastTimeouts()}; + + EXPECT_TRUE(client.Post("a", "{}").transportError); +} + +TEST_F(Test_AsioHttpClient, Post_ReportsATransportErrorWhenNothingIsListening) +{ + // Port 1 is reserved and never has a listener; the point is that we fail rather than hang. + AsioHttpClient client{nullptr, "127.0.0.1", 1, FastTimeouts()}; + + const auto start = std::chrono::steady_clock::now(); + const auto result = client.Post("a", "{}"); + const auto elapsed = std::chrono::steady_clock::now() - start; + + EXPECT_TRUE(result.transportError); + EXPECT_LT(elapsed, kGenerousBound); +} + +// oatpp set no socket timeouts at all, so this case used to hang forever. +TEST_F(Test_AsioHttpClient, Post_TimesOutWhenTheServerNeverAnswers) +{ + FakeHttpServer server{AlwaysReply("")}; + AsioHttpClient client{nullptr, "127.0.0.1", server.Port(), FastTimeouts()}; + + const auto start = std::chrono::steady_clock::now(); + const auto result = client.Post("a", "{}"); + const auto elapsed = std::chrono::steady_clock::now() - start; + + EXPECT_TRUE(result.transportError); + EXPECT_LT(elapsed, kGenerousBound) << "the read deadline should have fired"; +} + +TEST_F(Test_AsioHttpClient, Abort_UnblocksAnInFlightRequest) +{ + FakeHttpServer server{AlwaysReply("")}; + + AsioHttpClientTimeouts patient{}; + patient.read = 60s; // long enough that only Abort() can end the wait in time + AsioHttpClient client{nullptr, "127.0.0.1", server.Port(), patient}; + + std::thread aborter{[&client] { + std::this_thread::sleep_for(100ms); + client.Abort(); + }}; + + const auto start = std::chrono::steady_clock::now(); + const auto result = client.Post("a", "{}"); + const auto elapsed = std::chrono::steady_clock::now() - start; + aborter.join(); + + EXPECT_TRUE(result.transportError); + EXPECT_LT(elapsed, kGenerousBound) << "Abort() must cancel the pending read"; + EXPECT_TRUE(client.Post("a", "{}").transportError) << "an aborted client stays aborted"; +} + +TEST_F(Test_AsioHttpClient, RetryingHttpClient_OverTheRealTransport_RecoversFromServiceUnavailable) +{ + std::atomic attempts{0}; + FakeHttpServer server{[&attempts](const std::string&) { + return ++attempts <= 2 ? Reply(503, "") : Reply(200, "{}"); + }}; + + auto transport = std::make_shared(nullptr, "127.0.0.1", server.Port()); + HttpRetryPolicy policy{}; + policy.backoff = 1ms; + RetryingHttpClient client{transport, policy}; + + const auto result = client.Post("a", "{}"); + + ASSERT_FALSE(result.transportError); + EXPECT_EQ(result.statusCode, 200); + EXPECT_EQ(attempts.load(), 3); +} + +} // namespace +} // namespace VSilKit diff --git a/SilKit/source/dashboard/http/Test_HttpResponseParser.cpp b/SilKit/source/dashboard/http/Test_HttpResponseParser.cpp new file mode 100644 index 000000000..b937376ea --- /dev/null +++ b/SilKit/source/dashboard/http/Test_HttpResponseParser.cpp @@ -0,0 +1,186 @@ +// SPDX-FileCopyrightText: 2026 Vector Informatik GmbH +// +// SPDX-License-Identifier: MIT + +#include "dashboard/http/HttpResponseParser.hpp" + +#include + +#include "gtest/gtest.h" + +namespace VSilKit { +namespace { + +struct HeadCase +{ + const char* what; + const char* head; + bool valid; + int statusCode; + HttpBodyFraming framing; + uint64_t contentLength; + bool connectionClose; + bool interim; +}; + +// A valid head, for the cases where only one attribute is under test. +constexpr auto kOk = true; +constexpr auto kBad = false; + +class Test_HttpResponseParser_Head : public testing::TestWithParam +{ +}; + +TEST_P(Test_HttpResponseParser_Head, ParseResponseHead) +{ + const auto& c = GetParam(); + + ResponseHead head{}; + const bool valid = ParseResponseHead(c.head, head); + + ASSERT_EQ(valid, c.valid) << c.what; + if (!c.valid) + { + return; + } + EXPECT_EQ(head.statusCode, c.statusCode) << c.what; + EXPECT_EQ(static_cast(head.framing), static_cast(c.framing)) << c.what; + EXPECT_EQ(head.contentLength, c.contentLength) << c.what; + EXPECT_EQ(head.connectionClose, c.connectionClose) << c.what; + EXPECT_EQ(head.interim, c.interim) << c.what; +} + +const HeadCase kHeadCases[] = { + // --- status line --- + {"201 with content-length", "HTTP/1.1 201 Created\r\nContent-Length: 12\r\n\r\n", kOk, 201, + HttpBodyFraming::ContentLength, 12, false, false}, + {"no reason phrase", "HTTP/1.1 200\r\nContent-Length: 0\r\n\r\n", kOk, 200, HttpBodyFraming::ContentLength, 0, + false, false}, + {"HTTP/1.0", "HTTP/1.0 200 OK\r\nContent-Length: 1\r\n\r\n", kOk, 200, HttpBodyFraming::ContentLength, 1, false, + false}, + {"503", "HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\n\r\n", kOk, 503, + HttpBodyFraming::ContentLength, 0, false, false}, + {"204 has no body", "HTTP/1.1 204 No Content\r\n\r\n", kOk, 204, HttpBodyFraming::None, 0, false, false}, + {"304 ignores content-length", "HTTP/1.1 304 Not Modified\r\nContent-Length: 99\r\n\r\n", kOk, 304, + HttpBodyFraming::None, 0, false, false}, + {"1xx is interim", "HTTP/1.1 100 Continue\r\n\r\n", kOk, 100, HttpBodyFraming::None, 0, false, true}, + + {"not http", "not http at all\r\n\r\n", kBad, 0, HttpBodyFraming::None, 0, false, false}, + {"no status code", "HTTP/1.1\r\n\r\n", kBad, 0, HttpBodyFraming::None, 0, false, false}, + {"two-digit code", "HTTP/1.1 20 OK\r\n\r\n", kBad, 0, HttpBodyFraming::None, 0, false, false}, + {"non-numeric code", "HTTP/1.1 2O1 Created\r\n\r\n", kBad, 0, HttpBodyFraming::None, 0, false, false}, + {"unsupported version", "HTTP/2.0 200 OK\r\n\r\n", kBad, 0, HttpBodyFraming::None, 0, false, false}, + {"empty head", "", kBad, 0, HttpBodyFraming::None, 0, false, false}, + + // --- body framing --- + {"chunked", "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n", kOk, 200, HttpBodyFraming::Chunked, 0, + false, false}, + {"chunked wins over content-length", + "HTTP/1.1 200 OK\r\nContent-Length: 5\r\nTransfer-Encoding: chunked\r\n\r\n", kOk, 200, + HttpBodyFraming::Chunked, 0, false, false}, + {"no framing header reads until close", "HTTP/1.1 200 OK\r\n\r\n", kOk, 200, HttpBodyFraming::UntilClose, 0, + false, false}, + {"agreeing duplicate content-length", "HTTP/1.1 200 OK\r\nContent-Length: 5\r\nContent-Length: 5\r\n\r\n", kOk, + 200, HttpBodyFraming::ContentLength, 5, false, false}, + {"content-length at the cap", "HTTP/1.1 200 OK\r\nContent-Length: 1048576\r\n\r\n", kOk, 200, + HttpBodyFraming::ContentLength, 1048576, false, false}, + + {"conflicting duplicate content-length", "HTTP/1.1 200 OK\r\nContent-Length: 5\r\nContent-Length: 6\r\n\r\n", + kBad, 0, HttpBodyFraming::None, 0, false, false}, + {"non-numeric content-length", "HTTP/1.1 200 OK\r\nContent-Length: abc\r\n\r\n", kBad, 0, + HttpBodyFraming::None, 0, false, false}, + {"content-length over the cap", "HTTP/1.1 200 OK\r\nContent-Length: 1048577\r\n\r\n", kBad, 0, + HttpBodyFraming::None, 0, false, false}, + {"undecodable transfer-encoding", "HTTP/1.1 200 OK\r\nTransfer-Encoding: gzip\r\n\r\n", kBad, 0, + HttpBodyFraming::None, 0, false, false}, + + // --- leniency about header syntax --- + {"lowercase header name", "HTTP/1.1 200 OK\r\ncontent-length: 3\r\n\r\n", kOk, 200, + HttpBodyFraming::ContentLength, 3, false, false}, + {"mixed-case header name", "HTTP/1.1 200 OK\r\nCoNtEnT-LeNgTh: 3\r\n\r\n", kOk, 200, + HttpBodyFraming::ContentLength, 3, false, false}, + {"surrounding whitespace", "HTTP/1.1 200 OK\r\nContent-Length: 3 \r\n\r\n", kOk, 200, + HttpBodyFraming::ContentLength, 3, false, false}, + {"bare LF line endings", "HTTP/1.1 200 OK\nContent-Length: 3\n\n", kOk, 200, HttpBodyFraming::ContentLength, 3, + false, false}, + {"unknown headers ignored", + "HTTP/1.1 200 OK\r\nServer: nginx\r\nX-Whatever: 1\r\nDate: now\r\nContent-Length: 3\r\n\r\n", kOk, 200, + HttpBodyFraming::ContentLength, 3, false, false}, + {"obs-fold continuation skipped", + "HTTP/1.1 200 OK\r\nX-Long: a\r\n continued\r\nContent-Length: 3\r\n\r\n", kOk, 200, + HttpBodyFraming::ContentLength, 3, false, false}, + {"connection close", "HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 3\r\n\r\n", kOk, 200, + HttpBodyFraming::ContentLength, 3, true, false}, + {"connection keep-alive", "HTTP/1.1 200 OK\r\nConnection: keep-alive\r\nContent-Length: 3\r\n\r\n", kOk, 200, + HttpBodyFraming::ContentLength, 3, false, false}, + + {"header without a colon", "HTTP/1.1 200 OK\r\nnonsense\r\n\r\n", kBad, 0, HttpBodyFraming::None, 0, false, + false}, +}; + +INSTANTIATE_TEST_SUITE_P(Cases, Test_HttpResponseParser_Head, testing::ValuesIn(kHeadCases), + [](const testing::TestParamInfo& info) { + std::string name{info.param.what}; + for (auto& c : name) + { + if (!std::isalnum(static_cast(c))) + { + c = '_'; + } + } + return name; +}); + +struct ChunkCase +{ + const char* what; + const char* line; + bool valid; + uint64_t size; +}; + +class Test_HttpResponseParser_ChunkSize : public testing::TestWithParam +{ +}; + +TEST_P(Test_HttpResponseParser_ChunkSize, ParseChunkSize) +{ + const auto& c = GetParam(); + + uint64_t size = 0; + const bool valid = ParseChunkSize(c.line, size); + + ASSERT_EQ(valid, c.valid) << c.what; + if (c.valid) + { + EXPECT_EQ(size, c.size) << c.what; + } +} + +const ChunkCase kChunkCases[] = { + {"lowercase hex", "1a3", kOk, 0x1a3}, + {"uppercase hex", "1A3", kOk, 0x1a3}, + {"terminator", "0", kOk, 0}, + {"chunk extension stripped", "1a3;ext=val", kOk, 0x1a3}, + {"surrounding whitespace", " 1a3 ", kOk, 0x1a3}, + {"largest representable", "FFFFFFFFFFFFFFFF", kOk, 0xFFFFFFFFFFFFFFFFULL}, + {"empty", "", kBad, 0}, + {"not hex", "xyz", kBad, 0}, + {"overflows uint64", "FFFFFFFFFFFFFFFFF", kBad, 0}, +}; + +INSTANTIATE_TEST_SUITE_P(Cases, Test_HttpResponseParser_ChunkSize, testing::ValuesIn(kChunkCases), + [](const testing::TestParamInfo& info) { + std::string name{info.param.what}; + for (auto& c : name) + { + if (!std::isalnum(static_cast(c))) + { + c = '_'; + } + } + return name; +}); + +} // namespace +} // namespace VSilKit diff --git a/SilKit/source/dashboard/http/Test_RetryingHttpClient.cpp b/SilKit/source/dashboard/http/Test_RetryingHttpClient.cpp new file mode 100644 index 000000000..c2db46d27 --- /dev/null +++ b/SilKit/source/dashboard/http/Test_RetryingHttpClient.cpp @@ -0,0 +1,185 @@ +// SPDX-FileCopyrightText: 2026 Vector Informatik GmbH +// +// SPDX-License-Identifier: MIT + +#include "dashboard/http/RetryingHttpClient.hpp" + +#include +#include +#include + +#include "gmock/gmock.h" +#include "gtest/gtest.h" + +#include "dashboard/http/Mocks/MockHttpClient.hpp" + +namespace VSilKit { +namespace { + +using namespace std::chrono_literals; +using testing::Return; + +auto Ok(int statusCode, std::string body = {}) -> HttpResult +{ + return HttpResult{false, statusCode, std::move(body)}; +} + +auto Unavailable() -> HttpResult +{ + return HttpResult{}; +} + +class Test_RetryingHttpClient : public testing::Test +{ +public: + void SetUp() override { _inner = std::make_shared(); } + + auto CreateClient(HttpRetryPolicy policy = {}) -> RetryingHttpClient + { + return RetryingHttpClient{_inner, policy}; + } + + //! A policy with a negligible backoff, so timing does not dominate the test run. + static auto FastPolicy() -> HttpRetryPolicy + { + HttpRetryPolicy policy{}; + policy.backoff = 1ms; + return policy; + } + + std::shared_ptr _inner; +}; + +TEST_F(Test_RetryingHttpClient, Post_SuccessOnFirstAttempt_CallsInnerOnce) +{ + EXPECT_CALL(*_inner, Post("path", "{}")).WillOnce(Return(Ok(200, "body"))); + EXPECT_CALL(*_inner, Reset()).Times(0); + + auto client = CreateClient(FastPolicy()); + const auto result = client.Post("path", "{}"); + + EXPECT_FALSE(result.transportError); + EXPECT_EQ(result.statusCode, 200); + EXPECT_EQ(result.body, "body"); +} + +TEST_F(Test_RetryingHttpClient, Post_NonRetryableStatus_IsReturnedImmediately) +{ + EXPECT_CALL(*_inner, Post("path", "{}")).WillOnce(Return(Ok(500))); + + auto client = CreateClient(FastPolicy()); + const auto result = client.Post("path", "{}"); + + EXPECT_FALSE(result.transportError); + EXPECT_EQ(result.statusCode, 500); +} + +TEST_F(Test_RetryingHttpClient, Post_ServiceUnavailableThenSuccess_RetriesAndSucceeds) +{ + EXPECT_CALL(*_inner, Post("path", "{}")).WillOnce(Return(Ok(503))).WillOnce(Return(Ok(200))); + EXPECT_CALL(*_inner, Reset()).Times(1); + + auto client = CreateClient(FastPolicy()); + const auto result = client.Post("path", "{}"); + + EXPECT_FALSE(result.transportError); + EXPECT_EQ(result.statusCode, 200); +} + +// oatpp returned a retryable status from the final attempt to the caller rather than converting it +// into a transport error; that behaviour is preserved deliberately. +TEST_F(Test_RetryingHttpClient, Post_ServiceUnavailableOnEveryAttempt_ReturnsTheStatus) +{ + EXPECT_CALL(*_inner, Post("path", "{}")).Times(3).WillRepeatedly(Return(Ok(503))); + EXPECT_CALL(*_inner, Reset()).Times(2); + + auto client = CreateClient(FastPolicy()); + const auto result = client.Post("path", "{}"); + + EXPECT_FALSE(result.transportError); + EXPECT_EQ(result.statusCode, 503); +} + +TEST_F(Test_RetryingHttpClient, Post_TransportErrorOnEveryAttempt_ReportsTransportError) +{ + EXPECT_CALL(*_inner, Post("path", "{}")).Times(3).WillRepeatedly(Return(Unavailable())); + EXPECT_CALL(*_inner, Reset()).Times(2); + + auto client = CreateClient(FastPolicy()); + const auto result = client.Post("path", "{}"); + + EXPECT_TRUE(result.transportError); +} + +TEST_F(Test_RetryingHttpClient, Post_TransportErrorThenSuccess_Recovers) +{ + EXPECT_CALL(*_inner, Post("path", "{}")).WillOnce(Return(Unavailable())).WillOnce(Return(Ok(201, "{}"))); + EXPECT_CALL(*_inner, Reset()).Times(1); + + auto client = CreateClient(FastPolicy()); + const auto result = client.Post("path", "{}"); + + EXPECT_FALSE(result.transportError); + EXPECT_EQ(result.statusCode, 201); +} + +TEST_F(Test_RetryingHttpClient, Post_AfterAbort_DoesNotCallInner) +{ + EXPECT_CALL(*_inner, Post(testing::_, testing::_)).Times(0); + EXPECT_CALL(*_inner, Abort()).Times(1); + + auto client = CreateClient(FastPolicy()); + client.Abort(); + const auto result = client.Post("path", "{}"); + + EXPECT_TRUE(result.transportError); +} + +TEST_F(Test_RetryingHttpClient, Abort_DuringBackoff_ReturnsWithoutWaitingOutTheBackoff) +{ + // A long backoff: if it were not interruptible, this test would take 30 s. + HttpRetryPolicy policy{}; + policy.backoff = 30s; + + EXPECT_CALL(*_inner, Post("path", "{}")).WillOnce(Return(Ok(503))); + EXPECT_CALL(*_inner, Reset()).Times(1); + EXPECT_CALL(*_inner, Abort()).Times(1); + + auto client = CreateClient(policy); + + std::thread aborter{[&client] { + std::this_thread::sleep_for(50ms); + client.Abort(); + }}; + + const auto start = std::chrono::steady_clock::now(); + const auto result = client.Post("path", "{}"); + const auto elapsed = std::chrono::steady_clock::now() - start; + aborter.join(); + + EXPECT_TRUE(result.transportError); + EXPECT_LT(elapsed, 5s) << "Abort() must cut the backoff short"; +} + +TEST_F(Test_RetryingHttpClient, Post_RespectsAConfiguredAttemptLimit) +{ + HttpRetryPolicy policy = FastPolicy(); + policy.maxAttempts = 5; + + EXPECT_CALL(*_inner, Post("path", "{}")).Times(5).WillRepeatedly(Return(Ok(503))); + EXPECT_CALL(*_inner, Reset()).Times(4); + + auto client = CreateClient(policy); + EXPECT_EQ(client.Post("path", "{}").statusCode, 503); +} + +TEST_F(Test_RetryingHttpClient, Reset_IsForwardedToInner) +{ + EXPECT_CALL(*_inner, Reset()).Times(1); + + auto client = CreateClient(); + client.Reset(); +} + +} // namespace +} // namespace VSilKit diff --git a/SilKit/source/dashboard/json/DashboardJson.cpp b/SilKit/source/dashboard/json/DashboardJson.cpp new file mode 100644 index 000000000..1af2459ea --- /dev/null +++ b/SilKit/source/dashboard/json/DashboardJson.cpp @@ -0,0 +1,50 @@ +// SPDX-FileCopyrightText: 2026 Vector Informatik GmbH +// +// SPDX-License-Identifier: MIT + +#include "dashboard/json/DashboardJson.hpp" + +namespace SilKit { +namespace Dashboard { + +auto ParseSimulationCreationResponse(const std::string& body) -> std::optional +{ + if (body.empty()) + { + return std::nullopt; + } + + try + { + ryml::Tree tree{VSilKit::GetRapidyamlCallbacks()}; + ryml::parse_json_in_arena(ryml::to_csubstr(body), &tree); + + const auto root = tree.crootref(); + if (!root.is_map()) + { + return std::nullopt; + } + + const auto id = root.find_child("id"); + if (id.invalid() || !id.has_val() || id.val().empty()) + { + return std::nullopt; + } + + uint64_t value{}; + auto checked = ryml::fmt::overflow_checked(value); + if (!ryml::from_chars(id.val(), &checked)) + { + return std::nullopt; + } + return value; + } + catch (const std::exception&) + { + // The ryml callbacks throw on malformed input. + return std::nullopt; + } +} + +} // namespace Dashboard +} // namespace SilKit diff --git a/SilKit/source/dashboard/json/DashboardJson.hpp b/SilKit/source/dashboard/json/DashboardJson.hpp new file mode 100644 index 000000000..8d653ae82 --- /dev/null +++ b/SilKit/source/dashboard/json/DashboardJson.hpp @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: 2026 Vector Informatik GmbH +// +// SPDX-License-Identifier: MIT + +#pragma once + +#include +#include +#include + +#include "config/YamlParserUtils.hpp" + +#include "dashboard/json/DashboardJsonWriter.hpp" + +namespace SilKit { +namespace Dashboard { + +//! Serialize a dashboard DTO to compact JSON. +template +auto ToJson(const T& dto) -> std::string +{ + ryml::Tree tree{VSilKit::GetRapidyamlCallbacks()}; + DashboardJsonWriter writer{tree.rootref()}; + writer.Write(dto); + return ryml::emitrs_json(tree); +} + +/*! Extract the simulation id from a createSimulation response body, i.e. {"id":}. + * + * Returns std::nullopt for anything unusable rather than throwing: the caller degrades to "creating + * simulation failed", which is what the previous oatpp path reported for a non-201 response. + * + * Unlike that path, unknown fields are tolerated. oatpp ran with allowUnknownFields disabled, so an + * extra field in the response threw out of the dashboard's worker thread and killed it. + */ +auto ParseSimulationCreationResponse(const std::string& body) -> std::optional; + +} // namespace Dashboard +} // namespace SilKit diff --git a/SilKit/source/dashboard/json/DashboardJsonWriter.cpp b/SilKit/source/dashboard/json/DashboardJsonWriter.cpp new file mode 100644 index 000000000..8f4f61246 --- /dev/null +++ b/SilKit/source/dashboard/json/DashboardJsonWriter.cpp @@ -0,0 +1,208 @@ +// SPDX-FileCopyrightText: 2026 Vector Informatik GmbH +// +// SPDX-License-Identifier: MIT + +#include "dashboard/json/DashboardJsonWriter.hpp" + +#include + +namespace SilKit { +namespace Dashboard { + +namespace { + +//! U+FFFD REPLACEMENT CHARACTER, as UTF-8. +constexpr std::string_view kReplacementCharacter = "\xEF\xBF\xBD"; + +/*! True for the control bytes ryml's JSON emitter would pass through unescaped. + * + * ryml escapes only " \ \b \f \n \r \t. Any other C0 byte would be emitted raw, which is invalid + * JSON, so those are replaced instead. Reachable only through free-text fields such as + * ParticipantStatus::enterReason and supplemental-data-derived label and topic values. + */ +auto NeedsReplacement(unsigned char c) -> bool +{ + switch (c) + { + case '\b': + case '\t': + case '\n': + case '\f': + case '\r': + return false; + default: + return c < 0x20; + } +} + +} // namespace + +void DashboardJsonWriter::WriteQuoted(std::string_view value) +{ + const auto needsSanitizing = [value] { + for (const char c : value) + { + if (NeedsReplacement(static_cast(c))) + { + return true; + } + } + return false; + }(); + + // ryml copies the scalar into the tree arena, so a local buffer is safe here. + std::string sanitized; + if (needsSanitizing) + { + sanitized.reserve(value.size()); + for (const char c : value) + { + if (NeedsReplacement(static_cast(c))) + { + sanitized += kReplacementCharacter; + } + else + { + sanitized += c; + } + } + value = sanitized; + } + + node << ryml::csubstr{value.data(), value.size()}; + // Without this a numeric-looking string would be emitted as a bare JSON number, and an empty + // string would be emitted as null. + node.set_val_style(ryml::VALQUO); +} + +void DashboardJsonWriter::Write(const SimulationConfigurationDto& obj) +{ + MakeMap(); + WriteKeyValue("connectUri", obj.connectUri); +} + +void DashboardJsonWriter::Write(const SimulationCreationRequestDto& obj) +{ + MakeMap(); + WriteKeyValue("started", obj.started); + WriteKeyValue("configuration", obj.configuration); +} + +void DashboardJsonWriter::Write(const SystemStatusDto& obj) +{ + MakeMap(); + WriteKeyValue("state", obj.state); +} + +void DashboardJsonWriter::Write(const ParticipantStatusDto& obj) +{ + MakeMap(); + WriteKeyValue("state", obj.state); + WriteKeyValue("enterReason", obj.enterReason); + WriteKeyValue("enterTime", obj.enterTime); +} + +void DashboardJsonWriter::Write(const MatchingLabelDto& obj) +{ + MakeMap(); + WriteKeyValue("key", obj.key); + WriteKeyValue("value", obj.value); + WriteKeyValue("kind", obj.kind); +} + +void DashboardJsonWriter::Write(const DataSpecDto& obj) +{ + MakeMap(); + WriteKeyValue("topic", obj.topic); + WriteKeyValue("mediaType", obj.mediaType); + WriteKeyValue("labels", obj.labels); +} + +void DashboardJsonWriter::Write(const RpcSpecDto& obj) +{ + MakeMap(); + WriteKeyValue("functionName", obj.functionName); + WriteKeyValue("mediaType", obj.mediaType); + WriteKeyValue("labels", obj.labels); +} + +void DashboardJsonWriter::Write(const BulkSystemDto& obj) +{ + MakeMap(); + WriteKeyValue("statuses", obj.statuses); +} + +void DashboardJsonWriter::Write(const BulkControllerDto& obj) +{ + MakeMap(); + WriteKeyValue("id", obj.id); + WriteKeyValue("name", obj.name); + WriteKeyValue("networkName", obj.networkName); +} + +void DashboardJsonWriter::Write(const BulkDataServiceDto& obj) +{ + MakeMap(); + WriteKeyValue("id", obj.id); + WriteKeyValue("name", obj.name); + WriteKeyValue("networkName", obj.networkName); + WriteKeyValue("spec", obj.spec); +} + +void DashboardJsonWriter::Write(const BulkRpcServiceDto& obj) +{ + MakeMap(); + WriteKeyValue("id", obj.id); + WriteKeyValue("name", obj.name); + WriteKeyValue("networkName", obj.networkName); + WriteKeyValue("spec", obj.spec); +} + +void DashboardJsonWriter::Write(const BulkServiceInternalDto& obj) +{ + MakeMap(); + WriteKeyValue("id", obj.id); + WriteKeyValue("name", obj.name); + WriteKeyValue("networkName", obj.networkName); + WriteKeyValue("parentId", obj.parentId); +} + +void DashboardJsonWriter::Write(const BulkParticipantDto& obj) +{ + MakeMap(); + WriteKeyValue("name", obj.name); + WriteKeyValue("statuses", obj.statuses); + WriteKeyValue("canControllers", obj.canControllers); + WriteKeyValue("ethernetControllers", obj.ethernetControllers); + WriteKeyValue("flexrayControllers", obj.flexrayControllers); + WriteKeyValue("linControllers", obj.linControllers); + WriteKeyValue("dataPublishers", obj.dataPublishers); + WriteKeyValue("dataSubscribers", obj.dataSubscribers); + WriteKeyValue("dataSubscriberInternals", obj.dataSubscriberInternals); + WriteKeyValue("rpcClients", obj.rpcClients); + WriteKeyValue("rpcServers", obj.rpcServers); + WriteKeyValue("rpcServerInternals", obj.rpcServerInternals); + WriteKeyValue("canNetworks", obj.canNetworks); + WriteKeyValue("ethernetNetworks", obj.ethernetNetworks); + WriteKeyValue("flexrayNetworks", obj.flexrayNetworks); + WriteKeyValue("linNetworks", obj.linNetworks); +} + +void DashboardJsonWriter::Write(const BulkSimulationDto& obj) +{ + MakeMap(); + WriteKeyValueOrNull("stopped", obj.stopped); + WriteKeyValue("system", obj.system); + WriteKeyValue("participants", obj.participants); +} + +void DashboardJsonWriter::Write(const MetricsUpdateDto& obj) +{ + MakeMap(); + WriteKeyValue("attributes", obj.attributes); + WriteKeyValue("counters", obj.counters); + WriteKeyValue("statistics", obj.statistics); +} + +} // namespace Dashboard +} // namespace SilKit diff --git a/SilKit/source/dashboard/json/DashboardJsonWriter.hpp b/SilKit/source/dashboard/json/DashboardJsonWriter.hpp new file mode 100644 index 000000000..f1bcf080c --- /dev/null +++ b/SilKit/source/dashboard/json/DashboardJsonWriter.hpp @@ -0,0 +1,128 @@ +// SPDX-FileCopyrightText: 2026 Vector Informatik GmbH +// +// SPDX-License-Identifier: MIT + +#pragma once + +#include +#include +#include +#include + +#include "config/BasicYamlWriter.hpp" + +#include "dashboard/dto/BulkUpdateDto.hpp" +#include "dashboard/dto/MetricsDto.hpp" +#include "dashboard/dto/SimulationCreationRequestDto.hpp" + +namespace SilKit { +namespace Dashboard { + +/*! Writes dashboard DTOs into a ryml tree for JSON emission. + * + * Two ryml behaviours drive the design of the primitive overloads below: + * + * - a value is emitted unquoted unless the node carries VALQUO or ryml itself decides to quote it, + * and `scalar_style_json_choose` treats anything number-like as plain. A std::string of "12345" + * would therefore go out as a bare JSON number, so every string is force-quoted. + * - a zero-length scalar emits `null` when its backing pointer is null and `""` when VALQUO is + * set, which is how unset-versus-empty is expressed. + */ +struct DashboardJsonWriter : VSilKit::BasicYamlWriter +{ + using BasicYamlWriter::BasicYamlWriter; + using BasicYamlWriter::Write; // generic scalars and std::vector + + // --- primitives ------------------------------------------------------------------------- + + void Write(const std::string& value) + { + WriteQuoted(value); + } + + void Write(std::string_view value) + { + WriteQuoted(value); + } + + // Guard the paths that would silently emit an unquoted scalar. + void Write(const char*) = delete; + void Write(bool) = delete; + + //! Matches oatpp's OATPP_FLOAT_STRING_FORMAT, so statistic values keep their previous digits. + void Write(double value) + { + char buffer[64]; + const int length = std::snprintf(buffer, sizeof buffer, "%.16g", value); + node << ryml::csubstr{buffer, static_cast(length < 0 ? 0 : length)}; + } + + void WriteNull() + { + node << nullptr; + } + + //! Always emits the key, using JSON null when the value is absent. + template + void WriteKeyValueOrNull(const std::string& name, const std::optional& value) + { + if (value.has_value()) + { + WriteKeyValue(name, *value); + return; + } + MakeImpl(node.append_child() << ryml::key(name)).WriteNull(); + } + + // --- enums ------------------------------------------------------------------------------ + + void Write(SystemState value) + { + Write(ToStringView(value)); + } + + void Write(ParticipantState value) + { + Write(ToStringView(value)); + } + + void Write(LabelKind value) + { + Write(ToStringView(value)); + } + + // --- DTOs ------------------------------------------------------------------------------- + + void Write(const SimulationConfigurationDto& obj); + void Write(const SimulationCreationRequestDto& obj); + void Write(const SystemStatusDto& obj); + void Write(const ParticipantStatusDto& obj); + void Write(const MatchingLabelDto& obj); + void Write(const DataSpecDto& obj); + void Write(const RpcSpecDto& obj); + void Write(const BulkSystemDto& obj); + void Write(const BulkControllerDto& obj); + void Write(const BulkDataServiceDto& obj); + void Write(const BulkRpcServiceDto& obj); + void Write(const BulkServiceInternalDto& obj); + void Write(const BulkParticipantDto& obj); + void Write(const BulkSimulationDto& obj); + void Write(const MetricsUpdateDto& obj); + + //! One overload covers all three metric kinds. + template + void Write(const MetricDataDto& obj) + { + MakeMap(); + WriteKeyValue("ts", obj.ts); + WriteKeyValue("pn", obj.pn); + WriteKeyValue("mn", obj.mn); + WriteKeyValue("mv", obj.mv); + } + +private: + void WriteQuoted(std::string_view value); +}; + +} // namespace Dashboard +} // namespace SilKit diff --git a/SilKit/source/dashboard/json/Test_DashboardJsonWriter.cpp b/SilKit/source/dashboard/json/Test_DashboardJsonWriter.cpp new file mode 100644 index 000000000..c558f313f --- /dev/null +++ b/SilKit/source/dashboard/json/Test_DashboardJsonWriter.cpp @@ -0,0 +1,407 @@ +// SPDX-FileCopyrightText: 2026 Vector Informatik GmbH +// +// SPDX-License-Identifier: MIT + +/*! Wire-format guard for the dashboard's JSON payloads. + * + * Every expected string in this file was derived mechanically from the output of the oatpp + * ObjectMapper that used to produce these payloads (configured as DashboardComponents did: + * includeNullFields on, beautifier off), by applying exactly three transformations - the three + * known, accepted differences between oatpp's serializer and rapidyaml's JSON emitter: + * + * 1. rapidyaml writes `"key": value`; oatpp wrote `"key":value`. + * 2. rapidyaml does not escape '/'; oatpp emitted it as `\/`. + * 3. rapidyaml emits no \uXXXX escapes, so non-ASCII stays raw UTF-8. The C0 control bytes + * rapidyaml cannot escape at all are replaced with U+FFFD by DashboardJsonWriter, because + * emitting them raw would produce invalid JSON. + * + * All three are semantically transparent to a JSON parser. Any *other* difference from what the + * dashboard service used to receive - a reordered key, a dropped field, a string emitted as a bare + * number, a differently formatted double - fails a test here. That is the point of the file, so + * when a test below fails, do not update the expectation without establishing that the dashboard + * service accepts the new bytes. + * + * Expectations are written as ASCII-only escapes so the file does not depend on source encoding. + */ + +#include "dashboard/json/DashboardJson.hpp" + +#include +#include + +#include "gtest/gtest.h" + +namespace SilKit { +namespace Dashboard { +namespace { + +auto MakeLabel(std::string key, std::string value, LabelKind kind) -> MatchingLabelDto +{ + MatchingLabelDto label{}; + label.key = std::move(key); + label.value = std::move(value); + label.kind = kind; + return label; +} + +auto MakeController(uint64_t id, std::string name, std::string networkName) -> BulkControllerDto +{ + BulkControllerDto controller{}; + controller.id = id; + controller.name = std::move(name); + controller.networkName = std::move(networkName); + return controller; +} + +// --- the payload IsBulkUpdateSupported() probes with ------------------------------------------ + +TEST(Test_DashboardJsonWriter, BulkSimulationDto_Default_MatchesTheBulkUpdateProbePayload) +{ + EXPECT_EQ(ToJson(BulkSimulationDto{}), + "{\"stopped\": null,\"system\": {\"statuses\": []},\"participants\": []}"); +} + +// --- simulation creation ---------------------------------------------------------------------- + +TEST(Test_DashboardJsonWriter, SimulationCreationRequestDto_Populated) +{ + SimulationCreationRequestDto request{}; + request.started = 1758000000000ULL; + request.configuration.connectUri = "silkit://myhost:1234"; + + EXPECT_EQ(ToJson(request), + "{\"started\": 1758000000000,\"configuration\": {\"connectUri\": \"silkit://myhost:1234\"}}"); +} + +TEST(Test_DashboardJsonWriter, SimulationCreationRequestDto_ExtremeValues) +{ + SimulationCreationRequestDto request{}; + request.started = std::numeric_limits::max(); + request.configuration.connectUri = ""; + + EXPECT_EQ(ToJson(request), "{\"started\": 18446744073709551615,\"configuration\": {\"connectUri\": \"\"}}"); +} + +// --- states ----------------------------------------------------------------------------------- + +TEST(Test_DashboardJsonWriter, SystemStatusDto_StateIsEmittedAsItsName) +{ + SystemStatusDto status{}; + status.state = SystemState::ReadyToRun; + + EXPECT_EQ(ToJson(status), "{\"state\": \"readytorun\"}"); +} + +TEST(Test_DashboardJsonWriter, ParticipantStatusDto_AnEmptyReasonStaysAnEmptyStringNotNull) +{ + ParticipantStatusDto status{}; + status.state = ParticipantState::Running; + status.enterReason = ""; + status.enterTime = 0; + + EXPECT_EQ(ToJson(status), "{\"state\": \"running\",\"enterReason\": \"\",\"enterTime\": 0}"); +} + +TEST(Test_DashboardJsonWriter, ParticipantStatusDto_EscapesQuotesBackslashesAndWhitespace) +{ + ParticipantStatusDto status{}; + status.state = ParticipantState::Error; + status.enterReason = "quote:\" back:\\ nl:\n cr:\r tab:\t"; + status.enterTime = std::numeric_limits::max(); + + EXPECT_EQ(ToJson(status), "{\"state\": \"error\",\"enterReason\": \"quote:\\\" back:\\\\ nl:\\n cr:\\r " + "tab:\\t\",\"enterTime\": 18446744073709551615}"); +} + +// oatpp escaped non-ASCII as \uXXXX; rapidyaml passes UTF-8 through. Both decode identically. +TEST(Test_DashboardJsonWriter, ParticipantStatusDto_NonAsciiIsEmittedAsRawUtf8) +{ + ParticipantStatusDto status{}; + status.state = ParticipantState::Stopped; + status.enterReason = "Fahrzeug-S\xc3\xbc" "d \xe2\x82\xac"; // "Fahrzeug-Sued EUR" in UTF-8 + status.enterTime = 1; + + EXPECT_EQ(ToJson(status), + "{\"state\": \"stopped\",\"enterReason\": \"Fahrzeug-S\xc3\xbc" "d \xe2\x82\xac\",\"enterTime\": 1}"); +} + +// rapidyaml escapes only \b \f \n \r \t, so any other C0 byte would be emitted raw and break the +// JSON. DashboardJsonWriter substitutes U+FFFD instead. +TEST(Test_DashboardJsonWriter, ParticipantStatusDto_UnescapableControlCharactersBecomeReplacementCharacters) +{ + ParticipantStatusDto status{}; + status.state = ParticipantState::Aborting; + status.enterReason = std::string("bell:\x07 vt:\x0b esc:\x1b"); + status.enterTime = 2; + + EXPECT_EQ(ToJson(status), "{\"state\": \"aborting\",\"enterReason\": \"bell:\xef\xbf\xbd vt:\xef\xbf\xbd " + "esc:\xef\xbf\xbd\",\"enterTime\": 2}"); +} + +// --- labels ----------------------------------------------------------------------------------- + +TEST(Test_DashboardJsonWriter, MatchingLabelDto_KindIsEmittedAsItsName) +{ + EXPECT_EQ(ToJson(MakeLabel("k", "v", LabelKind::Optional)), + "{\"key\": \"k\",\"value\": \"v\",\"kind\": \"optional\"}"); + EXPECT_EQ(ToJson(MakeLabel("k", "v", LabelKind::Mandatory)), + "{\"key\": \"k\",\"value\": \"v\",\"kind\": \"mandatory\"}"); +} + +// --- string-versus-number typing -------------------------------------------------------------- + +/*! rapidyaml emits a scalar unquoted unless it is told otherwise, and treats anything number-like + * as plain, so without the writer's forced quoting these string fields would silently turn into + * JSON numbers and change the payload's types. + */ +TEST(Test_DashboardJsonWriter, StringFields_ThatLookLikeNumbers_StayQuoted) +{ + EXPECT_EQ(ToJson(MakeController(0, "12345", "0")), + "{\"id\": 0,\"name\": \"12345\",\"networkName\": \"0\"}"); +} + +TEST(Test_DashboardJsonWriter, StringFields_ThatLookLikeOtherJsonLiterals_StayQuoted) +{ + BulkDataServiceDto service{}; + service.id = 7; + service.name = "3.14"; + service.networkName = "1_000"; + service.spec.topic = "1e5"; + service.spec.mediaType = "007"; + service.spec.labels.push_back(MakeLabel("0x10", "-0", LabelKind::Optional)); + service.spec.labels.push_back(MakeLabel("true", "null", LabelKind::Mandatory)); + + EXPECT_EQ(ToJson(service), + "{\"id\": 7,\"name\": \"3.14\",\"networkName\": \"1_000\",\"spec\": {\"topic\": \"1e5\",\"mediaType\": " + "\"007\",\"labels\": [{\"key\": \"0x10\",\"value\": \"-0\",\"kind\": \"optional\"},{\"key\": " + "\"true\",\"value\": \"null\",\"kind\": \"mandatory\"}]}}"); +} + +TEST(Test_DashboardJsonWriter, BulkServiceInternalDto_Populated) +{ + BulkServiceInternalDto service{}; + service.id = 9; + service.name = "n"; + service.networkName = "net"; + service.parentId = 10; + + EXPECT_EQ(ToJson(service), "{\"id\": 9,\"name\": \"n\",\"networkName\": \"net\",\"parentId\": 10}"); +} + +// --- the full bulk update --------------------------------------------------------------------- + +/*! Covers the whole nested structure, and in particular pins BulkParticipantDto's sixteen keys in + * their declared order; the dashboard service is sensitive to the payload shape. + */ +TEST(Test_DashboardJsonWriter, BulkSimulationDto_FullyPopulated) +{ + BulkSystemDto system{}; + for (const auto state : {SystemState::ServicesCreated, SystemState::Running, SystemState::Shutdown}) + { + SystemStatusDto status{}; + status.state = state; + system.statuses.push_back(status); + } + + BulkParticipantDto participant{}; + participant.name = "P1"; + { + ParticipantStatusDto status{}; + status.state = ParticipantState::Running; + status.enterReason = "ok"; + status.enterTime = 42; + participant.statuses.push_back(status); + } + participant.canControllers.push_back(MakeController(1, "can1", "CAN1")); + participant.ethernetControllers.push_back(MakeController(2, "eth1", "ETH1")); + participant.flexrayControllers.push_back(MakeController(3, "fr1", "FR1")); + participant.linControllers.push_back(MakeController(4, "lin1", "LIN1")); + { + BulkDataServiceDto publisher{}; + publisher.id = 5; + publisher.name = "pub"; + publisher.networkName = "N"; + publisher.spec.topic = "t"; + publisher.spec.mediaType = "m"; + publisher.spec.labels.push_back(MakeLabel("lk", "lv", LabelKind::Mandatory)); + participant.dataPublishers.push_back(publisher); + } + { + BulkServiceInternalDto internal{}; + internal.id = 6; + internal.name = "sub_int"; + internal.networkName = "N"; + internal.parentId = 5; + participant.dataSubscriberInternals.push_back(internal); + } + { + BulkRpcServiceDto client{}; + client.id = 11; + client.name = "client"; + client.networkName = "N"; + client.spec.functionName = "f"; + client.spec.mediaType = "m"; + participant.rpcClients.push_back(client); + } + participant.canNetworks.push_back("CAN1"); + participant.ethernetNetworks.push_back("ETH1"); + participant.flexrayNetworks.push_back("FR1"); + participant.linNetworks.push_back("LIN1"); + + BulkSimulationDto bulk{}; + bulk.stopped = std::numeric_limits::min(); + bulk.system = system; + bulk.participants.push_back(participant); + + EXPECT_EQ( + ToJson(bulk), + "{\"stopped\": -9223372036854775808,\"system\": {\"statuses\": [{\"state\": \"servicescreated\"},{\"state\": " + "\"running\"},{\"state\": \"shutdown\"}]},\"participants\": [{\"name\": \"P1\",\"statuses\": [{\"state\": " + "\"running\",\"enterReason\": \"ok\",\"enterTime\": 42}],\"canControllers\": [{\"id\": 1,\"name\": " + "\"can1\",\"networkName\": \"CAN1\"}],\"ethernetControllers\": [{\"id\": 2,\"name\": \"eth1\",\"networkName\": " + "\"ETH1\"}],\"flexrayControllers\": [{\"id\": 3,\"name\": \"fr1\",\"networkName\": " + "\"FR1\"}],\"linControllers\": [{\"id\": 4,\"name\": \"lin1\",\"networkName\": \"LIN1\"}],\"dataPublishers\": " + "[{\"id\": 5,\"name\": \"pub\",\"networkName\": \"N\",\"spec\": {\"topic\": \"t\",\"mediaType\": " + "\"m\",\"labels\": [{\"key\": \"lk\",\"value\": \"lv\",\"kind\": " + "\"mandatory\"}]}}],\"dataSubscribers\": [],\"dataSubscriberInternals\": [{\"id\": 6,\"name\": " + "\"sub_int\",\"networkName\": \"N\",\"parentId\": 5}],\"rpcClients\": [{\"id\": 11,\"name\": " + "\"client\",\"networkName\": \"N\",\"spec\": {\"functionName\": \"f\",\"mediaType\": \"m\",\"labels\": " + "[]}}],\"rpcServers\": [],\"rpcServerInternals\": [],\"canNetworks\": [\"CAN1\"],\"ethernetNetworks\": " + "[\"ETH1\"],\"flexrayNetworks\": [\"FR1\"],\"linNetworks\": [\"LIN1\"]}]}"); +} + +// --- metrics ---------------------------------------------------------------------------------- + +TEST(Test_DashboardJsonWriter, AttributeDataDto_EmitsBaseFieldsBeforeTheValue) +{ + AttributeDataDto attribute{}; + attribute.ts = 1700000000000LL; + attribute.pn = "P1"; + attribute.mn = {"a", "b"}; + attribute.mv = "plain"; + + EXPECT_EQ(ToJson(attribute), "{\"ts\": 1700000000000,\"pn\": \"P1\",\"mn\": [\"a\",\"b\"],\"mv\": \"plain\"}"); +} + +// A STRING_LIST metric carries its list as an opaque string, which must stay a JSON string. +TEST(Test_DashboardJsonWriter, AttributeDataDto_AStringListValueStaysANestedString) +{ + AttributeDataDto attribute{}; + attribute.ts = 1; + attribute.pn = "P1"; + attribute.mn = {"names"}; + attribute.mv = "[\"a\",\"b\"]"; + + EXPECT_EQ(ToJson(attribute), + "{\"ts\": 1,\"pn\": \"P1\",\"mn\": [\"names\"],\"mv\": \"[\\\"a\\\",\\\"b\\\"]\"}"); +} + +TEST(Test_DashboardJsonWriter, CounterDataDto_HandlesTheFullInt64Range) +{ + CounterDataDto counter{}; + counter.ts = 2; + counter.pn = "P1"; + counter.mn = {"c"}; + counter.mv = std::numeric_limits::min(); + + EXPECT_EQ(ToJson(counter), "{\"ts\": 2,\"pn\": \"P1\",\"mn\": [\"c\"],\"mv\": -9223372036854775808}"); +} + +TEST(Test_DashboardJsonWriter, StatisticDataDto_FormatsDoublesTheWayOatppDid) +{ + StatisticDataDto statistic{}; + statistic.ts = 3; + statistic.pn = "P1"; + statistic.mn = {"s"}; + statistic.mv = {1.0, 2.5, 0.1, 100.0}; + + EXPECT_EQ(ToJson(statistic), "{\"ts\": 3,\"pn\": \"P1\",\"mn\": [\"s\"],\"mv\": [1,2.5,0.1,100]}"); +} + +/*! oatpp formatted doubles with "%.16g", which truncates a shortest-round-trip 17-digit double. + * The writer keeps that format, so the values the dashboard receives do not change. + */ +TEST(Test_DashboardJsonWriter, StatisticDataDto_KeepsOatppsSixteenSignificantDigits) +{ + StatisticDataDto statistic{}; + statistic.ts = 4; + statistic.pn = "P1"; + statistic.mn = {"s17"}; + statistic.mv = {0.1234567890123456789, 1e-300, 1.7976931348623157e308}; + + EXPECT_EQ(ToJson(statistic), "{\"ts\": 4,\"pn\": \"P1\",\"mn\": [\"s17\"],\"mv\": " + "[0.1234567890123457,1e-300,1.797693134862316e+308]}"); +} + +TEST(Test_DashboardJsonWriter, MetricsUpdateDto_Empty) +{ + EXPECT_EQ(ToJson(MetricsUpdateDto{}), "{\"attributes\": [],\"counters\": [],\"statistics\": []}"); +} + +TEST(Test_DashboardJsonWriter, MetricsUpdateDto_OneOfEachKind) +{ + AttributeDataDto attribute{}; + attribute.ts = 1700000000000LL; + attribute.pn = "P1"; + attribute.mn = {"a", "b"}; + attribute.mv = "plain"; + + CounterDataDto counter{}; + counter.ts = 2; + counter.pn = "P1"; + counter.mn = {"c"}; + counter.mv = std::numeric_limits::min(); + + StatisticDataDto statistic{}; + statistic.ts = 3; + statistic.pn = "P1"; + statistic.mn = {"s"}; + statistic.mv = {1.0, 2.5, 0.1, 100.0}; + + MetricsUpdateDto update{}; + update.attributes.push_back(attribute); + update.counters.push_back(counter); + update.statistics.push_back(statistic); + + EXPECT_EQ(ToJson(update), + "{\"attributes\": [{\"ts\": 1700000000000,\"pn\": \"P1\",\"mn\": [\"a\",\"b\"],\"mv\": " + "\"plain\"}],\"counters\": [{\"ts\": 2,\"pn\": \"P1\",\"mn\": [\"c\"],\"mv\": " + "-9223372036854775808}],\"statistics\": [{\"ts\": 3,\"pn\": \"P1\",\"mn\": [\"s\"],\"mv\": " + "[1,2.5,0.1,100]}]}"); +} + +// --- response parsing ------------------------------------------------------------------------- + +TEST(Test_DashboardJsonWriter, ParseSimulationCreationResponse_ReadsTheId) +{ + EXPECT_EQ(ParseSimulationCreationResponse(R"({"id":42})"), 42u); +} + +TEST(Test_DashboardJsonWriter, ParseSimulationCreationResponse_HandlesTheFullUint64Range) +{ + EXPECT_EQ(ParseSimulationCreationResponse(R"({"id":18446744073709551615})"), + std::numeric_limits::max()); +} + +/*! oatpp rejected unknown fields, and the resulting exception propagated out of the dashboard's + * worker thread and killed it. Tolerating them is a deliberate behaviour change. + */ +TEST(Test_DashboardJsonWriter, ParseSimulationCreationResponse_IgnoresUnknownFields) +{ + EXPECT_EQ(ParseSimulationCreationResponse(R"({"id":7,"extra":"whatever","nested":{"a":1}})"), 7u); +} + +TEST(Test_DashboardJsonWriter, ParseSimulationCreationResponse_ReturnsNulloptForUnusableBodies) +{ + EXPECT_FALSE(ParseSimulationCreationResponse("").has_value()); + EXPECT_FALSE(ParseSimulationCreationResponse("not json at all").has_value()); + EXPECT_FALSE(ParseSimulationCreationResponse("[1,2,3]").has_value()); + EXPECT_FALSE(ParseSimulationCreationResponse(R"({"other":1})").has_value()); + EXPECT_FALSE(ParseSimulationCreationResponse(R"({"id":"not a number"})").has_value()); + EXPECT_FALSE(ParseSimulationCreationResponse(R"({"id":null})").has_value()); + EXPECT_FALSE(ParseSimulationCreationResponse(R"({"id":99999999999999999999999})").has_value()); +} + +} // namespace +} // namespace Dashboard +} // namespace SilKit diff --git a/SilKit/source/dashboard/service/SilKitToOatppMapper.cpp b/SilKit/source/dashboard/service/DashboardDtoMapper.cpp similarity index 51% rename from SilKit/source/dashboard/service/SilKitToOatppMapper.cpp rename to SilKit/source/dashboard/service/DashboardDtoMapper.cpp index 01c25add9..a10f0bc9b 100644 --- a/SilKit/source/dashboard/service/SilKitToOatppMapper.cpp +++ b/SilKit/source/dashboard/service/DashboardDtoMapper.cpp @@ -1,14 +1,15 @@ -// SPDX-FileCopyrightText: 2022-2025 Vector Informatik GmbH +// SPDX-FileCopyrightText: 2022-2026 Vector Informatik GmbH // // SPDX-License-Identifier: MIT -#include "dashboard/service/SilKitToOatppMapper.hpp" - -#include "config/YamlParser.hpp" -#include "util/StringHelpers.hpp" +#include "dashboard/service/DashboardDtoMapper.hpp" #include #include +#include + +#include "config/YamlParser.hpp" +#include "util/StringHelpers.hpp" namespace SilKit { namespace Dashboard { @@ -35,7 +36,8 @@ auto ToUInt64(const std::string& value) -> std::uint64_t } } -auto GetSupplementalDataValue(const Core::ServiceDescriptor& serviceDescriptor, const std::string& key) -> oatpp::String +auto GetSupplementalDataValue(const Core::ServiceDescriptor& serviceDescriptor, + const std::string& key) -> std::string { std::string str; if (!serviceDescriptor.GetSupplementalDataItem(key, str)) @@ -53,29 +55,10 @@ auto GetControllerType(const SilKit::Core::ServiceDescriptor& serviceDescriptor) auto GetSupplementalDataValueAsEndpointId(const SilKit::Core::ServiceDescriptor& serviceDescriptor, const std::string& key) -> SilKit::Core::EndpointId { - auto value = GetSupplementalDataValue(serviceDescriptor, key); - return ToUInt64(*value.get()); -} - -} // namespace - -static oatpp::Object CreateSimulationConfigurationDto(const std::string& connectUri) -{ - auto configuration = SimulationConfigurationDto::createShared(); - configuration->connectUri = connectUri; - return configuration; -} - -oatpp::Object SilKitToOatppMapper::CreateSimulationCreationRequestDto( - const std::string& connectUri, uint64_t start) -{ - auto simulation = SimulationCreationRequestDto::createShared(); - simulation->started = start; - simulation->configuration = CreateSimulationConfigurationDto(connectUri); - return simulation; + return ToUInt64(GetSupplementalDataValue(serviceDescriptor, key)); } -static SystemState MapSystemState(Services::Orchestration::SystemState systemState) +auto MapSystemState(Services::Orchestration::SystemState systemState) -> SystemState { switch (systemState) { @@ -110,15 +93,7 @@ static SystemState MapSystemState(Services::Orchestration::SystemState systemSta } } -oatpp::Object SilKitToOatppMapper::CreateSystemStatusDto( - Services::Orchestration::SystemState systemState) -{ - auto status = SystemStatusDto::createShared(); - status->state = MapSystemState(systemState); - return status; -} - -static ParticipantState MapParticipantState(Services::Orchestration::ParticipantState state) +auto MapParticipantState(Services::Orchestration::ParticipantState state) -> ParticipantState { switch (state) { @@ -153,18 +128,7 @@ static ParticipantState MapParticipantState(Services::Orchestration::Participant } } -oatpp::Object SilKitToOatppMapper::CreateParticipantStatusDto( - const Services::Orchestration::ParticipantStatus& participantStatus) -{ - auto status = ParticipantStatusDto::createShared(); - status->state = MapParticipantState(participantStatus.state); - status->enterReason = participantStatus.enterReason; - status->enterTime = - std::chrono::duration_cast(participantStatus.enterTime.time_since_epoch()).count(); - return status; -} - -static LabelKind MapLabelKind(Services::MatchingLabel::Kind labelKind) +auto MapLabelKind(Services::MatchingLabel::Kind labelKind) -> LabelKind { switch (labelKind) { @@ -177,98 +141,113 @@ static LabelKind MapLabelKind(Services::MatchingLabel::Kind labelKind) } } -static oatpp::Object CreateMatchingLabelDto(const Services::MatchingLabel& matchingLabel) +auto CreateMatchingLabelDto(const Services::MatchingLabel& matchingLabel) -> MatchingLabelDto { - auto label = oatpp::Object::createShared(); - label->key = matchingLabel.key; - label->value = matchingLabel.value; - label->kind = MapLabelKind(matchingLabel.kind); + MatchingLabelDto label{}; + label.key = matchingLabel.key; + label.value = matchingLabel.value; + label.kind = MapLabelKind(matchingLabel.kind); return label; } -static oatpp::Vector> CreateMatchingLabels(const Core::ServiceDescriptor& serviceDescriptor, - const std::string& labelsKey) +auto CreateMatchingLabels(const Core::ServiceDescriptor& serviceDescriptor, + const std::string& labelsKey) -> std::vector { - auto labels = oatpp::Vector>::createShared(); std::string labelsStr; if (!serviceDescriptor.GetSupplementalDataItem(labelsKey, labelsStr)) { throw SilKitError{"Missing key " + labelsKey + " in supplementalData"}; } - std::vector matchingLabels = - Config::Deserialize>(labelsStr); - std::vector::iterator it; - for (it = matchingLabels.begin(); it != matchingLabels.end(); it++) + + std::vector labels; + for (const auto& matchingLabel : Config::Deserialize>(labelsStr)) { - labels->push_back(CreateMatchingLabelDto(*it)); + labels.emplace_back(CreateMatchingLabelDto(matchingLabel)); } return labels; } -oatpp::Object SilKitToOatppMapper::CreateServiceDto(const Core::ServiceDescriptor& serviceDescriptor) +auto CreateDataSpecDto(const Core::ServiceDescriptor& serviceDescriptor, const std::string& topicKey, + const std::string& mediaTypeKey, const std::string& labelsKey) -> DataSpecDto { - auto controller = ServiceDto::createShared(); - controller->name = serviceDescriptor.GetServiceName(); - controller->networkName = serviceDescriptor.GetNetworkName(); - return controller; + DataSpecDto dataSpec{}; + dataSpec.topic = GetSupplementalDataValue(serviceDescriptor, topicKey); + dataSpec.mediaType = GetSupplementalDataValue(serviceDescriptor, mediaTypeKey); + dataSpec.labels = CreateMatchingLabels(serviceDescriptor, labelsKey); + return dataSpec; } -static oatpp::Object CreateDataSpecDto(const Core::ServiceDescriptor& serviceDescriptor, - const std::string& topicKey, const std::string& mediaTypeKey, - const std::string& labelsKey) +auto CreateRpcSpecDto(const Core::ServiceDescriptor& serviceDescriptor, const std::string& functionNameKey, + const std::string& mediaTypeKey, const std::string& labelsKey) -> RpcSpecDto { - auto dataSpec = DataSpecDto::createShared(); - dataSpec->topic = GetSupplementalDataValue(serviceDescriptor, topicKey); - dataSpec->mediaType = GetSupplementalDataValue(serviceDescriptor, mediaTypeKey); - dataSpec->labels = CreateMatchingLabels(serviceDescriptor, labelsKey); - return dataSpec; + RpcSpecDto rpcSpec{}; + rpcSpec.functionName = GetSupplementalDataValue(serviceDescriptor, functionNameKey); + rpcSpec.mediaType = GetSupplementalDataValue(serviceDescriptor, mediaTypeKey); + rpcSpec.labels = CreateMatchingLabels(serviceDescriptor, labelsKey); + return rpcSpec; } +} // namespace -static oatpp::Object CreateRpcSpecDto(const Core::ServiceDescriptor& serviceDescriptor, - const std::string& functionNameKey, const std::string& mediaTypeKey, - const std::string& labelsKey) +auto DashboardDtoMapper::CreateSimulationCreationRequestDto(const std::string& connectUri, + uint64_t start) -> SimulationCreationRequestDto { - auto rpcSpec = RpcSpecDto::createShared(); - rpcSpec->functionName = GetSupplementalDataValue(serviceDescriptor, functionNameKey); - rpcSpec->mediaType = GetSupplementalDataValue(serviceDescriptor, mediaTypeKey); - rpcSpec->labels = CreateMatchingLabels(serviceDescriptor, labelsKey); - return rpcSpec; + SimulationCreationRequestDto simulation{}; + simulation.started = start; + simulation.configuration.connectUri = connectUri; + return simulation; } -auto SilKitToOatppMapper::CreateBulkControllerDto(const ServiceDescriptor& serviceDescriptor) - -> Object +auto DashboardDtoMapper::CreateSystemStatusDto(Services::Orchestration::SystemState systemState) -> SystemStatusDto { - auto dto = BulkControllerDto::createShared(); + SystemStatusDto status{}; + status.state = MapSystemState(systemState); + return status; +} - dto->id = serviceDescriptor.GetServiceId(); - dto->name = serviceDescriptor.GetServiceName(); - dto->networkName = serviceDescriptor.GetNetworkName(); +auto DashboardDtoMapper::CreateParticipantStatusDto( + const Services::Orchestration::ParticipantStatus& participantStatus) -> ParticipantStatusDto +{ + ParticipantStatusDto status{}; + status.state = MapParticipantState(participantStatus.state); + status.enterReason = participantStatus.enterReason; + status.enterTime = static_cast( + std::chrono::duration_cast(participantStatus.enterTime.time_since_epoch()) + .count()); + return status; +} + +auto DashboardDtoMapper::CreateBulkControllerDto(const ServiceDescriptor& serviceDescriptor) -> BulkControllerDto +{ + BulkControllerDto dto{}; + + dto.id = serviceDescriptor.GetServiceId(); + dto.name = serviceDescriptor.GetServiceName(); + dto.networkName = serviceDescriptor.GetNetworkName(); return dto; } -auto SilKitToOatppMapper::CreateBulkDataServiceDto(const ServiceDescriptor& serviceDescriptor) - -> Object +auto DashboardDtoMapper::CreateBulkDataServiceDto(const ServiceDescriptor& serviceDescriptor) -> BulkDataServiceDto { - auto dto = BulkDataServiceDto::createShared(); + BulkDataServiceDto dto{}; - dto->id = serviceDescriptor.GetServiceId(); - dto->name = serviceDescriptor.GetServiceName(); - dto->networkName = serviceDescriptor.GetNetworkName(); + dto.id = serviceDescriptor.GetServiceId(); + dto.name = serviceDescriptor.GetServiceName(); + dto.networkName = serviceDescriptor.GetNetworkName(); const auto controllerType = GetControllerType(serviceDescriptor); if (controllerType == SilKit::Core::Discovery::controllerTypeDataSubscriber) { - dto->spec = CreateDataSpecDto(serviceDescriptor, SilKit::Core::Discovery::supplKeyDataSubscriberTopic, - SilKit::Core::Discovery::supplKeyDataSubscriberMediaType, - SilKit::Core::Discovery::supplKeyDataSubscriberSubLabels); + dto.spec = CreateDataSpecDto(serviceDescriptor, SilKit::Core::Discovery::supplKeyDataSubscriberTopic, + SilKit::Core::Discovery::supplKeyDataSubscriberMediaType, + SilKit::Core::Discovery::supplKeyDataSubscriberSubLabels); } else if (controllerType == SilKit::Core::Discovery::controllerTypeDataPublisher) { - dto->spec = CreateDataSpecDto(serviceDescriptor, SilKit::Core::Discovery::supplKeyDataPublisherTopic, - SilKit::Core::Discovery::supplKeyDataPublisherMediaType, - SilKit::Core::Discovery::supplKeyDataPublisherPubLabels); + dto.spec = CreateDataSpecDto(serviceDescriptor, SilKit::Core::Discovery::supplKeyDataPublisherTopic, + SilKit::Core::Discovery::supplKeyDataPublisherMediaType, + SilKit::Core::Discovery::supplKeyDataPublisherPubLabels); } else { @@ -278,27 +257,26 @@ auto SilKitToOatppMapper::CreateBulkDataServiceDto(const ServiceDescriptor& serv return dto; } -auto SilKitToOatppMapper::CreateBulkRpcServiceDto(const ServiceDescriptor& serviceDescriptor) - -> Object +auto DashboardDtoMapper::CreateBulkRpcServiceDto(const ServiceDescriptor& serviceDescriptor) -> BulkRpcServiceDto { - auto dto = BulkRpcServiceDto::createShared(); + BulkRpcServiceDto dto{}; - dto->id = serviceDescriptor.GetServiceId(); - dto->name = serviceDescriptor.GetServiceName(); - dto->networkName = serviceDescriptor.GetNetworkName(); + dto.id = serviceDescriptor.GetServiceId(); + dto.name = serviceDescriptor.GetServiceName(); + dto.networkName = serviceDescriptor.GetNetworkName(); const auto controllerType = GetControllerType(serviceDescriptor); if (controllerType == SilKit::Core::Discovery::controllerTypeRpcClient) { - dto->spec = CreateRpcSpecDto(serviceDescriptor, SilKit::Core::Discovery::supplKeyRpcClientFunctionName, - SilKit::Core::Discovery::supplKeyRpcClientMediaType, - SilKit::Core::Discovery::supplKeyRpcClientLabels); + dto.spec = CreateRpcSpecDto(serviceDescriptor, SilKit::Core::Discovery::supplKeyRpcClientFunctionName, + SilKit::Core::Discovery::supplKeyRpcClientMediaType, + SilKit::Core::Discovery::supplKeyRpcClientLabels); } else if (controllerType == SilKit::Core::Discovery::controllerTypeRpcServer) { - dto->spec = CreateRpcSpecDto(serviceDescriptor, SilKit::Core::Discovery::supplKeyRpcServerFunctionName, - SilKit::Core::Discovery::supplKeyRpcServerMediaType, - SilKit::Core::Discovery::supplKeyRpcServerLabels); + dto.spec = CreateRpcSpecDto(serviceDescriptor, SilKit::Core::Discovery::supplKeyRpcServerFunctionName, + SilKit::Core::Discovery::supplKeyRpcServerMediaType, + SilKit::Core::Discovery::supplKeyRpcServerLabels); } else { @@ -308,24 +286,24 @@ auto SilKitToOatppMapper::CreateBulkRpcServiceDto(const ServiceDescriptor& servi return dto; } -auto SilKitToOatppMapper::CreateBulkServiceInternalDto(const ServiceDescriptor& serviceDescriptor) - -> Object +auto DashboardDtoMapper::CreateBulkServiceInternalDto(const ServiceDescriptor& serviceDescriptor) + -> BulkServiceInternalDto { - auto dto = BulkServiceInternalDto::createShared(); + BulkServiceInternalDto dto{}; - dto->id = serviceDescriptor.GetServiceId(); - dto->name = serviceDescriptor.GetServiceName(); - dto->networkName = serviceDescriptor.GetNetworkName(); + dto.id = serviceDescriptor.GetServiceId(); + dto.name = serviceDescriptor.GetServiceName(); + dto.networkName = serviceDescriptor.GetNetworkName(); const auto controllerType = GetControllerType(serviceDescriptor); if (controllerType == SilKit::Core::Discovery::controllerTypeDataSubscriberInternal) { - dto->parentId = GetSupplementalDataValueAsEndpointId( + dto.parentId = GetSupplementalDataValueAsEndpointId( serviceDescriptor, SilKit::Core::Discovery::supplKeyDataSubscriberInternalParentServiceID); } else if (controllerType == SilKit::Core::Discovery::controllerTypeRpcServerInternal) { - dto->parentId = GetSupplementalDataValueAsEndpointId( + dto.parentId = GetSupplementalDataValueAsEndpointId( serviceDescriptor, SilKit::Core::Discovery::supplKeyRpcServerInternalParentServiceID); } else @@ -336,32 +314,33 @@ auto SilKitToOatppMapper::CreateBulkServiceInternalDto(const ServiceDescriptor& return dto; } -auto SilKitToOatppMapper::CreateBulkSimulationDto(const DashboardBulkUpdate& bulkUpdate) -> Object +auto DashboardDtoMapper::CreateBulkSimulationDto(const DashboardBulkUpdate& bulkUpdate) -> BulkSimulationDto { - auto bulkSimulationDto = BulkSimulationDto::createShared(); + BulkSimulationDto bulkSimulationDto{}; if (bulkUpdate.stopped) { - bulkSimulationDto->stopped = static_cast(*bulkUpdate.stopped); + bulkSimulationDto.stopped = static_cast(*bulkUpdate.stopped); } for (const auto& systemState : bulkUpdate.systemStates) { - bulkSimulationDto->system->statuses->emplace_back(CreateSystemStatusDto(systemState)); + bulkSimulationDto.system.statuses.emplace_back(CreateSystemStatusDto(systemState)); } - std::unordered_map nameToBulkParticipantDto; + std::unordered_map nameToBulkParticipantDto; - const auto getOrCreateParticipantDto = [&nameToBulkParticipantDto](const std::string& name) -> BulkParticipantDto& { + const auto getOrCreateParticipantDto = [&nameToBulkParticipantDto](const std::string& name) + -> BulkParticipantDto& { auto it = nameToBulkParticipantDto.find(name); if (it == nameToBulkParticipantDto.end()) { - auto dto = BulkParticipantDto::createShared(); - dto->name = name; + BulkParticipantDto dto{}; + dto.name = name; it = nameToBulkParticipantDto.emplace(name, std::move(dto)).first; } - return *it->second.get(); + return it->second; }; for (const auto& participantConnectionInformation : bulkUpdate.participantConnectionInformations) @@ -372,7 +351,7 @@ auto SilKitToOatppMapper::CreateBulkSimulationDto(const DashboardBulkUpdate& bul for (const auto& participantStatus : bulkUpdate.participantStatuses) { auto& dto = getOrCreateParticipantDto(participantStatus.participantName); - dto.statuses->emplace_back(CreateParticipantStatusDto(participantStatus)); + dto.statuses.emplace_back(CreateParticipantStatusDto(participantStatus)); } for (const auto& serviceData : bulkUpdate.serviceDatas) @@ -390,65 +369,67 @@ auto SilKitToOatppMapper::CreateBulkSimulationDto(const DashboardBulkUpdate& bul for (auto& pair : nameToBulkParticipantDto) { - bulkSimulationDto->participants->emplace_back(pair.second); + bulkSimulationDto.participants.emplace_back(std::move(pair.second)); } return bulkSimulationDto; } -auto SilKitToOatppMapper::CreateMetricsUpdateDto( - const std::string& participantName, const VSilKit::MetricsUpdate& metricsUpdate) -> Object +auto DashboardDtoMapper::CreateMetricsUpdateDto(const std::string& participantName, + const VSilKit::MetricsUpdate& metricsUpdate) -> MetricsUpdateDto { - auto objectMapper = oatpp::parser::json::mapping::ObjectMapper::createShared(); - auto dto = MetricsUpdateDto::createShared(); + MetricsUpdateDto dto{}; for (const auto& metricData : metricsUpdate.metrics) { - auto setValues = [&](auto&& dataDto, auto&& metricData) { - dataDto->pn = participantName; - dataDto->ts = metricData.timestamp; - auto&& nameList = SilKit::Util::SplitString(metricData.name, "/"); - std::copy(nameList.begin(), nameList.end(), std::back_inserter(*dataDto->mn)); + auto setValues = [&participantName](auto& dataDto, const auto& metric) { + dataDto.pn = participantName; + dataDto.ts = metric.timestamp; + auto&& nameList = SilKit::Util::SplitString(metric.name, "/"); + std::copy(nameList.begin(), nameList.end(), std::back_inserter(dataDto.mn)); }; switch (metricData.kind) { case VSilKit::MetricKind::COUNTER: { - auto dataDto = CounterDataDto::createShared(); + CounterDataDto dataDto{}; setValues(dataDto, metricData); - dataDto->mv = objectMapper->readFromString(metricData.value); - dto->counters->emplace_back(std::move(dataDto)); + // MetricsManager formats counters with std::to_string, so this round-trips exactly. + dataDto.mv = Config::Deserialize(metricData.value); + dto.counters.emplace_back(std::move(dataDto)); break; } case VSilKit::MetricKind::STATISTIC: { - auto dataDto = StatisticDataDto::createShared(); + StatisticDataDto dataDto{}; setValues(dataDto, metricData); - dataDto->mv = objectMapper->readFromString>(metricData.value); - dto->statistics->emplace_back(std::move(dataDto)); + /* MetricsManager emits shortest-round-trip doubles, which can need 17 significant + * digits, while the writer re-emits with "%.16g". oatpp did exactly the same + * parse-then-reformat, so the (slightly lossy) values on the wire do not change. */ + dataDto.mv = Config::Deserialize>(metricData.value); + dto.statistics.emplace_back(std::move(dataDto)); break; } case VSilKit::MetricKind::ATTRIBUTE: case VSilKit::MetricKind::STRING_LIST: { - auto dataDto = AttributeDataDto::createShared(); + AttributeDataDto dataDto{}; setValues(dataDto, metricData); - dataDto->mv = metricData.value; - dto->attributes->emplace_back(std::move(dataDto)); + dataDto.mv = metricData.value; + dto.attributes.emplace_back(std::move(dataDto)); break; } default: throw SilKit::SilKitError{"MetricsUpdate unknown MetricKind"}; - break; } } return dto; } -// SilKitToOatppMapper Private Methods +// DashboardDtoMapper Private Methods -void SilKitToOatppMapper::ProcessServiceDiscovery(BulkParticipantDto& dto, const ServiceDescriptor& serviceDescriptor) +void DashboardDtoMapper::ProcessServiceDiscovery(BulkParticipantDto& dto, const ServiceDescriptor& serviceDescriptor) { switch (serviceDescriptor.GetServiceType()) { @@ -463,53 +444,53 @@ void SilKitToOatppMapper::ProcessServiceDiscovery(BulkParticipantDto& dto, const } } -void SilKitToOatppMapper::ProcessControllerDiscovery(BulkParticipantDto& dto, - const ServiceDescriptor& serviceDescriptor) +void DashboardDtoMapper::ProcessControllerDiscovery(BulkParticipantDto& dto, + const ServiceDescriptor& serviceDescriptor) { const auto controllerType = GetControllerType(serviceDescriptor); // Bus Controllers if (controllerType == SilKit::Core::Discovery::controllerTypeCan) { - dto.canControllers->emplace_back(CreateBulkControllerDto(serviceDescriptor)); + dto.canControllers.emplace_back(CreateBulkControllerDto(serviceDescriptor)); } else if (controllerType == SilKit::Core::Discovery::controllerTypeEthernet) { - dto.ethernetControllers->emplace_back(CreateBulkControllerDto(serviceDescriptor)); + dto.ethernetControllers.emplace_back(CreateBulkControllerDto(serviceDescriptor)); } else if (controllerType == SilKit::Core::Discovery::controllerTypeFlexray) { - dto.flexrayControllers->emplace_back(CreateBulkControllerDto(serviceDescriptor)); + dto.flexrayControllers.emplace_back(CreateBulkControllerDto(serviceDescriptor)); } else if (controllerType == SilKit::Core::Discovery::controllerTypeLin) { - dto.linControllers->emplace_back(CreateBulkControllerDto(serviceDescriptor)); + dto.linControllers.emplace_back(CreateBulkControllerDto(serviceDescriptor)); } // PubSub Services else if (controllerType == SilKit::Core::Discovery::controllerTypeDataPublisher) { - dto.dataPublishers->emplace_back(CreateBulkDataServiceDto(serviceDescriptor)); + dto.dataPublishers.emplace_back(CreateBulkDataServiceDto(serviceDescriptor)); } else if (controllerType == SilKit::Core::Discovery::controllerTypeDataSubscriber) { - dto.dataSubscribers->emplace_back(CreateBulkDataServiceDto(serviceDescriptor)); + dto.dataSubscribers.emplace_back(CreateBulkDataServiceDto(serviceDescriptor)); } else if (controllerType == SilKit::Core::Discovery::controllerTypeDataSubscriberInternal) { - dto.dataSubscriberInternals->emplace_back(CreateBulkServiceInternalDto(serviceDescriptor)); + dto.dataSubscriberInternals.emplace_back(CreateBulkServiceInternalDto(serviceDescriptor)); } // RPC Services else if (controllerType == SilKit::Core::Discovery::controllerTypeRpcClient) { - dto.rpcClients->emplace_back(CreateBulkRpcServiceDto(serviceDescriptor)); + dto.rpcClients.emplace_back(CreateBulkRpcServiceDto(serviceDescriptor)); } else if (controllerType == SilKit::Core::Discovery::controllerTypeRpcServer) { - dto.rpcServers->emplace_back(CreateBulkRpcServiceDto(serviceDescriptor)); + dto.rpcServers.emplace_back(CreateBulkRpcServiceDto(serviceDescriptor)); } else if (controllerType == SilKit::Core::Discovery::controllerTypeRpcServerInternal) { - dto.rpcServerInternals->emplace_back(CreateBulkServiceInternalDto(serviceDescriptor)); + dto.rpcServerInternals.emplace_back(CreateBulkServiceInternalDto(serviceDescriptor)); } // Everything Else else @@ -518,21 +499,21 @@ void SilKitToOatppMapper::ProcessControllerDiscovery(BulkParticipantDto& dto, } } -void SilKitToOatppMapper::ProcessLinkDiscovery(BulkParticipantDto& dto, const ServiceDescriptor& serviceDescriptor) +void DashboardDtoMapper::ProcessLinkDiscovery(BulkParticipantDto& dto, const ServiceDescriptor& serviceDescriptor) { switch (serviceDescriptor.GetNetworkType()) { case SilKit::Config::NetworkType::CAN: - dto.canNetworks->emplace_back(serviceDescriptor.GetNetworkName()); + dto.canNetworks.emplace_back(serviceDescriptor.GetNetworkName()); break; case SilKit::Config::NetworkType::Ethernet: - dto.ethernetNetworks->emplace_back(serviceDescriptor.GetNetworkName()); + dto.ethernetNetworks.emplace_back(serviceDescriptor.GetNetworkName()); break; case SilKit::Config::NetworkType::FlexRay: - dto.flexrayNetworks->emplace_back(serviceDescriptor.GetNetworkName()); + dto.flexrayNetworks.emplace_back(serviceDescriptor.GetNetworkName()); break; case SilKit::Config::NetworkType::LIN: - dto.linNetworks->emplace_back(serviceDescriptor.GetNetworkName()); + dto.linNetworks.emplace_back(serviceDescriptor.GetNetworkName()); break; default: break; diff --git a/SilKit/source/dashboard/service/DashboardDtoMapper.hpp b/SilKit/source/dashboard/service/DashboardDtoMapper.hpp new file mode 100644 index 000000000..37e2c6356 --- /dev/null +++ b/SilKit/source/dashboard/service/DashboardDtoMapper.hpp @@ -0,0 +1,41 @@ +// SPDX-FileCopyrightText: 2022 Vector Informatik GmbH +// +// SPDX-License-Identifier: MIT + +#pragma once + +#include "core/internal/ServiceDescriptor.hpp" + +#include "dashboard/service/IDashboardDtoMapper.hpp" + +namespace SilKit { +namespace Dashboard { + +class DashboardDtoMapper : public IDashboardDtoMapper +{ + using ServiceDescriptor = SilKit::Core::ServiceDescriptor; + +public: + auto CreateSimulationCreationRequestDto(const std::string& connectUri, + uint64_t start) -> SimulationCreationRequestDto override; + auto CreateBulkSimulationDto(const DashboardBulkUpdate& bulkUpdate) -> BulkSimulationDto override; + auto CreateMetricsUpdateDto(const std::string& participantName, + const VSilKit::MetricsUpdate& metricsUpdate) -> MetricsUpdateDto override; + +public: // exercised directly by the tests + auto CreateSystemStatusDto(Services::Orchestration::SystemState systemState) -> SystemStatusDto; + auto CreateParticipantStatusDto(const Services::Orchestration::ParticipantStatus& participantStatus) + -> ParticipantStatusDto; + auto CreateBulkControllerDto(const ServiceDescriptor& serviceDescriptor) -> BulkControllerDto; + auto CreateBulkDataServiceDto(const ServiceDescriptor& serviceDescriptor) -> BulkDataServiceDto; + auto CreateBulkRpcServiceDto(const ServiceDescriptor& serviceDescriptor) -> BulkRpcServiceDto; + auto CreateBulkServiceInternalDto(const ServiceDescriptor& serviceDescriptor) -> BulkServiceInternalDto; + +private: + void ProcessServiceDiscovery(BulkParticipantDto& dto, const ServiceDescriptor& serviceDescriptor); + void ProcessControllerDiscovery(BulkParticipantDto& dto, const ServiceDescriptor& serviceDescriptor); + void ProcessLinkDiscovery(BulkParticipantDto& dto, const ServiceDescriptor& serviceDescriptor); +}; + +} // namespace Dashboard +} // namespace SilKit diff --git a/SilKit/source/dashboard/service/DashboardRestClient.cpp b/SilKit/source/dashboard/service/DashboardRestClient.cpp index ca62e9eb7..9a7e8df0e 100644 --- a/SilKit/source/dashboard/service/DashboardRestClient.cpp +++ b/SilKit/source/dashboard/service/DashboardRestClient.cpp @@ -1,93 +1,68 @@ -// SPDX-FileCopyrightText: 2022-2025 Vector Informatik GmbH +// SPDX-FileCopyrightText: 2022-2026 Vector Informatik GmbH // // SPDX-License-Identifier: MIT -#include "silkit/services/orchestration/string_utils.hpp" -#include "silkit/SilKit.hpp" #include "dashboard/service/DashboardRestClient.hpp" -#include "dashboard/client/DashboardComponents.hpp" + +#include + +#include "silkit/SilKit.hpp" +#include "silkit/services/orchestration/string_utils.hpp" + +#include "dashboard/client/DashboardSystemServiceClient.hpp" +#include "dashboard/http/AsioHttpClient.hpp" +#include "dashboard/http/RetryingHttpClient.hpp" +#include "dashboard/service/DashboardDtoMapper.hpp" #include "services/logging/LoggerMessage.hpp" #include "util/Uri.hpp" -#include "dashboard/service/SilKitToOatppMapper.hpp" -#include "dashboard/client/DashboardSystemServiceClient.hpp" using SilKit::Services::Logging::Level; -using SilKit::Services::Logging::Topic; using SilKit::Services::Logging::LoggerMessage; +using SilKit::Services::Logging::Topic; namespace SilKit { namespace Dashboard { -LibraryInitializer::LibraryInitializer() -{ - oatpp::base::Environment::init(); -} -LibraryInitializer::~LibraryInitializer() +DashboardRestClient::DashboardRestClient(Services::Logging::ILoggerInternal* logger, + const std::string& dashboardServerUri) + : _logger(logger) { - oatpp::base::Environment::destroy(); + _dtoMapper = std::make_shared(); + + const auto uri = SilKit::Core::Uri::Parse(dashboardServerUri); + auto transport = std::make_shared(logger, uri.Host(), uri.Port()); + _httpClient = std::make_shared(std::move(transport)); + _serviceClient = std::make_shared(_logger, _httpClient); } -DashboardRestClient::DashboardRestClient(Services::Logging::ILoggerInternal* logger, const std::string& dashboardServerUri) +DashboardRestClient::DashboardRestClient(Services::Logging::ILoggerInternal* logger, + std::shared_ptr serviceClient, + std::shared_ptr mapper) : _logger(logger) + , _dtoMapper(std::move(mapper)) + , _serviceClient(std::move(serviceClient)) { - _libraryInit = std::make_shared(); - - _silKitToOatppMapper = std::make_shared(); - - auto uri = SilKit::Core::Uri::Parse(dashboardServerUri); - SilKit::Dashboard::DashboardComponents dashboardComponents{uri.Host(), uri.Port()}; - auto objectMapper = OATPP_GET_COMPONENT(std::shared_ptr); - _retryPolicy = std::make_shared(3); - OATPP_COMPONENT(std::shared_ptr, connectionProvider); - auto requestExecutor = oatpp::web::client::HttpRequestExecutor::createShared(connectionProvider, _retryPolicy); - _apiClient = SilKit::Dashboard::DashboardSystemApiClient::createShared(requestExecutor, objectMapper); - _serviceClient = - std::make_shared(_logger, _apiClient, objectMapper); } -DashboardRestClient::DashboardRestClient(std::shared_ptr libraryInit, - Services::Logging::ILoggerInternal* logger, - std::shared_ptr serviceClient, - std::shared_ptr mapper) - +DashboardRestClient::~DashboardRestClient() { - _logger = logger; - _libraryInit = libraryInit; - _serviceClient = serviceClient; - _silKitToOatppMapper = mapper; - - auto uri = SilKit::Core::Uri::Parse("http://localhost:1234"); - SilKit::Dashboard::DashboardComponents dashboardComponents{uri.Host(), uri.Port()}; - auto objectMapper = OATPP_GET_COMPONENT(std::shared_ptr); - _retryPolicy = std::make_shared(3); - OATPP_COMPONENT(std::shared_ptr, connectionProvider); - auto requestExecutor = oatpp::web::client::HttpRequestExecutor::createShared(connectionProvider, _retryPolicy); - _apiClient = SilKit::Dashboard::DashboardSystemApiClient::createShared(requestExecutor, objectMapper); + Abort(); + _dtoMapper.reset(); + _serviceClient.reset(); + _httpClient.reset(); } -DashboardRestClient::~DashboardRestClient() +void DashboardRestClient::Abort() { - if (_retryPolicy != nullptr) + if (_httpClient != nullptr) { - _retryPolicy->AbortAllRetries(); - _retryPolicy.reset(); + _httpClient->Abort(); } - _silKitToOatppMapper.reset(); - _apiClient.reset(); - _serviceClient.reset(); - _libraryInit.reset(); } bool DashboardRestClient::IsBulkUpdateSupported() { - auto bulkSimulationDto = SilKit::Dashboard::BulkSimulationDto::createShared(); - const auto response = _apiClient->updateSimulation(oatpp::UInt64{std::uint64_t{0}}, bulkSimulationDto); - if (response) - { - const auto statusCode = response->getStatusCode(); - return 200 <= statusCode && statusCode < 300; - } - return false; + return _serviceClient->CheckBulkUpdateSupported(); } uint64_t DashboardRestClient::OnSimulationStart(const std::string& connectUri, uint64_t time) @@ -95,14 +70,14 @@ uint64_t DashboardRestClient::OnSimulationStart(const std::string& connectUri, u _logger->MakeMessage(Level::Info, TopicOf(*this)) .SetMessage("Dashboard: creating simulation {} {}", connectUri, time) .Dispatch(); - auto simulation = - _serviceClient->CreateSimulation(_silKitToOatppMapper->CreateSimulationCreationRequestDto(connectUri, time)); - if (simulation) + const auto simulationId = + _serviceClient->CreateSimulation(_dtoMapper->CreateSimulationCreationRequestDto(connectUri, time)); + if (simulationId.has_value()) { _logger->MakeMessage(Level::Info, TopicOf(*this)) - .SetMessage("Dashboard: created simulation with id {}", *simulation->id.get()) + .SetMessage("Dashboard: created simulation with id {}", *simulationId) .Dispatch(); - return simulation->id; + return *simulationId; } _logger->MakeMessage(Level::Warn, TopicOf(*this)) .SetMessage("Dashboard: creating simulation failed") @@ -112,14 +87,13 @@ uint64_t DashboardRestClient::OnSimulationStart(const std::string& connectUri, u void DashboardRestClient::OnBulkUpdate(uint64_t simulationId, const DashboardBulkUpdate& bulkUpdate) { - _serviceClient->UpdateSimulation(simulationId, _silKitToOatppMapper->CreateBulkSimulationDto(bulkUpdate)); + _serviceClient->UpdateSimulation(simulationId, _dtoMapper->CreateBulkSimulationDto(bulkUpdate)); } void DashboardRestClient::OnMetricsUpdate(uint64_t simulationId, const std::string& origin, const VSilKit::MetricsUpdate& metricsUpdate) { - _serviceClient->UpdateSimulationMetrics(simulationId, - _silKitToOatppMapper->CreateMetricsUpdateDto(origin, metricsUpdate)); + _serviceClient->UpdateSimulationMetrics(simulationId, _dtoMapper->CreateMetricsUpdateDto(origin, metricsUpdate)); } } // namespace Dashboard diff --git a/SilKit/source/dashboard/service/DashboardRestClient.hpp b/SilKit/source/dashboard/service/DashboardRestClient.hpp index 179490cb6..67692d52c 100644 --- a/SilKit/source/dashboard/service/DashboardRestClient.hpp +++ b/SilKit/source/dashboard/service/DashboardRestClient.hpp @@ -10,24 +10,16 @@ #include "services/logging/ILoggerInternal.hpp" -#include "dashboard/service/ISilKitToOatppMapper.hpp" -#include "dashboard/client/IDashboardSystemServiceClient.hpp" -#include "dashboard/client/DashboardSystemApiClient.hpp" -#include "dashboard/client/DashboardRetryPolicy.hpp" -#include "dashboard/IRestClient.hpp" #include "dashboard/DashboardBulkUpdate.hpp" +#include "dashboard/IRestClient.hpp" +#include "dashboard/client/IDashboardSystemServiceClient.hpp" +#include "dashboard/http/IHttpClient.hpp" +#include "dashboard/service/IDashboardDtoMapper.hpp" #include "services/metrics/MetricsDatatypes.hpp" namespace SilKit { namespace Dashboard { -// Utility to initialize the Oatpp library separately, e.g. in test cases -struct LibraryInitializer -{ - LibraryInitializer(); - ~LibraryInitializer(); -}; - class DashboardRestClient : public VSilKit::IRestClient { public: @@ -35,9 +27,9 @@ class DashboardRestClient : public VSilKit::IRestClient ~DashboardRestClient() override; public: // For testing - DashboardRestClient(std::shared_ptr libraryInit, Services::Logging::ILoggerInternal* logger, + DashboardRestClient(Services::Logging::ILoggerInternal* logger, std::shared_ptr serviceClient, - std::shared_ptr mapper); + std::shared_ptr mapper); public: // IRestClient bool IsBulkUpdateSupported() override; @@ -48,12 +40,13 @@ class DashboardRestClient : public VSilKit::IRestClient void OnMetricsUpdate(uint64_t simulationId, const std::string& origin, const VSilKit::MetricsUpdate& metricsUpdate) override; + void Abort() override; + private: //member - std::shared_ptr _libraryInit; - Services::Logging::ILoggerInternal* _logger; - std::shared_ptr _retryPolicy; - std::shared_ptr _silKitToOatppMapper; - std::shared_ptr _apiClient; + Services::Logging::ILoggerInternal* _logger{nullptr}; + //! Null in tests; held only so that Abort() can reach the transport. + std::shared_ptr _httpClient; + std::shared_ptr _dtoMapper; std::shared_ptr _serviceClient; }; diff --git a/SilKit/source/dashboard/service/IDashboardDtoMapper.hpp b/SilKit/source/dashboard/service/IDashboardDtoMapper.hpp new file mode 100644 index 000000000..cf4cc0e8e --- /dev/null +++ b/SilKit/source/dashboard/service/IDashboardDtoMapper.hpp @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: 2022 Vector Informatik GmbH +// +// SPDX-License-Identifier: MIT + +#pragma once + +#include +#include + +#include "silkit/services/orchestration/OrchestrationDatatypes.hpp" + +#include "dashboard/DashboardBulkUpdate.hpp" +#include "dashboard/dto/BulkUpdateDto.hpp" +#include "dashboard/dto/MetricsDto.hpp" +#include "dashboard/dto/SimulationCreationRequestDto.hpp" +#include "services/metrics/MetricsDatatypes.hpp" + +namespace SilKit { +namespace Dashboard { + +/*! Maps SIL Kit internal types onto the dashboard's wire DTOs. + * + * Only the three entry points DashboardRestClient actually needs are virtual; the per-service + * helpers are public non-virtual members of DashboardDtoMapper, which is what the tests exercise. + */ +class IDashboardDtoMapper +{ +public: + virtual ~IDashboardDtoMapper() = default; + + virtual auto CreateSimulationCreationRequestDto(const std::string& connectUri, + uint64_t start) -> SimulationCreationRequestDto = 0; + virtual auto CreateBulkSimulationDto(const DashboardBulkUpdate& bulkUpdate) -> BulkSimulationDto = 0; + virtual auto CreateMetricsUpdateDto(const std::string& participantName, + const VSilKit::MetricsUpdate& metricsUpdate) -> MetricsUpdateDto = 0; +}; + +} // namespace Dashboard +} // namespace SilKit diff --git a/SilKit/source/dashboard/service/ISilKitToOatppMapper.hpp b/SilKit/source/dashboard/service/ISilKitToOatppMapper.hpp deleted file mode 100644 index ebe7e5c05..000000000 --- a/SilKit/source/dashboard/service/ISilKitToOatppMapper.hpp +++ /dev/null @@ -1,53 +0,0 @@ -// SPDX-FileCopyrightText: 2022 Vector Informatik GmbH -// -// SPDX-License-Identifier: MIT - -#pragma once - -#include - -#include "dashboard/OatppHeaders.hpp" - -#include "silkit/services/orchestration/OrchestrationDatatypes.hpp" - -#include "dashboard/dto/ParticipantStatusDto.hpp" -#include "core/internal/ServiceDescriptor.hpp" -#include "dashboard/dto/ServiceDto.hpp" -#include "dashboard/dto/SimulationCreationRequestDto.hpp" -#include "dashboard/dto/SystemStatusDto.hpp" -#include "dashboard/dto/BulkUpdateDto.hpp" -#include "dashboard/dto/MetricsDto.hpp" - -#include "dashboard/DashboardBulkUpdate.hpp" -#include "services/metrics/MetricsDatatypes.hpp" - - -namespace SilKit { -namespace Dashboard { -class ISilKitToOatppMapper -{ - using ServiceDescriptor = SilKit::Core::ServiceDescriptor; - - template - using Object = oatpp::Object; - -public: - virtual ~ISilKitToOatppMapper() = default; - virtual oatpp::Object CreateSimulationCreationRequestDto( - const std::string& connectUri, uint64_t start) = 0; - virtual oatpp::Object CreateSystemStatusDto(Services::Orchestration::SystemState systemState) = 0; - virtual oatpp::Object CreateParticipantStatusDto( - const Services::Orchestration::ParticipantStatus& participantStatus) = 0; - virtual oatpp::Object CreateServiceDto(const Core::ServiceDescriptor& serviceDescriptor) = 0; - - virtual auto CreateBulkControllerDto(const ServiceDescriptor& serviceDescriptor) -> Object = 0; - virtual auto CreateBulkDataServiceDto(const ServiceDescriptor& serviceDescriptor) -> Object = 0; - virtual auto CreateBulkRpcServiceDto(const ServiceDescriptor& serviceDescriptor) -> Object = 0; - virtual auto CreateBulkServiceInternalDto(const ServiceDescriptor& serviceDescriptor) - -> Object = 0; - virtual auto CreateBulkSimulationDto(const DashboardBulkUpdate& bulkUpdate) -> Object = 0; - virtual auto CreateMetricsUpdateDto(const std::string& origin, - const VSilKit::MetricsUpdate& metricsUpdate) -> Object = 0; -}; -} // namespace Dashboard -} // namespace SilKit diff --git a/SilKit/source/dashboard/service/Mocks/MockDashboardDtoMapper.hpp b/SilKit/source/dashboard/service/Mocks/MockDashboardDtoMapper.hpp new file mode 100644 index 000000000..b166cad98 --- /dev/null +++ b/SilKit/source/dashboard/service/Mocks/MockDashboardDtoMapper.hpp @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: 2022 Vector Informatik GmbH +// +// SPDX-License-Identifier: MIT + +#pragma once + +#include "gmock/gmock.h" + +#include "dashboard/service/IDashboardDtoMapper.hpp" + +namespace SilKit { +namespace Dashboard { + +class MockDashboardDtoMapper : public IDashboardDtoMapper +{ +public: + MOCK_METHOD(SimulationCreationRequestDto, CreateSimulationCreationRequestDto, + (const std::string& connectUri, uint64_t start), (override)); + MOCK_METHOD(BulkSimulationDto, CreateBulkSimulationDto, (const DashboardBulkUpdate& bulkUpdate), (override)); + MOCK_METHOD(MetricsUpdateDto, CreateMetricsUpdateDto, + (const std::string& participantName, const VSilKit::MetricsUpdate& metricsUpdate), (override)); +}; + +} // namespace Dashboard +} // namespace SilKit diff --git a/SilKit/source/dashboard/service/Mocks/MockSilKitToOatppMapper.hpp b/SilKit/source/dashboard/service/Mocks/MockSilKitToOatppMapper.hpp deleted file mode 100644 index ccfccde3f..000000000 --- a/SilKit/source/dashboard/service/Mocks/MockSilKitToOatppMapper.hpp +++ /dev/null @@ -1,38 +0,0 @@ -// SPDX-FileCopyrightText: 2022 Vector Informatik GmbH -// -// SPDX-License-Identifier: MIT - -#pragma once - -#include "gmock/gmock-function-mocker.h" -#include "dashboard/service/ISilKitToOatppMapper.hpp" - -namespace SilKit { -namespace Dashboard { -class MockSilKitToOatppMapper : public ISilKitToOatppMapper -{ - using ServiceDescriptor = SilKit::Core::ServiceDescriptor; - - template - using Object = oatpp::Object; - -public: - MOCK_METHOD(oatpp::Object, CreateSimulationCreationRequestDto, - (const std::string&, uint64_t), (override)); - MOCK_METHOD(oatpp::Object, CreateSystemStatusDto, - (SilKit::Services::Orchestration::SystemState), (override)); - MOCK_METHOD(oatpp::Object, CreateParticipantStatusDto, - (const SilKit::Services::Orchestration::ParticipantStatus&), (override)); - MOCK_METHOD(oatpp::Object, CreateServiceDto, - (const SilKit::Core::ServiceDescriptor&), (override)); - - MOCK_METHOD(Object, CreateBulkControllerDto, (const ServiceDescriptor&), (override)); - MOCK_METHOD(Object, CreateBulkDataServiceDto, (const ServiceDescriptor&), (override)); - MOCK_METHOD(Object, CreateBulkRpcServiceDto, (const ServiceDescriptor&), (override)); - MOCK_METHOD(Object, CreateBulkServiceInternalDto, (const ServiceDescriptor&), (override)); - MOCK_METHOD(Object, CreateBulkSimulationDto, (const DashboardBulkUpdate&), (override)); - MOCK_METHOD(Object, CreateMetricsUpdateDto, (const std::string&, const VSilKit::MetricsUpdate&), - (override)); -}; -} // namespace Dashboard -} // namespace SilKit \ No newline at end of file diff --git a/SilKit/source/dashboard/service/SilKitToOatppMapper.hpp b/SilKit/source/dashboard/service/SilKitToOatppMapper.hpp deleted file mode 100644 index 7342c52d1..000000000 --- a/SilKit/source/dashboard/service/SilKitToOatppMapper.hpp +++ /dev/null @@ -1,43 +0,0 @@ -// SPDX-FileCopyrightText: 2022 Vector Informatik GmbH -// -// SPDX-License-Identifier: MIT - -#pragma once - -#include "dashboard/service/ISilKitToOatppMapper.hpp" - -namespace SilKit { -namespace Dashboard { - -class SilKitToOatppMapper : public ISilKitToOatppMapper -{ - using ServiceDescriptor = SilKit::Core::ServiceDescriptor; - - template - using Object = oatpp::Object; - -public: - oatpp::Object CreateSimulationCreationRequestDto(const std::string& connectUri, - uint64_t start) override; - oatpp::Object CreateSystemStatusDto(Services::Orchestration::SystemState systemState) override; - oatpp::Object CreateParticipantStatusDto( - const Services::Orchestration::ParticipantStatus& participantStatus) override; - oatpp::Object CreateServiceDto(const Core::ServiceDescriptor& serviceDescriptor) override; - - auto CreateBulkControllerDto(const ServiceDescriptor& serviceDescriptor) -> Object override; - auto CreateBulkDataServiceDto(const ServiceDescriptor& serviceDescriptor) -> Object override; - auto CreateBulkRpcServiceDto(const ServiceDescriptor& serviceDescriptor) -> Object override; - auto CreateBulkServiceInternalDto(const ServiceDescriptor& serviceDescriptor) - -> Object override; - auto CreateBulkSimulationDto(const DashboardBulkUpdate& bulkUpdate) -> Object override; - auto CreateMetricsUpdateDto(const std::string& origin, - const VSilKit::MetricsUpdate& metricsUpdate) -> Object override; - -private: - void ProcessServiceDiscovery(BulkParticipantDto& dto, const ServiceDescriptor& serviceDescriptor); - void ProcessControllerDiscovery(BulkParticipantDto& dto, const ServiceDescriptor& serviceDescriptor); - void ProcessLinkDiscovery(BulkParticipantDto& dto, const ServiceDescriptor& serviceDescriptor); -}; - -} // namespace Dashboard -} // namespace SilKit diff --git a/SilKit/source/dashboard/service/Test_DashboardSilKitToOatppMapper.cpp b/SilKit/source/dashboard/service/Test_DashboardDtoMapper.cpp similarity index 61% rename from SilKit/source/dashboard/service/Test_DashboardSilKitToOatppMapper.cpp rename to SilKit/source/dashboard/service/Test_DashboardDtoMapper.cpp index 80fbfa446..8eaf4077f 100644 --- a/SilKit/source/dashboard/service/Test_DashboardSilKitToOatppMapper.cpp +++ b/SilKit/source/dashboard/service/Test_DashboardDtoMapper.cpp @@ -8,7 +8,7 @@ #include "config/YamlParser.hpp" -#include "dashboard/service/SilKitToOatppMapper.hpp" +#include "dashboard/service/DashboardDtoMapper.hpp" #include "fmt/core.h" #include @@ -18,18 +18,18 @@ namespace SilKit { namespace Dashboard { using namespace VSilKit; -class Test_DashboardSilKitToOatppMapper : public testing::Test +class Test_DashboardDtoMapper : public testing::Test { public: void SetUp() override {} - static std::shared_ptr CreateService() + static std::shared_ptr CreateService() { - return std::make_shared(); + return std::make_shared(); } }; -TEST_F(Test_DashboardSilKitToOatppMapper, CreateSimulationCreationRequestDto_MapEndpoint) +TEST_F(Test_DashboardDtoMapper, CreateSimulationCreationRequestDto_MapEndpoint) { // Arrange const std::string endpoint("silkit://myhost:1234"); @@ -41,10 +41,10 @@ TEST_F(Test_DashboardSilKitToOatppMapper, CreateSimulationCreationRequestDto_Map const auto dto = dataMapper->CreateSimulationCreationRequestDto(endpoint, expectedStartTime); // Assert - ASSERT_STREQ(dto->configuration->connectUri->c_str(), endpoint.c_str()); + ASSERT_STREQ(dto.configuration.connectUri.c_str(), endpoint.c_str()); } -TEST_F(Test_DashboardSilKitToOatppMapper, CreateSimulationCreationRequestDto_StartTimeEqualsCreationTime) +TEST_F(Test_DashboardDtoMapper, CreateSimulationCreationRequestDto_StartTimeEqualsCreationTime) { // Arrange const auto now = std::chrono::system_clock::now().time_since_epoch(); @@ -55,20 +55,20 @@ TEST_F(Test_DashboardSilKitToOatppMapper, CreateSimulationCreationRequestDto_Sta const auto dto = dataMapper->CreateSimulationCreationRequestDto("", expectedStartTime); // Assert - ASSERT_EQ(dto->started, expectedStartTime); + ASSERT_EQ(dto.started, expectedStartTime); } -TEST_F(Test_DashboardSilKitToOatppMapper, CreateSystemStatusDto_MapState) +TEST_F(Test_DashboardDtoMapper, CreateSystemStatusDto_MapState) { // Act const auto dataMapper = CreateService(); const auto dto = dataMapper->CreateSystemStatusDto(Services::Orchestration::SystemState::ReadyToRun); // Assert - ASSERT_EQ(dto->state, SystemState::ReadyToRun); + ASSERT_EQ(dto.state, SystemState::ReadyToRun); } -TEST_F(Test_DashboardSilKitToOatppMapper, CreateParticipantStatusDto_MapReasonAndTimeAndState) +TEST_F(Test_DashboardDtoMapper, CreateParticipantStatusDto_MapReasonAndTimeAndState) { namespace orchestration = Services::Orchestration; @@ -90,30 +90,12 @@ TEST_F(Test_DashboardSilKitToOatppMapper, CreateParticipantStatusDto_MapReasonAn const auto dto = dataMapper->CreateParticipantStatusDto(participant_status); // Assert - ASSERT_STREQ(dto->enterReason->c_str(), expectedReason.c_str()); - ASSERT_EQ(dto->enterTime, static_cast(expectedEnterTime)); - ASSERT_EQ(dto->state, ParticipantState::ReadyToRun); + ASSERT_STREQ(dto.enterReason.c_str(), expectedReason.c_str()); + ASSERT_EQ(dto.enterTime, static_cast(expectedEnterTime)); + ASSERT_EQ(dto.state, ParticipantState::ReadyToRun); } -TEST_F(Test_DashboardSilKitToOatppMapper, CreateServiceDto_MapNameAndNetworkName) -{ - // Arrange - const std::string expectedName("myService"); - const std::string expectedNetwork("myNetwork"); - Core::ServiceDescriptor descriptor; - descriptor.SetServiceName(expectedName); - descriptor.SetNetworkName(expectedNetwork); - - // Act - const auto dataMapper = CreateService(); - const auto dto = dataMapper->CreateServiceDto(descriptor); - - // Assert - ASSERT_STREQ(dto->name->c_str(), expectedName.c_str()); - ASSERT_STREQ(dto->networkName->c_str(), expectedNetwork.c_str()); -} - -TEST_F(Test_DashboardSilKitToOatppMapper, CreateBulkControllerDto) +TEST_F(Test_DashboardDtoMapper, CreateBulkControllerDto) { // Arrange constexpr SilKit::Core::EndpointId expectedId{12345}; @@ -129,12 +111,12 @@ TEST_F(Test_DashboardSilKitToOatppMapper, CreateBulkControllerDto) const auto dto = dataMapper->CreateBulkControllerDto(descriptor); // Assert - ASSERT_EQ(dto->id.getValue(0), expectedId); - ASSERT_STREQ(dto->name->c_str(), expectedName.c_str()); - ASSERT_STREQ(dto->networkName->c_str(), expectedNetwork.c_str()); + ASSERT_EQ(dto.id, expectedId); + ASSERT_STREQ(dto.name.c_str(), expectedName.c_str()); + ASSERT_STREQ(dto.networkName.c_str(), expectedNetwork.c_str()); } -TEST_F(Test_DashboardSilKitToOatppMapper, CreateBulkDataServiceDto_MapNetworkNameAndTopicAndMediaTypeAndLabel) +TEST_F(Test_DashboardDtoMapper, CreateBulkDataServiceDto_MapNetworkNameAndTopicAndMediaTypeAndLabel) { // Arrange Core::ServiceDescriptor descriptor; @@ -169,17 +151,17 @@ TEST_F(Test_DashboardSilKitToOatppMapper, CreateBulkDataServiceDto_MapNetworkNam const auto dto = dataMapper->CreateBulkDataServiceDto(descriptor); // Assert - ASSERT_EQ(dto->id.getValue(0), expectedId); - ASSERT_STREQ(dto->name->c_str(), expectedName.c_str()); - ASSERT_STREQ(dto->networkName->c_str(), expectedNetwork.c_str()); - ASSERT_STREQ(dto->spec->topic->c_str(), expectedTopic.c_str()); - ASSERT_STREQ(dto->spec->mediaType->c_str(), expectedMediaType.c_str()); - ASSERT_STREQ(dto->spec->labels->at(0)->key->c_str(), expectedLabel.key.c_str()); - ASSERT_STREQ(dto->spec->labels->at(0)->value->c_str(), expectedLabel.value.c_str()); - ASSERT_EQ(dto->spec->labels->at(0)->kind, LabelKind::Mandatory); + ASSERT_EQ(dto.id, expectedId); + ASSERT_STREQ(dto.name.c_str(), expectedName.c_str()); + ASSERT_STREQ(dto.networkName.c_str(), expectedNetwork.c_str()); + ASSERT_STREQ(dto.spec.topic.c_str(), expectedTopic.c_str()); + ASSERT_STREQ(dto.spec.mediaType.c_str(), expectedMediaType.c_str()); + ASSERT_STREQ(dto.spec.labels.at(0).key.c_str(), expectedLabel.key.c_str()); + ASSERT_STREQ(dto.spec.labels.at(0).value.c_str(), expectedLabel.value.c_str()); + ASSERT_EQ(dto.spec.labels.at(0).kind, LabelKind::Mandatory); } -TEST_F(Test_DashboardSilKitToOatppMapper, CreateBulkRpcServiceDto_MapNetworkNameAndFunctionNameAndMediaTypeAndLabel) +TEST_F(Test_DashboardDtoMapper, CreateBulkRpcServiceDto_MapNetworkNameAndFunctionNameAndMediaTypeAndLabel) { // Arrange Core::ServiceDescriptor descriptor; @@ -211,17 +193,17 @@ TEST_F(Test_DashboardSilKitToOatppMapper, CreateBulkRpcServiceDto_MapNetworkName const auto dto = dataMapper->CreateBulkRpcServiceDto(descriptor); // Assert - ASSERT_EQ(dto->id.getValue(0), expectedId); - ASSERT_STREQ(dto->name->c_str(), expectedName.c_str()); - ASSERT_STREQ(dto->networkName->c_str(), expectedNetwork.c_str()); - ASSERT_STREQ(dto->spec->functionName->c_str(), expectedFunctionName.c_str()); - ASSERT_STREQ(dto->spec->mediaType->c_str(), expectedMediaType.c_str()); - ASSERT_STREQ(dto->spec->labels->at(0)->key->c_str(), expectedLabel.key.c_str()); - ASSERT_STREQ(dto->spec->labels->at(0)->value->c_str(), expectedLabel.value.c_str()); - ASSERT_EQ(dto->spec->labels->at(0)->kind, LabelKind::Mandatory); + ASSERT_EQ(dto.id, expectedId); + ASSERT_STREQ(dto.name.c_str(), expectedName.c_str()); + ASSERT_STREQ(dto.networkName.c_str(), expectedNetwork.c_str()); + ASSERT_STREQ(dto.spec.functionName.c_str(), expectedFunctionName.c_str()); + ASSERT_STREQ(dto.spec.mediaType.c_str(), expectedMediaType.c_str()); + ASSERT_STREQ(dto.spec.labels.at(0).key.c_str(), expectedLabel.key.c_str()); + ASSERT_STREQ(dto.spec.labels.at(0).value.c_str(), expectedLabel.value.c_str()); + ASSERT_EQ(dto.spec.labels.at(0).kind, LabelKind::Mandatory); } -TEST_F(Test_DashboardSilKitToOatppMapper, CreateBulkServiceInternalDto_RpcServerInternal) +TEST_F(Test_DashboardDtoMapper, CreateBulkServiceInternalDto_RpcServerInternal) { // Arrange Core::ServiceDescriptor descriptor; @@ -244,13 +226,13 @@ TEST_F(Test_DashboardSilKitToOatppMapper, CreateBulkServiceInternalDto_RpcServer const auto dto = dataMapper->CreateBulkServiceInternalDto(descriptor); // Assert - ASSERT_EQ(dto->id.getValue(0), expectedId); - ASSERT_STREQ(dto->name->c_str(), expectedName.c_str()); - ASSERT_STREQ(dto->networkName->c_str(), expectedNetwork.c_str()); - ASSERT_EQ(dto->parentId.getValue(0), expectedParentId); + ASSERT_EQ(dto.id, expectedId); + ASSERT_STREQ(dto.name.c_str(), expectedName.c_str()); + ASSERT_STREQ(dto.networkName.c_str(), expectedNetwork.c_str()); + ASSERT_EQ(dto.parentId, expectedParentId); } -TEST_F(Test_DashboardSilKitToOatppMapper, CreateBulkSimulationDto) +TEST_F(Test_DashboardDtoMapper, CreateBulkSimulationDto) { const auto to_ms = [](const auto tp) -> std::uint64_t { return std::chrono::duration_cast(tp.time_since_epoch()).count(); @@ -357,103 +339,71 @@ TEST_F(Test_DashboardSilKitToOatppMapper, CreateBulkSimulationDto) const auto dto = dataMapper->CreateBulkSimulationDto(expectedBulkUpdate); // Assert - ASSERT_NE(dto->stopped.getPtr(), nullptr); - ASSERT_EQ(dto->stopped.getValue(0), static_cast(*expectedBulkUpdate.stopped)); - - ASSERT_NE(dto->system.getPtr(), nullptr); - ASSERT_NE(dto->system->statuses.getPtr(), nullptr); - ASSERT_EQ(dto->system->statuses->size(), expectedBulkUpdate.systemStates.size()); - ASSERT_NE(dto->system->statuses[0].getPtr(), nullptr); - ASSERT_NE(dto->system->statuses[0]->state.getPtr(), nullptr); - ASSERT_EQ(dto->system->statuses[0]->state, expectedSystemState0); - ASSERT_NE(dto->system->statuses[1].getPtr(), nullptr); - ASSERT_NE(dto->system->statuses[1]->state.getPtr(), nullptr); - ASSERT_EQ(dto->system->statuses[1]->state, expectedSystemState1); - ASSERT_NE(dto->system->statuses[2].getPtr(), nullptr); - ASSERT_NE(dto->system->statuses[2]->state.getPtr(), nullptr); - ASSERT_EQ(dto->system->statuses[2]->state, expectedSystemState2); - - ASSERT_NE(dto->participants.getPtr(), nullptr); - ASSERT_EQ(dto->participants->size(), 3u); - - oatpp::Object aParticipantDto; - oatpp::Object bParticipantDto; - oatpp::Object cParticipantDto; - - for (const auto& participantDto : *dto->participants.get()) + ASSERT_TRUE(dto.stopped.has_value()); + ASSERT_EQ(*dto.stopped, static_cast(*expectedBulkUpdate.stopped)); + + ASSERT_EQ(dto.system.statuses.size(), expectedBulkUpdate.systemStates.size()); + ASSERT_EQ(dto.system.statuses[0].state, expectedSystemState0); + ASSERT_EQ(dto.system.statuses[1].state, expectedSystemState1); + ASSERT_EQ(dto.system.statuses[2].state, expectedSystemState2); + + ASSERT_EQ(dto.participants.size(), 3u); + + const BulkParticipantDto* aParticipantDto{nullptr}; + const BulkParticipantDto* bParticipantDto{nullptr}; + const BulkParticipantDto* cParticipantDto{nullptr}; + + for (const auto& participantDto : dto.participants) { - ASSERT_NE(participantDto.getPtr(), nullptr); - ASSERT_NE(participantDto->name.getPtr(), nullptr); - if (participantDto->name == "A") + if (participantDto.name == "A") { - aParticipantDto = participantDto; + aParticipantDto = &participantDto; } - if (participantDto->name == "B") + if (participantDto.name == "B") { - bParticipantDto = participantDto; + bParticipantDto = &participantDto; } - if (participantDto->name == "C") + if (participantDto.name == "C") { - cParticipantDto = participantDto; + cParticipantDto = &participantDto; } } - ASSERT_NE(aParticipantDto.getPtr(), nullptr); - ASSERT_NE(aParticipantDto->statuses.getPtr(), nullptr); - ASSERT_EQ(aParticipantDto->statuses->size(), 2u); - ASSERT_NE(aParticipantDto->statuses[0].getPtr(), nullptr); - ASSERT_EQ(aParticipantDto->statuses[0]->state, expectedAParticipantState0); - ASSERT_EQ(aParticipantDto->statuses[0]->enterReason, aParticipantStatus0.enterReason); - ASSERT_EQ(aParticipantDto->statuses[0]->enterTime, to_ms(aParticipantStatus0.enterTime)); - ASSERT_NE(aParticipantDto->statuses[1].getPtr(), nullptr); - ASSERT_EQ(aParticipantDto->statuses[1]->state, expectedAParticipantState1); - ASSERT_EQ(aParticipantDto->statuses[1]->enterReason, aParticipantStatus1.enterReason); - ASSERT_EQ(aParticipantDto->statuses[1]->enterTime, to_ms(aParticipantStatus1.enterTime)); - - ASSERT_NE(aParticipantDto->canControllers.getPtr(), nullptr); - ASSERT_EQ(aParticipantDto->canControllers->size(), 2u); - ASSERT_NE(aParticipantDto->canControllers[0].getPtr(), nullptr); - ASSERT_NE(aParticipantDto->canControllers[1].getPtr(), nullptr); - - ASSERT_NE(aParticipantDto->ethernetControllers.getPtr(), nullptr); - ASSERT_EQ(aParticipantDto->ethernetControllers->size(), 2u); - ASSERT_NE(aParticipantDto->ethernetControllers[0].getPtr(), nullptr); - ASSERT_NE(aParticipantDto->ethernetControllers[1].getPtr(), nullptr); - - ASSERT_NE(aParticipantDto->flexrayControllers.getPtr(), nullptr); - ASSERT_EQ(aParticipantDto->flexrayControllers->size(), 2u); - ASSERT_NE(aParticipantDto->flexrayControllers[0].getPtr(), nullptr); - ASSERT_NE(aParticipantDto->flexrayControllers[1].getPtr(), nullptr); - - ASSERT_NE(aParticipantDto->linControllers.getPtr(), nullptr); - ASSERT_EQ(aParticipantDto->linControllers->size(), 2u); - ASSERT_NE(aParticipantDto->linControllers[0].getPtr(), nullptr); - ASSERT_NE(aParticipantDto->linControllers[1].getPtr(), nullptr); - - ASSERT_NE(aParticipantDto->canNetworks.getPtr(), nullptr); - ASSERT_EQ(aParticipantDto->canNetworks->size(), 2u); - - ASSERT_NE(aParticipantDto->ethernetNetworks.getPtr(), nullptr); - ASSERT_EQ(aParticipantDto->ethernetNetworks->size(), 2u); - - ASSERT_NE(aParticipantDto->flexrayNetworks.getPtr(), nullptr); - ASSERT_EQ(aParticipantDto->flexrayNetworks->size(), 2u); - - ASSERT_NE(aParticipantDto->linNetworks.getPtr(), nullptr); - ASSERT_EQ(aParticipantDto->linNetworks->size(), 2u); - - ASSERT_NE(bParticipantDto.getPtr(), nullptr); - ASSERT_NE(bParticipantDto->statuses.getPtr(), nullptr); - ASSERT_EQ(bParticipantDto->statuses->size(), 1u); - ASSERT_NE(bParticipantDto->statuses[0].getPtr(), nullptr); - ASSERT_EQ(bParticipantDto->statuses[0]->state, expectedBParticipantState); - ASSERT_EQ(bParticipantDto->statuses[0]->enterReason, bParticipantStatus.enterReason); - ASSERT_EQ(bParticipantDto->statuses[0]->enterTime, to_ms(bParticipantStatus.enterTime)); - - ASSERT_NE(cParticipantDto.getPtr(), nullptr); + ASSERT_NE(aParticipantDto, nullptr); + ASSERT_EQ(aParticipantDto->statuses.size(), 2u); + ASSERT_EQ(aParticipantDto->statuses[0].state, expectedAParticipantState0); + ASSERT_EQ(aParticipantDto->statuses[0].enterReason, aParticipantStatus0.enterReason); + ASSERT_EQ(aParticipantDto->statuses[0].enterTime, to_ms(aParticipantStatus0.enterTime)); + ASSERT_EQ(aParticipantDto->statuses[1].state, expectedAParticipantState1); + ASSERT_EQ(aParticipantDto->statuses[1].enterReason, aParticipantStatus1.enterReason); + ASSERT_EQ(aParticipantDto->statuses[1].enterTime, to_ms(aParticipantStatus1.enterTime)); + + ASSERT_EQ(aParticipantDto->canControllers.size(), 2u); + + ASSERT_EQ(aParticipantDto->ethernetControllers.size(), 2u); + + ASSERT_EQ(aParticipantDto->flexrayControllers.size(), 2u); + + ASSERT_EQ(aParticipantDto->linControllers.size(), 2u); + + ASSERT_EQ(aParticipantDto->canNetworks.size(), 2u); + + ASSERT_EQ(aParticipantDto->ethernetNetworks.size(), 2u); + + ASSERT_EQ(aParticipantDto->flexrayNetworks.size(), 2u); + + ASSERT_EQ(aParticipantDto->linNetworks.size(), 2u); + + ASSERT_NE(bParticipantDto, nullptr); + ASSERT_EQ(bParticipantDto->statuses.size(), 1u); + ASSERT_EQ(bParticipantDto->statuses[0].state, expectedBParticipantState); + ASSERT_EQ(bParticipantDto->statuses[0].enterReason, bParticipantStatus.enterReason); + ASSERT_EQ(bParticipantDto->statuses[0].enterTime, to_ms(bParticipantStatus.enterTime)); + + ASSERT_NE(cParticipantDto, nullptr); } } // namespace Dashboard diff --git a/SilKit/source/dashboard/service/Test_DashboardRestClient.cpp b/SilKit/source/dashboard/service/Test_DashboardRestClient.cpp index fe75e2cba..31084f192 100644 --- a/SilKit/source/dashboard/service/Test_DashboardRestClient.cpp +++ b/SilKit/source/dashboard/service/Test_DashboardRestClient.cpp @@ -7,112 +7,144 @@ #include "core/mock/participant/MockParticipant.hpp" +#include "dashboard/json/DashboardJson.hpp" #include "dashboard/service/DashboardRestClient.hpp" -#include "Mocks/MockSilKitToOatppMapper.hpp" #include "dashboard/client/Mocks/MockDashboardSystemServiceClient.hpp" -#include "dashboard/client/Mocks/MockDashboardSystemApiClient.hpp" +#include "dashboard/service/Mocks/MockDashboardDtoMapper.hpp" using namespace testing; + namespace SilKit { namespace Dashboard { +namespace { + class Test_DashboardRestClient : public Test { public: void SetUp() override { _mockServiceClient = std::make_shared>(); - _mockSilKitToOatppMapper = std::make_shared>(); - _libraryInit = std::make_shared(); + _mockDtoMapper = std::make_shared>(); EXPECT_CALL(_dummyLogger, GetLogLevel).WillRepeatedly(Return(Services::Logging::Level::Warn)); } - std::shared_ptr CreateService() + auto CreateService() -> std::shared_ptr { - return std::make_shared(_libraryInit, &_dummyLogger, _mockServiceClient, - _mockSilKitToOatppMapper); + return std::make_shared(&_dummyLogger, _mockServiceClient, _mockDtoMapper); } Core::Tests::MockLogger _dummyLogger; - std::shared_ptr _libraryInit; std::shared_ptr> _mockServiceClient; - std::shared_ptr> _mockSilKitToOatppMapper; + std::shared_ptr> _mockDtoMapper; }; TEST_F(Test_DashboardRestClient, Create) { - // Arrange - - // Act const auto service = CreateService(); - - // Assert } TEST_F(Test_DashboardRestClient, OnSimulationStart_CreateSimulationSuccess) { - // Arrange - const uint64_t expectedSimulationId = 123; - auto request = SimulationCreationRequestDto::createShared(); - EXPECT_CALL(*_mockSilKitToOatppMapper, CreateSimulationCreationRequestDto).WillOnce(Return(request)); - auto response = SimulationCreationResponseDto::createShared(); - response->id = expectedSimulationId; - EXPECT_CALL(*_mockServiceClient, CreateSimulation).WillOnce(Return(response)); - const auto service = CreateService(); + constexpr uint64_t expectedSimulationId = 123; + EXPECT_CALL(*_mockDtoMapper, CreateSimulationCreationRequestDto) + .WillOnce(Return(SimulationCreationRequestDto{})); + EXPECT_CALL(*_mockServiceClient, CreateSimulation).WillOnce(Return(expectedSimulationId)); - // Act - auto res = service->OnSimulationStart("silkit://localhost:8500", 0); + const auto service = CreateService(); + const auto simulationId = service->OnSimulationStart("silkit://localhost:8500", 0); - // Assert - ASSERT_EQ(res, expectedSimulationId) << "Wrong simulationId!"; + ASSERT_EQ(simulationId, expectedSimulationId) << "Wrong simulationId!"; } TEST_F(Test_DashboardRestClient, OnSimulationStart_CreateSimulationFailure) { - // Arrange - auto request = SimulationCreationRequestDto::createShared(); - EXPECT_CALL(*_mockSilKitToOatppMapper, CreateSimulationCreationRequestDto).WillOnce(Return(request)); - EXPECT_CALL(*_mockServiceClient, CreateSimulation).WillOnce(Return(nullptr)); + EXPECT_CALL(*_mockDtoMapper, CreateSimulationCreationRequestDto) + .WillOnce(Return(SimulationCreationRequestDto{})); + EXPECT_CALL(*_mockServiceClient, CreateSimulation).WillOnce(Return(std::nullopt)); EXPECT_CALL(_dummyLogger, ProcessLoggerMessage(Services::Logging::ALoggerMessageWith( Services::Logging::Level::Warn, "Dashboard: creating simulation failed"))); const auto service = CreateService(); + const auto simulationId = service->OnSimulationStart("silkit://localhost:8500", 0); - // Act - auto res = service->OnSimulationStart("silkit://localhost:8500", 0); - - // Assert - ASSERT_EQ(res, 0) << "Wrong simulationId!"; + ASSERT_EQ(simulationId, 0u) << "Wrong simulationId!"; } -TEST_F(Test_DashboardRestClient, OnBulkUpdate) +TEST_F(Test_DashboardRestClient, OnBulkUpdate_ForwardsTheMappedDtoWithTheSimulationId) { constexpr uint64_t expectedSimulationId{123}; - const auto expectedBulkSimulationDto = SilKit::Dashboard::BulkSimulationDto::createShared(); - // Arrange - const auto service = CreateService(); + // The DTOs are plain aggregates without operator==, so compare their serialized form; that also + // covers the writer for this payload. + BulkSimulationDto expectedDto{}; + expectedDto.stopped = 42; - using testing::_; - EXPECT_CALL(*_mockSilKitToOatppMapper, CreateBulkSimulationDto(_)).WillOnce(Return(expectedBulkSimulationDto)); + EXPECT_CALL(*_mockDtoMapper, CreateBulkSimulationDto(_)).WillOnce(Return(expectedDto)); - oatpp::UInt64 simulationId; - oatpp::Object bulkSimulationDto; + uint64_t actualSimulationId{0}; + std::string actualDtoJson; EXPECT_CALL(*_mockServiceClient, UpdateSimulation) - .WillOnce(WithArgs<0, 1>([&](oatpp::UInt64 simulationId_, oatpp::Object bulkSimulation_) { - simulationId = std::move(simulationId_); - bulkSimulationDto = std::move(bulkSimulation_); + .WillOnce(WithArgs<0, 1>([&](uint64_t simulationId, const BulkSimulationDto& bulkSimulation) { + actualSimulationId = simulationId; + actualDtoJson = ToJson(bulkSimulation); })); - // Act - service->OnBulkUpdate(expectedSimulationId, SilKit::Dashboard::DashboardBulkUpdate{}); + const auto service = CreateService(); + service->OnBulkUpdate(expectedSimulationId, DashboardBulkUpdate{}); + + EXPECT_EQ(actualSimulationId, expectedSimulationId); + EXPECT_EQ(actualDtoJson, ToJson(expectedDto)); +} + +TEST_F(Test_DashboardRestClient, OnMetricsUpdate_ForwardsTheMappedDtoWithTheSimulationId) +{ + constexpr uint64_t expectedSimulationId{7}; + + MetricsUpdateDto expectedDto{}; + CounterDataDto counter{}; + counter.ts = 1; + counter.pn = "P1"; + counter.mn = {"c"}; + counter.mv = 5; + expectedDto.counters.push_back(counter); + + EXPECT_CALL(*_mockDtoMapper, CreateMetricsUpdateDto("P1", _)).WillOnce(Return(expectedDto)); + + uint64_t actualSimulationId{0}; + std::string actualDtoJson; + EXPECT_CALL(*_mockServiceClient, UpdateSimulationMetrics) + .WillOnce(WithArgs<0, 1>([&](uint64_t simulationId, const MetricsUpdateDto& metrics) { + actualSimulationId = simulationId; + actualDtoJson = ToJson(metrics); + })); + + const auto service = CreateService(); + service->OnMetricsUpdate(expectedSimulationId, "P1", VSilKit::MetricsUpdate{}); + + EXPECT_EQ(actualSimulationId, expectedSimulationId); + EXPECT_EQ(actualDtoJson, ToJson(expectedDto)); +} + +TEST_F(Test_DashboardRestClient, IsBulkUpdateSupported_DelegatesToTheServiceClient) +{ + EXPECT_CALL(*_mockServiceClient, CheckBulkUpdateSupported()).WillOnce(Return(true)); + + const auto service = CreateService(); + + EXPECT_TRUE(service->IsBulkUpdateSupported()); +} + +// The test constructor has no transport, so Abort() must still be safe to call. +TEST_F(Test_DashboardRestClient, Abort_WithoutATransport_IsANoOp) +{ + const auto service = CreateService(); - // Assert - ASSERT_EQ(simulationId.getValue(0), expectedSimulationId); - ASSERT_NE(bulkSimulationDto.getPtr(), nullptr); - ASSERT_EQ(bulkSimulationDto, expectedBulkSimulationDto); + service->Abort(); + service->Abort(); } +} // namespace } // namespace Dashboard } // namespace SilKit diff --git a/SilKit/source/dashboard/service/Test_DashboardShutdown.cpp b/SilKit/source/dashboard/service/Test_DashboardShutdown.cpp new file mode 100644 index 000000000..2f0367f25 --- /dev/null +++ b/SilKit/source/dashboard/service/Test_DashboardShutdown.cpp @@ -0,0 +1,144 @@ +// SPDX-FileCopyrightText: 2026 Vector Informatik GmbH +// +// SPDX-License-Identifier: MIT + +/*! Shutdown behaviour of the fully assembled dashboard REST client. + * + * These exercise the real production wiring - DashboardRestClient over DashboardSystemServiceClient + * over RetryingHttpClient over AsioHttpClient - against a loopback server, rather than mocks. + * + * The case that matters is a dashboard server that accepts the connection and then never answers. + * IsBulkUpdateSupported() is the very first thing the registry's dashboard worker thread does, so + * before this rework that request blocked forever: oatpp set no socket timeouts, and the abort hook + * the destructor called ran only after the worker thread had already been joined. The registry + * therefore hung on shutdown. Both halves of the fix are covered here: the request is now bounded + * by a read deadline, and Abort() can cut it short from another thread. + */ + +#include +#include +#include + +#include "gmock/gmock.h" +#include "gtest/gtest.h" + +#include "core/mock/participant/MockParticipant.hpp" + +#include "dashboard/http/FakeHttpServer.hpp" +#include "dashboard/service/DashboardRestClient.hpp" + +using namespace testing; +using namespace std::chrono_literals; +using VSilKit::Tests::FakeHttpServer; + +namespace SilKit { +namespace Dashboard { +namespace { + +// Loose on purpose: these only need to separate "bounded" from "hung". +constexpr auto kGenerousBound = 60s; + +class Test_DashboardShutdown : public Test +{ +public: + void SetUp() override + { + EXPECT_CALL(_dummyLogger, GetLogLevel).WillRepeatedly(Return(Services::Logging::Level::Off)); + } + + auto CreateClient(uint16_t port) -> std::shared_ptr + { + return std::make_shared(&_dummyLogger, + "http://127.0.0.1:" + std::to_string(port)); + } + + NiceMock _dummyLogger; +}; + +TEST_F(Test_DashboardShutdown, IsBulkUpdateSupported_AgainstASilentServer_CanBeAborted) +{ + FakeHttpServer server{FakeHttpServer::Always("")}; // accepts, never answers + + const auto client = CreateClient(server.Port()); + + std::thread aborter{[&client] { + std::this_thread::sleep_for(200ms); + client->Abort(); + }}; + + const auto start = std::chrono::steady_clock::now(); + const auto supported = client->IsBulkUpdateSupported(); + const auto elapsed = std::chrono::steady_clock::now() - start; + aborter.join(); + + EXPECT_FALSE(supported); + EXPECT_LT(elapsed, kGenerousBound) << "Abort() must unblock the in-flight probe"; +} + +TEST_F(Test_DashboardShutdown, IsBulkUpdateSupported_WithNothingListening_FailsWithoutHanging) +{ + // Port 1 is reserved and never has a listener. + const auto client = CreateClient(1); + + const auto start = std::chrono::steady_clock::now(); + const auto supported = client->IsBulkUpdateSupported(); + const auto elapsed = std::chrono::steady_clock::now() - start; + + EXPECT_FALSE(supported); + EXPECT_LT(elapsed, kGenerousBound); +} + +TEST_F(Test_DashboardShutdown, Abort_IsIdempotentAndSafeBeforeAnyRequest) +{ + FakeHttpServer server{FakeHttpServer::Always(FakeHttpServer::MakeReply(200, "{}"))}; + + const auto client = CreateClient(server.Port()); + + client->Abort(); + client->Abort(); + + // Once aborted the client stays aborted, so the probe fails fast instead of contacting a server + // that would have answered. + EXPECT_FALSE(client->IsBulkUpdateSupported()); +} + +TEST_F(Test_DashboardShutdown, Destruction_AfterAnAbortedRequest_DoesNotBlock) +{ + FakeHttpServer server{FakeHttpServer::Always("")}; + + const auto start = std::chrono::steady_clock::now(); + { + const auto client = CreateClient(server.Port()); + std::thread aborter{[&client] { + std::this_thread::sleep_for(200ms); + client->Abort(); + }}; + client->IsBulkUpdateSupported(); + aborter.join(); + } + const auto elapsed = std::chrono::steady_clock::now() - start; + + EXPECT_LT(elapsed, kGenerousBound); +} + +// The happy path over the real stack, so the shutdown cases above are not the only coverage of the +// assembled client. +TEST_F(Test_DashboardShutdown, OnSimulationStart_AgainstARespondingServer_ReturnsTheSimulationId) +{ + FakeHttpServer server{FakeHttpServer::Always(FakeHttpServer::MakeReply(201, R"({"id":4711})"))}; + + const auto client = CreateClient(server.Port()); + const auto simulationId = client->OnSimulationStart("silkit://localhost:8500", 12345); + + EXPECT_EQ(simulationId, 4711u); + + const auto requests = server.Requests(); + ASSERT_EQ(requests.size(), 1u); + EXPECT_NE(requests[0].find("POST /system-service/v1.0/simulations HTTP/1.1"), std::string::npos); + EXPECT_NE(requests[0].find(R"({"started": 12345,"configuration": {"connectUri": "silkit://localhost:8500"}})"), + std::string::npos); +} + +} // namespace +} // namespace Dashboard +} // namespace SilKit diff --git a/ThirdParty/CMakeLists.txt b/ThirdParty/CMakeLists.txt index 0ea48aec0..709aade05 100644 --- a/ThirdParty/CMakeLists.txt +++ b/ThirdParty/CMakeLists.txt @@ -156,39 +156,6 @@ function(include_spdlog) endfunction() -function(include_oatpp) - silkit_clean_default_compileflags() - - set(OATPP_BUILD_TESTS OFF CACHE BOOL "" FORCE) - set(OATPP_INSTALL OFF CACHE BOOL "" FORCE) - set(OATPP_ADD_LINK_LIBS OFF CACHE BOOL "" FORCE) - set(OATPP_LINK_ATOMIC OFF CACHE BOOL "" FORCE) - - set(CMAKE_VISIBILITY_INLINES_HIDDEN 1) - set(CMAKE_CXX_VISIBILITY_PRESET hidden) - set(CMAKE_C_VISIBILITY_PRESET hidden) - # work around old cmake in oatpp: - set(CMAKE_POLICY_DEFAULT_CMP0063 NEW) - - add_subdirectory( - "${SILKIT_THIRD_PARTY_SOURCE_DIR}/oatpp" - "${SILKIT_THIRD_PARTY_BINARY_DIR}/_tp_oatpp" - EXCLUDE_FROM_ALL - ) - silkit_target_clean_compileflags(oatpp) - - if (CMAKE_CXX_COMPILER_ID MATCHES MSVC) - target_compile_options(oatpp PRIVATE "/wd4244" "/wd4068") - endif () - - if (CMAKE_CXX_COMPILER_ID MATCHES GNU) - target_compile_options(oatpp PRIVATE "-Wno-useless-cast" "-Wno-conversion") - endif () - - set_property(TARGET oatpp PROPERTY CXX_VISIBILITY_PRESET hidden) - set_property(TARGET oatpp PROPERTY VISIBILITY_INLINES_HIDDEN ON) -endfunction() - function(include_rapidyaml) add_subdirectory( "${SILKIT_THIRD_PARTY_SOURCE_DIR}/rapidyaml" @@ -206,8 +173,4 @@ function(silkit_add_third_party_packages) include_fmt() include_spdlog() include_rapidyaml() - - if (SILKIT_BUILD_DASHBOARD) - include_oatpp() - endif () endfunction() diff --git a/ThirdParty/LICENSES.rst b/ThirdParty/LICENSES.rst index e09b71d85..b41816e6b 100644 --- a/ThirdParty/LICENSES.rst +++ b/ThirdParty/LICENSES.rst @@ -8,7 +8,6 @@ The SIL Kit uses the following third party software components which are governe 3. Spdlog 4. Fmtlib 5. rapidyaml - 6. OATPP The full and unmodified license of each component is printed below. @@ -161,210 +160,3 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - -6. Oat++ ----------------------- - -Apache License -Version 2.0, January 2004 -http://www.apache.org/licenses/ - -TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - -1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - -2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - -3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - -4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - -5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - -6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - -7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - -8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - -9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - -END OF TERMS AND CONDITIONS - -APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - -Copyright [yyyy] [name of copyright owner] - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - -http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. - diff --git a/ThirdParty/oatpp b/ThirdParty/oatpp deleted file mode 160000 index 17ef2a7f6..000000000 --- a/ThirdParty/oatpp +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 17ef2a7f6c8a932498799b2a5ae5aab2869975c7 diff --git a/docs/changelog/versions/latest.md b/docs/changelog/versions/latest.md index 2e63e8d26..a73073b19 100644 --- a/docs/changelog/versions/latest.md +++ b/docs/changelog/versions/latest.md @@ -7,12 +7,24 @@ ## Fixed +- The SIL Kit Registry could hang on shutdown when a configured dashboard server accepted the + connection but never answered. Dashboard requests now have connect, write and read deadlines, and + an in-flight request is aborted after a grace period so that shutdown is always bounded. +- A malformed or unexpected response from the dashboard service no longer terminates the registry's + dashboard worker thread; unknown fields in the response are now ignored. - Fix ITest_AsyncSimTask (test failed when run repeatedly) - Fix the `TimeSyncService` warning about an exceeded soft time limit, which showed a literal `{}` instead of the measured timeout in milliseconds ## Changed +- `third-party`: the dashboard client no longer depends on `oatpp`, and the `ThirdParty/oatpp` + submodule has been removed. The dashboard payloads are now built with the already-bundled + `rapidyaml`, and the REST requests are issued over the already-bundled standalone `asio`. + The requests the dashboard service receives are unchanged apart from three cosmetic differences in + the JSON encoding: a space follows each `:` separator, forward slashes are no longer escaped as + `\/`, and non-ASCII characters are sent as UTF-8 rather than `\uXXXX` escapes. Control characters + that cannot be escaped are replaced with U+FFFD. - Changes to the SIL KIT MSI installer: - Default installation path changed from `\Vector SIL Kit ` to `\SIL Kit ` - Windows System Service Name changed from `VectorSilKitRegistry` to `SilKitRegistry` diff --git a/docs/licenses/license.rst b/docs/licenses/license.rst index 7abd8f42d..e58a7f9ef 100644 --- a/docs/licenses/license.rst +++ b/docs/licenses/license.rst @@ -189,211 +189,3 @@ Fmtlib ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -Oat++ -~~~~~ - -.. code-block:: text - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - From 94cdb196c2954726a3c706a48515879aa96e5f7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marius=20B=C3=B6rschig?= Date: Tue, 1 Sep 2026 19:53:17 +0200 Subject: [PATCH 2/5] fixup! dashboard: get rid of oatpp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refactor and cleanups Signed-off-by: Marius Börschig --- SilKit/source/core/internal/internal_fwd.hpp | 3 +- .../internal/traits/SilKitLoggingTraits.hpp | 3 +- SilKit/source/core/vasio/CMakeLists.txt | 1 + .../core/vasio/IRegistryEventListener.hpp | 49 +++ SilKit/source/core/vasio/VAsioRegistry.hpp | 22 +- SilKit/source/dashboard/CMakeLists.txt | 99 ++--- .../dashboard/CreateDashboardInstance.cpp | 9 +- .../dashboard/CreateDashboardInstance.hpp | 20 +- .../source/dashboard/DashboardBulkUpdate.hpp | 5 +- SilKit/source/dashboard/DashboardInstance.cpp | 343 ++++-------------- SilKit/source/dashboard/DashboardInstance.hpp | 69 ++-- .../source/dashboard/DashboardUnavailable.cpp | 10 +- .../dashboard/EventQueueWorkerThread.cpp | 183 ++++++++++ .../dashboard/EventQueueWorkerThread.hpp | 62 ++++ .../source/dashboard/IDashboardInstance.hpp | 20 +- SilKit/source/dashboard/IRestClient.hpp | 2 - .../source/dashboard/Mocks/MockRestClient.hpp | 27 ++ SilKit/source/dashboard/SilKitEvent.hpp | 196 ++++------ .../Test_DashboardEventQueueWorker.cpp | 342 +++++++++++++++++ .../client/DashboardSystemServiceClient.cpp | 8 - .../client/DashboardSystemServiceClient.hpp | 1 - .../client/IDashboardSystemServiceClient.hpp | 3 - .../MockDashboardSystemServiceClient.hpp | 1 - .../Test_DashboardSystemServiceClient.cpp | 36 -- .../json/Test_DashboardJsonWriter.cpp | 4 +- .../dashboard/service/DashboardRestClient.cpp | 5 - .../dashboard/service/DashboardRestClient.hpp | 1 - .../service/Test_DashboardDtoMapper.cpp | 2 +- .../service/Test_DashboardRestClient.cpp | 9 - .../service/Test_DashboardShutdown.cpp | 31 +- Utilities/SilKitRegistry/Registry.cpp | 27 +- Utilities/SilKitRegistry/Registry.hpp | 9 +- docs/changelog/versions/latest.md | 13 + 33 files changed, 965 insertions(+), 650 deletions(-) create mode 100644 SilKit/source/core/vasio/IRegistryEventListener.hpp create mode 100644 SilKit/source/dashboard/EventQueueWorkerThread.cpp create mode 100644 SilKit/source/dashboard/EventQueueWorkerThread.hpp create mode 100644 SilKit/source/dashboard/Mocks/MockRestClient.hpp create mode 100644 SilKit/source/dashboard/Test_DashboardEventQueueWorker.cpp diff --git a/SilKit/source/core/internal/internal_fwd.hpp b/SilKit/source/core/internal/internal_fwd.hpp index ff0182513..c90f28e23 100644 --- a/SilKit/source/core/internal/internal_fwd.hpp +++ b/SilKit/source/core/internal/internal_fwd.hpp @@ -12,6 +12,8 @@ class MetricsProcessor; class AsioGenericRawByteStream; class AsioHttpClient; class RetryingHttpClient; +class DashboardInstance; +class EventQueueWorkerThread; } // namespace VSilKit namespace SilKit { namespace Tracing { @@ -21,7 +23,6 @@ class ReplayScheduler; namespace Dashboard { class DashboardRestClient; class DashboardSystemServiceClient; -class DashboardInstance; } // namespace Dashboard namespace Experimental { namespace NetworkSimulation { diff --git a/SilKit/source/core/internal/traits/SilKitLoggingTraits.hpp b/SilKit/source/core/internal/traits/SilKitLoggingTraits.hpp index 75eedfe21..32ea94854 100644 --- a/SilKit/source/core/internal/traits/SilKitLoggingTraits.hpp +++ b/SilKit/source/core/internal/traits/SilKitLoggingTraits.hpp @@ -80,7 +80,8 @@ DefineSilKitLoggingTrait_Topic(VSilKit::AsioGenericRawByteStream, SilKit::Servic DefineSilKitLoggingTrait_Topic(SilKit::Dashboard::DashboardRestClient, SilKit::Services::Logging::Topic::Dashboard); DefineSilKitLoggingTrait_Topic(SilKit::Dashboard::DashboardSystemServiceClient, SilKit::Services::Logging::Topic::Dashboard); -DefineSilKitLoggingTrait_Topic(SilKit::Dashboard::DashboardInstance, SilKit::Services::Logging::Topic::Dashboard); +DefineSilKitLoggingTrait_Topic(VSilKit::DashboardInstance, SilKit::Services::Logging::Topic::Dashboard); +DefineSilKitLoggingTrait_Topic(VSilKit::EventQueueWorkerThread, SilKit::Services::Logging::Topic::Dashboard); DefineSilKitLoggingTrait_Topic(VSilKit::AsioHttpClient, SilKit::Services::Logging::Topic::Dashboard); DefineSilKitLoggingTrait_Topic(VSilKit::RetryingHttpClient, SilKit::Services::Logging::Topic::Dashboard); diff --git a/SilKit/source/core/vasio/CMakeLists.txt b/SilKit/source/core/vasio/CMakeLists.txt index a09f62be4..4d33c64e5 100644 --- a/SilKit/source/core/vasio/CMakeLists.txt +++ b/SilKit/source/core/vasio/CMakeLists.txt @@ -8,6 +8,7 @@ add_library(O_SilKit_Core_VAsio OBJECT VAsioConnection.hpp VAsioConnection.cpp + IRegistryEventListener.hpp VAsioRegistry.hpp VAsioRegistry.cpp diff --git a/SilKit/source/core/vasio/IRegistryEventListener.hpp b/SilKit/source/core/vasio/IRegistryEventListener.hpp new file mode 100644 index 000000000..ec3478efd --- /dev/null +++ b/SilKit/source/core/vasio/IRegistryEventListener.hpp @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: 2026 Vector Informatik GmbH +// +// SPDX-License-Identifier: MIT + +#pragma once + +#include + +#include "silkit/services/orchestration/OrchestrationDatatypes.hpp" + +#include "core/service/ServiceDatatypes.hpp" +#include "services/logging/ILoggerInternal.hpp" +#include "services/metrics/MetricsDatatypes.hpp" +#include "silkit/util/Span.hpp" + +namespace SilKit { +namespace Core { + +/*! Everything the registry reports about the simulations it is hosting. + * + * Split out of VAsioRegistry.hpp so that a listener implementation does not have to include the + * whole registry (and with it VAsioConnection and ParticipantConfiguration) just to declare itself. + * + * The registry holds its listener as a raw, non-owning pointer and calls every method below from + * its asio I/O thread, except OnLoggerCreated and OnRegistryUri, which happen earlier on the + * thread that constructs and starts the registry. A listener must therefore outlive the registry. + */ +struct IRegistryEventListener +{ + virtual ~IRegistryEventListener() = default; + + virtual void OnLoggerCreated(SilKit::Services::Logging::ILoggerInternal* logger) = 0; + virtual void OnRegistryUri(const std::string& registryUri) = 0; + virtual void OnParticipantConnected(const std::string& simulationName, const std::string& participantName) = 0; + virtual void OnParticipantDisconnected(const std::string& simulationName, const std::string& participantName) = 0; + virtual void OnRequiredParticipantsUpdate(const std::string& simulationName, const std::string& participantName, + SilKit::Util::Span requiredParticipantNames) = 0; + virtual void OnParticipantStatusUpdate( + const std::string& simulationName, const std::string& participantName, + const SilKit::Services::Orchestration::ParticipantStatus& participantStatus) = 0; + virtual void OnServiceDiscoveryEvent( + const std::string& simulationName, const std::string& participantName, + const SilKit::Core::Discovery::ServiceDiscoveryEvent& serviceDiscoveryEvent) = 0; + virtual void OnMetricsUpdate(const std::string& simulationName, const std::string& origin, + const VSilKit::MetricsUpdate& metricsUpdate) = 0; +}; + +} // namespace Core +} // namespace SilKit diff --git a/SilKit/source/core/vasio/VAsioRegistry.hpp b/SilKit/source/core/vasio/VAsioRegistry.hpp index 648805c7f..0c6c0248d 100644 --- a/SilKit/source/core/vasio/VAsioRegistry.hpp +++ b/SilKit/source/core/vasio/VAsioRegistry.hpp @@ -17,6 +17,8 @@ #include "services/metrics/MetricsReceiver.hpp" +#include "core/vasio/IRegistryEventListener.hpp" + namespace SilKit { namespace Core { @@ -28,26 +30,6 @@ struct IMsgForVAsioRegistry { }; -struct IRegistryEventListener -{ - virtual ~IRegistryEventListener() = default; - - virtual void OnLoggerCreated(SilKit::Services::Logging::ILoggerInternal* logger) = 0; - virtual void OnRegistryUri(const std::string& registryUri) = 0; - virtual void OnParticipantConnected(const std::string& simulationName, const std::string& participantName) = 0; - virtual void OnParticipantDisconnected(const std::string& simulationName, const std::string& participantName) = 0; - virtual void OnRequiredParticipantsUpdate(const std::string& simulationName, const std::string& participantName, - SilKit::Util::Span requiredParticipantNames) = 0; - virtual void OnParticipantStatusUpdate( - const std::string& simulationName, const std::string& participantName, - const SilKit::Services::Orchestration::ParticipantStatus& participantStatus) = 0; - virtual void OnServiceDiscoveryEvent( - const std::string& simulationName, const std::string& participantName, - const SilKit::Core::Discovery::ServiceDiscoveryEvent& serviceDiscoveryEvent) = 0; - virtual void OnMetricsUpdate(const std::string& simulationName, const std::string& origin, - const VSilKit::MetricsUpdate& metricsUpdate) = 0; -}; - class VAsioRegistry : public SilKit::Vendor::Vector::ISilKitRegistry , public IMsgForVAsioRegistry diff --git a/SilKit/source/dashboard/CMakeLists.txt b/SilKit/source/dashboard/CMakeLists.txt index 88f8fa188..4298050d3 100755 --- a/SilKit/source/dashboard/CMakeLists.txt +++ b/SilKit/source/dashboard/CMakeLists.txt @@ -23,6 +23,10 @@ if(SILKIT_BUILD_DASHBOARD) endif () add_library(O_SilKit_Dashboard STATIC + IDashboardInstance.hpp + IRestClient.hpp + CreateDashboardInstance.hpp + client/DashboardPaths.hpp client/DashboardSystemServiceClient.cpp client/DashboardSystemServiceClient.hpp @@ -30,7 +34,6 @@ if(SILKIT_BUILD_DASHBOARD) http/AsioHttpClient.cpp http/AsioHttpClient.hpp - http/FakeHttpServer.hpp http/HttpResponseParser.cpp http/HttpResponseParser.hpp http/HttpRetryPolicy.hpp @@ -63,6 +66,8 @@ if(SILKIT_BUILD_DASHBOARD) SilKitEvent.hpp DashboardBulkUpdate.hpp + EventQueueWorkerThread.cpp + EventQueueWorkerThread.hpp DashboardInstance.cpp CreateDashboardInstance.cpp @@ -91,77 +96,31 @@ if(SILKIT_BUILD_DASHBOARD) set_property(TARGET O_SilKit_Dashboard PROPERTY FOLDER "Dashboard") - add_silkit_test_to_executable(SilKitDashboardTests - SOURCES Test_DashboardSilKitEventQueue.cpp - LIBS - S_SilKitImpl - O_SilKit_Dashboard - I_SilKit - ) - - add_silkit_test_to_executable(SilKitDashboardTests - SOURCES service/Test_DashboardShutdown.cpp - LIBS - S_SilKitImpl - O_SilKit_Dashboard - I_SilKit - ) - - add_silkit_test_to_executable(SilKitDashboardTests - SOURCES service/Test_DashboardRestClient.cpp - LIBS - S_SilKitImpl - O_SilKit_Dashboard - I_SilKit - ) - - add_silkit_test_to_executable(SilKitDashboardTests - SOURCES service/Test_DashboardDtoMapper.cpp - LIBS - S_SilKitImpl - O_SilKit_Dashboard - I_SilKit - ) - - add_silkit_test_to_executable(SilKitDashboardTests - SOURCES json/Test_DashboardJsonWriter.cpp - LIBS - S_SilKitImpl - O_SilKit_Dashboard - I_SilKit - ) - - add_silkit_test_to_executable(SilKitDashboardTests - SOURCES http/Test_HttpResponseParser.cpp - LIBS - S_SilKitImpl - O_SilKit_Dashboard - I_SilKit - ) - - add_silkit_test_to_executable(SilKitDashboardTests - SOURCES http/Test_RetryingHttpClient.cpp - LIBS - S_SilKitImpl - O_SilKit_Dashboard - I_SilKit - ) - - add_silkit_test_to_executable(SilKitDashboardTests - SOURCES http/Test_AsioHttpClient.cpp - LIBS - S_SilKitImpl - O_SilKit_Dashboard - I_SilKit + # Every dashboard test links the same set, so register them from one list. + set(SILKIT_DASHBOARD_TEST_SOURCES + Test_DashboardEventQueueWorker.cpp + Test_DashboardSilKitEventQueue.cpp + client/Test_DashboardSystemServiceClient.cpp + http/Test_AsioHttpClient.cpp + http/Test_HttpResponseParser.cpp + http/Test_RetryingHttpClient.cpp + json/Test_DashboardJsonWriter.cpp + service/Test_DashboardDtoMapper.cpp + service/Test_DashboardRestClient.cpp + service/Test_DashboardShutdown.cpp ) - add_silkit_test_to_executable(SilKitDashboardTests - SOURCES client/Test_DashboardSystemServiceClient.cpp - LIBS - S_SilKitImpl - O_SilKit_Dashboard - I_SilKit - ) + target_sources(SilKitDashboardTests PRIVATE http/FakeHttpServer.hpp Mocks/MockRestClient.hpp) + + foreach(testSource IN LISTS SILKIT_DASHBOARD_TEST_SOURCES) + add_silkit_test_to_executable(SilKitDashboardTests + SOURCES ${testSource} + LIBS + S_SilKitImpl + O_SilKit_Dashboard + I_SilKit + ) + endforeach() else() diff --git a/SilKit/source/dashboard/CreateDashboardInstance.cpp b/SilKit/source/dashboard/CreateDashboardInstance.cpp index 50b036ae2..c36107baf 100644 --- a/SilKit/source/dashboard/CreateDashboardInstance.cpp +++ b/SilKit/source/dashboard/CreateDashboardInstance.cpp @@ -8,9 +8,14 @@ namespace VSilKit { -auto CreateDashboardInstance() -> std::unique_ptr +auto IsDashboardAvailable() -> bool { - return std::make_unique(); + return true; +} + +auto CreateDashboardInstance(const std::string& dashboardUri) -> std::unique_ptr +{ + return std::make_unique(dashboardUri); } diff --git a/SilKit/source/dashboard/CreateDashboardInstance.hpp b/SilKit/source/dashboard/CreateDashboardInstance.hpp index dc7cae2ee..0aa7008f2 100644 --- a/SilKit/source/dashboard/CreateDashboardInstance.hpp +++ b/SilKit/source/dashboard/CreateDashboardInstance.hpp @@ -4,16 +4,24 @@ #pragma once - -#include "dashboard/IDashboardInstance.hpp" - #include +#include +#include "dashboard/IDashboardInstance.hpp" namespace VSilKit { - -auto CreateDashboardInstance() -> std::unique_ptr; - +/*! Whether this build has dashboard support compiled in (the SILKIT_BUILD_DASHBOARD option). + * + * Lets callers report a plainly disabled feature without going through an exception. + */ +auto IsDashboardAvailable() -> bool; + +/*! Create a dashboard instance that will report to dashboardUri. + * + * Throws if this build has no dashboard support, or if dashboardUri is not a usable http URI. + * The instance does not connect until the registry reports its URI. + */ +auto CreateDashboardInstance(const std::string& dashboardUri) -> std::unique_ptr; } // namespace VSilKit diff --git a/SilKit/source/dashboard/DashboardBulkUpdate.hpp b/SilKit/source/dashboard/DashboardBulkUpdate.hpp index b9143218b..1aa2d1d4c 100644 --- a/SilKit/source/dashboard/DashboardBulkUpdate.hpp +++ b/SilKit/source/dashboard/DashboardBulkUpdate.hpp @@ -9,7 +9,7 @@ #include "dashboard/SilKitEvent.hpp" #include -#include +#include #include #include @@ -23,7 +23,8 @@ class DashboardBulkUpdate using SystemState = SilKit::Services::Orchestration::SystemState; using ParticipantStatus = SilKit::Services::Orchestration::ParticipantStatus; - std::unique_ptr stopped; + //! Set once the simulation has stopped; the value is the stop timestamp. + std::optional stopped; std::vector systemStates; std::vector participantConnectionInformations; std::vector participantStatuses; diff --git a/SilKit/source/dashboard/DashboardInstance.cpp b/SilKit/source/dashboard/DashboardInstance.cpp index 22aacb4ea..ccae6cc27 100644 --- a/SilKit/source/dashboard/DashboardInstance.cpp +++ b/SilKit/source/dashboard/DashboardInstance.cpp @@ -3,15 +3,26 @@ // SPDX-License-Identifier: MIT #include "dashboard/DashboardInstance.hpp" -#include "dashboard/SilKitEvent.hpp" -#include "dashboard/LockedQueue.hpp" -#include "dashboard/service/DashboardDtoMapper.hpp" + +#include +#include +#include + +#include "dashboard/EventQueueWorkerThread.hpp" #include "dashboard/service/DashboardRestClient.hpp" +#include "services/logging/LoggerMessage.hpp" +#include "util/Assert.hpp" +#include "util/SetThreadName.hpp" +#include "util/Uri.hpp" namespace { +/// How long the destructor lets an in-flight dashboard request finish before aborting it. +constexpr auto kShutdownGracePeriod = std::chrono::seconds{5}; + + uint64_t GetCurrentSystemTime() { auto now = std::chrono::system_clock::now().time_since_epoch(); @@ -37,39 +48,43 @@ using VSilKit::ServiceData; namespace VSilKit { +using namespace SilKit::Services; +using namespace SilKit::Services::Logging; +using SilKit::Dashboard::DashboardBulkUpdate; -DashboardInstance::DashboardInstance() {} +DashboardInstance::DashboardInstance(const std::string& dashboardUri) + : _dashboardUri{dashboardUri} +{ + // Parse eagerly so that a malformed --dashboard-uri is reported when the instance is created, + // rather than later on the registry's thread. The result is discarded; DashboardRestClient + // parses it again once it is built. + (void)SilKit::Core::Uri::Parse(dashboardUri); +} DashboardInstance::~DashboardInstance() { - try - { - _eventQueueWorkerThreadAbort.set_value(); - } - catch (...) + _abortWorker.store(true, std::memory_order_release); + _silKitEventQueue.Stop(); + + if (!_eventQueueWorkerThread.joinable()) { - // ignored + return; } - _silKitEventQueue.Stop(); - /* The worker may be blocked in an HTTP request. Give it a grace period to finish flushing, then * abort the transport so shutdown stays bounded even when the dashboard server accepts * connections but never answers. std::thread has no timed join, hence the watchdog. */ std::promise workerFinished; auto workerFinishedFuture = workerFinished.get_future(); auto watchdog = std::async(std::launch::async, [this, &workerFinishedFuture] { - if (workerFinishedFuture.wait_for(_shutdownGracePeriod) == std::future_status::timeout + if (workerFinishedFuture.wait_for(kShutdownGracePeriod) == std::future_status::timeout && _dashboardRestClient != nullptr) { _dashboardRestClient->Abort(); } }); - if (_eventQueueWorkerThread.joinable()) - { - _eventQueueWorkerThread.join(); - } + _eventQueueWorkerThread.join(); workerFinished.set_value(); watchdog.wait(); @@ -80,244 +95,17 @@ auto DashboardInstance::GetRegistryEventListener() -> SilKit::Core::IRegistryEve return this; } -void DashboardInstance::SetupDashboardConnection(const std::string& dashboardUri) -{ - _dashboardRestClient = std::make_shared(_logger, dashboardUri); - RunEventQueueWorkerThread(); -} - -using namespace SilKit::Services; -using namespace SilKit::Services::Logging; -using namespace SilKit::Dashboard; - -class EventQueueWorkerThread -{ - ILoggerInternal* _logger{nullptr}; - IRestClient* _dashboardRestClient{nullptr}; - LockedQueue* _eventQueue{nullptr}; - std::future _abort; - -public: //CTor - EventQueueWorkerThread(ILoggerInternal* logger, IRestClient* dashboardRestClient, LockedQueue* eventQueue, - std::future abort) - : _logger{logger} - , _dashboardRestClient{dashboardRestClient} - , _eventQueue{eventQueue} - , _abort{std::move(abort)} - { - } - - auto DetectBulkUpdate() const -> bool - { - auto bulkUpdateAvailable = _dashboardRestClient->IsBulkUpdateSupported(); - if (bulkUpdateAvailable) - { - _logger->MakeMessage(Level::Debug, TopicOf(*this)) - .SetMessage("Dashboard bulk-updates are available") - .Dispatch(); - } - else - { - _logger->MakeMessage(Level::Debug, TopicOf(*this)) - .SetMessage("Dashboard bulk-updates are not available, falling back to individual requests") - .Dispatch(); - } - - return bulkUpdateAvailable; - } - - void ProcessEventsWithBulkUpdates() const - { - std::unordered_map simulationNameToId; - std::unordered_map simulationBulkUpdates; - - std::vector events; - while (_eventQueue->DequeueAllInto(events)) - { - const auto ProcessAllAccumulatedBulkUpdates = [this, &simulationBulkUpdates] { - for (auto& pair : simulationBulkUpdates) - { - const auto simulationId = pair.first; - auto& bulkUpdate = pair.second; - - if (bulkUpdate.Empty()) - { - continue; - } - - _dashboardRestClient->OnBulkUpdate(simulationId, bulkUpdate); - bulkUpdate.Clear(); - } - }; - - for (const auto& event : events) - { - if (!_abort.valid() || _abort.wait_for(std::chrono::seconds{}) != std::future_status::timeout) - { - return; - } - - // process OnSimulationStart separately, which creates the simulation-id for a simulation name - - if (event.Type() == SilKitEventType::OnSimulationStart) - { - ProcessAllAccumulatedBulkUpdates(); - - const auto it{simulationNameToId.find(event.GetSimulationName())}; - if (it != simulationNameToId.end()) - { - // it is possible that multiple SimulationStart events are created (due to the queuing) - _logger->MakeMessage(Level::Debug, TopicOf(*this)) - .SetMessage("Dashboard: Simulation {} already has id {}", event.GetSimulationName(), it->second) - .Dispatch(); - continue; - } - - const auto& simulationStart = event.GetSimulationStart(); - const auto simulationId = - _dashboardRestClient->OnSimulationStart(simulationStart.connectUri, simulationStart.time); - - if (simulationId == 0) - { - _logger->MakeMessage(Level::Warn, TopicOf(*this)) - .SetMessage("Dashboard: Simulation {} could not be created", event.GetSimulationName()) - .Dispatch(); - continue; - } - - simulationNameToId.emplace(event.GetSimulationName(), simulationId); - - continue; - } - - // fetch the simulation id for the given name - - const auto it{simulationNameToId.find(event.GetSimulationName())}; - if (it == simulationNameToId.end()) - { - _logger->MakeMessage(Level::Warn, TopicOf(*this)) - .SetMessage("Dashboard: Simulation {} is unknown", event.GetSimulationName()) - .Dispatch(); - continue; - } - - const auto simulationId{it->second}; - auto& bulkUpdate{simulationBulkUpdates[simulationId]}; - - // process all event types, except OnSimulationStart - - switch (event.Type()) - { - case SilKitEventType::OnParticipantConnected: - { - const auto& participantConnectionInformation = event.GetParticipantConnectionInformation(); - bulkUpdate.participantConnectionInformations.emplace_back(participantConnectionInformation); - } - break; - - case SilKitEventType::OnSystemStateChanged: - { - const auto& systemState = event.GetSystemState(); - bulkUpdate.systemStates.emplace_back(systemState); - } - break; - - case SilKitEventType::OnParticipantStatusChanged: - { - const auto& participantStatus = event.GetParticipantStatus(); - bulkUpdate.participantStatuses.emplace_back(participantStatus); - } - break; - - case SilKitEventType::OnServiceDiscoveryEvent: - { - const auto& serviceData = event.GetServiceData(); - bulkUpdate.serviceDatas.emplace_back(serviceData); - } - break; - - case SilKitEventType::OnSimulationEnd: - { - const auto& simulationEnd = event.GetSimulationEnd(); - bulkUpdate.stopped = std::make_unique(simulationEnd.time); - - simulationNameToId.erase(it); - } - break; - - case SilKitEventType::OnMetricUpdate: - { - const auto& data = event.GetMetricsUpdate(); - _dashboardRestClient->OnMetricsUpdate(simulationId, data.first, data.second); - } - break; - - default: - { - _logger->MakeMessage(Level::Error, TopicOf(*this)) - .SetMessage("Dashboard: unexpected SilKitEventType") - .Dispatch(); - } - break; - } - } - - events.clear(); - ProcessAllAccumulatedBulkUpdates(); - } - } - - void operator()() const - try - { - SilKit::Util::SetThreadName("SK-Dash-Cons"); - - const bool bulkUpdateAvailable = DetectBulkUpdate(); - - if (bulkUpdateAvailable) - { - ProcessEventsWithBulkUpdates(); - } - else - { - throw SilKit::SilKitError{"Bulk update for REST API is required!"}; - } - } - catch (const std::exception& exception) - { - _logger->MakeMessage(Level::Error, TopicOf(*this)) - .SetMessage("Dashboard: event queue worker failed: {}", exception.what()) - .Dispatch(); - } - catch (...) - { - _logger->MakeMessage(Level::Error, TopicOf(*this)) - .SetMessage("Dashboard: event queue worker failed with unknown exception") - .Dispatch(); - } -}; - -void DashboardInstance::RunEventQueueWorkerThread() +void DashboardInstance::StartWorker() { SILKIT_ASSERT(_eventQueueWorkerThread.get_id() == std::thread::id{}); - _eventQueueWorkerThreadAbort = std::promise{}; - - EventQueueWorkerThread workerThread{_logger, _dashboardRestClient.get(), &_silKitEventQueue, - _eventQueueWorkerThreadAbort.get_future()}; - - _eventQueueWorkerThread = std::thread{std::move(workerThread)}; -} + _dashboardRestClient = std::make_unique(_logger, _dashboardUri); -auto DashboardInstance::GetOrCreateSimulationData(const std::string& simulationName) -> SimulationData& -{ - auto& simulationDataRef{_simulationEventHandlers[simulationName]}; - return simulationDataRef; -} - -void DashboardInstance::RemoveSimulationData(const std::string& simulationName) -{ - _simulationEventHandlers.erase(simulationName); + EventQueueWorkerThread worker{_logger, _dashboardRestClient.get(), &_silKitEventQueue, &_abortWorker}; + _eventQueueWorkerThread = std::thread{[worker] { + SilKit::Util::SetThreadName("SK-Dash-Cons"); + worker(); + }}; } void DashboardInstance::OnLoggerCreated(SilKit::Services::Logging::ILoggerInternal* logger) @@ -331,8 +119,11 @@ void DashboardInstance::OnRegistryUri(const std::string& registryUri) _logger->MakeMessage(Level::Debug, TopicOf(*this)) .SetMessage("DashboardInstance::OnRegistryUri: registryUri={}", registryUri) .Dispatch(); - SILKIT_ASSERT(_registryUri == nullptr); - _registryUri = std::make_unique(registryUri); + SILKIT_ASSERT(!_registryUri.has_value()); + _registryUri = SilKit::Core::Uri{registryUri}; + + // Both prerequisites are now in place: the logger and the registry URI. + StartWorker(); } void DashboardInstance::OnParticipantConnected(const std::string& simulationName, const std::string& participantName) @@ -342,15 +133,14 @@ void DashboardInstance::OnParticipantConnected(const std::string& simulationName simulationName, participantName) .Dispatch(); - auto& simulationData{GetOrCreateSimulationData(simulationName)}; + auto& systemStateTracker{_systemStateTrackers[simulationName]}; - if (simulationData.systemStateTracker.IsEmpty()) + if (systemStateTracker.IsEmpty()) { const auto connectUri{ SilKit::Core::Uri::MakeSilKit(_registryUri->Host(), _registryUri->Port(), simulationName)}; _silKitEventQueue.Enqueue( - SilKitEvent{simulationName, SimulationStart{connectUri.EncodedString(), GetCurrentSystemTime()}} - ); + SilKitEvent{simulationName, SimulationStart{connectUri.EncodedString(), GetCurrentSystemTime()}}); } _silKitEventQueue.Enqueue(SilKitEvent{ @@ -367,21 +157,21 @@ void DashboardInstance::OnParticipantDisconnected(const std::string& simulationN bool isEmpty{false}; { - auto& simulationData{GetOrCreateSimulationData(simulationName)}; + auto& systemStateTracker{_systemStateTrackers[simulationName]}; - const auto result{simulationData.systemStateTracker.RemoveParticipant(participantName)}; - isEmpty = simulationData.systemStateTracker.IsEmpty(); + const auto result{systemStateTracker.RemoveParticipant(participantName)}; + isEmpty = systemStateTracker.IsEmpty(); if (result.systemStateChanged) { - _silKitEventQueue.Enqueue(SilKitEvent{simulationName, simulationData.systemStateTracker.GetSystemState()}); + _silKitEventQueue.Enqueue(SilKitEvent{simulationName, systemStateTracker.GetSystemState()}); } } if (isEmpty) { _silKitEventQueue.Enqueue(SilKitEvent{simulationName, SimulationEnd{GetCurrentSystemTime()}}); - RemoveSimulationData(simulationName); + _systemStateTrackers.erase(simulationName); } } @@ -395,12 +185,12 @@ void DashboardInstance::OnRequiredParticipantsUpdate(const std::string& simulati simulationName, participantName, requiredParticipantNames.size()) .Dispatch(); - auto& simulationData{GetOrCreateSimulationData(simulationName)}; - const auto result{simulationData.systemStateTracker.UpdateRequiredParticipants(requiredParticipantNames)}; + auto& systemStateTracker{_systemStateTrackers[simulationName]}; + const auto result{systemStateTracker.UpdateRequiredParticipants(requiredParticipantNames)}; if (result.systemStateChanged) { - _silKitEventQueue.Enqueue(SilKitEvent{simulationName, simulationData.systemStateTracker.GetSystemState()}); + _silKitEventQueue.Enqueue(SilKitEvent{simulationName, systemStateTracker.GetSystemState()}); } } @@ -411,12 +201,11 @@ void DashboardInstance::OnParticipantStatusUpdate( _logger->MakeMessage(Level::Trace, TopicOf(*this)) .SetMessage("DashboardInstance::OnParticipantStatusUpdate: simulationName={} participantName={} " "participantState={}", - - simulationName, participantName, participantStatus.state) + simulationName, participantName, participantStatus.state) .Dispatch(); - auto& simulationData{GetOrCreateSimulationData(simulationName)}; - const auto result{simulationData.systemStateTracker.UpdateParticipantStatus(participantStatus)}; + auto& systemStateTracker{_systemStateTrackers[simulationName]}; + const auto result{systemStateTracker.UpdateParticipantStatus(participantStatus)}; if (result.participantStateChanged) { @@ -425,7 +214,7 @@ void DashboardInstance::OnParticipantStatusUpdate( if (result.systemStateChanged) { - _silKitEventQueue.Enqueue(SilKitEvent{simulationName, simulationData.systemStateTracker.GetSystemState()}); + _silKitEventQueue.Enqueue(SilKitEvent{simulationName, systemStateTracker.GetSystemState()}); } } @@ -433,16 +222,16 @@ void DashboardInstance::OnServiceDiscoveryEvent( const std::string& simulationName, const std::string& participantName, const SilKit::Core::Discovery::ServiceDiscoveryEvent& serviceDiscoveryEvent) { - _logger->MakeMessage(Level::Trace, TopicOf(*this)) - .SetMessage("DashboardInstance::OnServiceDiscoveryEvent: simulationName={} participantName={} serviceName={}", - simulationName, participantName, serviceDiscoveryEvent.serviceDescriptor.GetServiceName()) - .Dispatch(); - if (ShouldSkipServiceDiscoveryEvent(serviceDiscoveryEvent)) { return; } + _logger->MakeMessage(Level::Trace, TopicOf(*this)) + .SetMessage("DashboardInstance::OnServiceDiscoveryEvent: simulationName={} participantName={} serviceName={}", + simulationName, participantName, serviceDiscoveryEvent.serviceDescriptor.GetServiceName()) + .Dispatch(); + _silKitEventQueue.Enqueue( SilKitEvent{simulationName, ServiceData{serviceDiscoveryEvent.type, serviceDiscoveryEvent.serviceDescriptor}}); } @@ -455,9 +244,7 @@ void DashboardInstance::OnMetricsUpdate(const std::string& simulationName, const simulationName, origin, metricsUpdate) .Dispatch(); - std::pair data{origin, metricsUpdate}; - - _silKitEventQueue.Enqueue(SilKitEvent{simulationName, std::move(data)}); + _silKitEventQueue.Enqueue(SilKitEvent{simulationName, MetricsUpdatePair{origin, metricsUpdate}}); } diff --git a/SilKit/source/dashboard/DashboardInstance.hpp b/SilKit/source/dashboard/DashboardInstance.hpp index c63c4e821..c716a4497 100644 --- a/SilKit/source/dashboard/DashboardInstance.hpp +++ b/SilKit/source/dashboard/DashboardInstance.hpp @@ -4,38 +4,42 @@ #pragma once -#include "dashboard/IDashboardInstance.hpp" -#include "core/vasio/VAsioRegistry.hpp" - -#include "services/logging/LoggerMessage.hpp" +#include +#include +#include +#include +#include +#include +#include "core/vasio/IRegistryEventListener.hpp" #include "services/orchestration/SystemStateTracker.hpp" -#include "dashboard/IRestClient.hpp" +#include "util/Uri.hpp" +#include "dashboard/IDashboardInstance.hpp" +#include "dashboard/IRestClient.hpp" #include "dashboard/LockedQueue.hpp" #include "dashboard/SilKitEvent.hpp" -#include -#include -#include -#include -#include -#include - - namespace VSilKit { +/*! Forwards what the registry reports to the SIL Kit Dashboard's REST service. + * + * Two threads are involved. The registry calls the IRegistryEventListener methods below from its + * I/O thread; those only track per-simulation state and push onto _silKitEventQueue. A dedicated + * worker thread ("SK-Dash-Cons") drains that queue, batches the events per simulation and performs + * every HTTP request. The queue is the only synchronisation between the two. + * + * IRegistryEventListener is inherited privately on purpose: the registry receives a listener + * pointer from GetRegistryEventListener(), but the On* methods are not part of the public + * IDashboardInstance surface. + */ class DashboardInstance final : public IDashboardInstance , private SilKit::Core::IRegistryEventListener { - struct SimulationData - { - SystemStateTracker systemStateTracker; - }; - public: - explicit DashboardInstance(); + //! Throws if dashboardUri is not a usable http URI, so a bad --dashboard-uri fails at creation. + explicit DashboardInstance(const std::string& dashboardUri); DashboardInstance(const DashboardInstance&) = delete; DashboardInstance(DashboardInstance&&) = delete; @@ -46,14 +50,14 @@ class DashboardInstance final ~DashboardInstance() override; auto GetRegistryEventListener() -> SilKit::Core::IRegistryEventListener* override; - void SetupDashboardConnection(const std::string& dashboardUri) override; - -private: - auto GetOrCreateSimulationData(const std::string& simulationName) -> SimulationData&; - void RemoveSimulationData(const std::string& simulationName); private: - void RunEventQueueWorkerThread(); + /*! Connects to the dashboard and starts the worker thread. + * + * Deferred until OnRegistryUri because the REST client needs the logger from OnLoggerCreated + * and the events need the registry URI. Called once. + */ + void StartWorker(); private: // SilKit::Core::IRegistryEventListener void OnLoggerCreated(SilKit::Services::Logging::ILoggerInternal* logger) override; @@ -71,21 +75,22 @@ class DashboardInstance final const VSilKit::MetricsUpdate& metricsUpdate) override; private: + const std::string _dashboardUri; + /// Assigned in OnLoggerCreated SilKit::Services::Logging::ILoggerInternal* _logger{nullptr}; /// Assigned in OnRegistryUri - std::unique_ptr _registryUri; + std::optional _registryUri; - std::shared_ptr _dashboardRestClient; + std::unique_ptr _dashboardRestClient; LockedQueue _silKitEventQueue; - /// How long the destructor lets an in-flight dashboard request finish before aborting it. - std::chrono::milliseconds _shutdownGracePeriod{5000}; - std::thread _eventQueueWorkerThread; - std::promise _eventQueueWorkerThreadAbort; + /// Read by the worker thread, set by the destructor. + std::atomic _abortWorker{false}; - std::unordered_map _simulationEventHandlers; + /// One tracker per live simulation, touched only from the registry's thread. + std::unordered_map _systemStateTrackers; }; } // namespace VSilKit diff --git a/SilKit/source/dashboard/DashboardUnavailable.cpp b/SilKit/source/dashboard/DashboardUnavailable.cpp index 68013ea3f..1874c0021 100644 --- a/SilKit/source/dashboard/DashboardUnavailable.cpp +++ b/SilKit/source/dashboard/DashboardUnavailable.cpp @@ -2,12 +2,20 @@ // // SPDX-License-Identifier: MIT +// Replaces the whole dashboard implementation when SILKIT_BUILD_DASHBOARD is OFF. Callers should +// consult IsDashboardAvailable() first; the throwing factory is only a backstop. + #include "dashboard/CreateDashboardInstance.hpp" #include "silkit/participant/exception.hpp" namespace VSilKit { -auto CreateDashboardInstance() -> std::unique_ptr +auto IsDashboardAvailable() -> bool +{ + return false; +} + +auto CreateDashboardInstance(const std::string& /*dashboardUri*/) -> std::unique_ptr { throw SilKit::SilKitError("SIL Kit Dashboard support is disabled"); } diff --git a/SilKit/source/dashboard/EventQueueWorkerThread.cpp b/SilKit/source/dashboard/EventQueueWorkerThread.cpp new file mode 100644 index 000000000..b1f007fb9 --- /dev/null +++ b/SilKit/source/dashboard/EventQueueWorkerThread.cpp @@ -0,0 +1,183 @@ +// SPDX-FileCopyrightText: 2026 Vector Informatik GmbH +// +// SPDX-License-Identifier: MIT + +#include "dashboard/EventQueueWorkerThread.hpp" + +#include + +#include "core/internal/traits/SilKitLoggingTraits.hpp" +#include "services/logging/LoggerMessage.hpp" + +namespace VSilKit { + +using SilKit::Dashboard::DashboardBulkUpdate; +using SilKit::Services::Logging::ILoggerInternal; +using SilKit::Services::Logging::Level; + +EventQueueWorkerThread::EventQueueWorkerThread(ILoggerInternal* logger, IRestClient* dashboardRestClient, + LockedQueue* eventQueue, + const std::atomic* abort) + : _logger{logger} + , _dashboardRestClient{dashboardRestClient} + , _eventQueue{eventQueue} + , _abort{abort} +{ +} + +void EventQueueWorkerThread::operator()() const +try +{ + ProcessEvents(); +} +catch (const std::exception& exception) +{ + _logger->MakeMessage(Level::Error, TopicOf(*this)) + .SetMessage("Dashboard: event queue worker failed: {}", exception.what()) + .Dispatch(); +} +catch (...) +{ + _logger->MakeMessage(Level::Error, TopicOf(*this)) + .SetMessage("Dashboard: event queue worker failed with unknown exception") + .Dispatch(); +} + +auto EventQueueWorkerThread::IsAborted() const -> bool +{ + return _abort != nullptr && _abort->load(std::memory_order_acquire); +} + +void EventQueueWorkerThread::FlushAccumulated(std::unordered_map& bulkUpdates) const +{ + for (auto it = bulkUpdates.begin(); it != bulkUpdates.end();) + { + auto& bulkUpdate = it->second; + + if (bulkUpdate.Empty()) + { + ++it; + continue; + } + + const bool simulationEnded = bulkUpdate.stopped.has_value(); + _dashboardRestClient->OnBulkUpdate(it->first, bulkUpdate); + + if (simulationEnded) + { + it = bulkUpdates.erase(it); + } + else + { + bulkUpdate.Clear(); + ++it; + } + } +} + +void EventQueueWorkerThread::ProcessEvents() const +{ + std::unordered_map simulationNameToId; + std::unordered_map simulationBulkUpdates; + + std::vector events; + while (_eventQueue->DequeueAllInto(events)) + { + for (const auto& event : events) + { + if (IsAborted()) + { + // Send what has already been accumulated; the shutdown grace period exists precisely + // so this last update still reaches the dashboard. + FlushAccumulated(simulationBulkUpdates); + return; + } + + // OnSimulationStart is handled separately: it establishes the simulation id that every + // other event for that simulation needs. + if (event.Type() == SilKitEventType::OnSimulationStart) + { + FlushAccumulated(simulationBulkUpdates); + + const auto known{simulationNameToId.find(event.GetSimulationName())}; + if (known != simulationNameToId.end()) + { + // Queuing means a simulation can be announced more than once. + _logger->MakeMessage(Level::Debug, TopicOf(*this)) + .SetMessage("Dashboard: Simulation {} already has id {}", event.GetSimulationName(), + known->second) + .Dispatch(); + continue; + } + + const auto& simulationStart = event.GetSimulationStart(); + const auto simulationId = + _dashboardRestClient->OnSimulationStart(simulationStart.connectUri, simulationStart.time); + + if (simulationId == 0) + { + _logger->MakeMessage(Level::Warn, TopicOf(*this)) + .SetMessage("Dashboard: Simulation {} could not be created", event.GetSimulationName()) + .Dispatch(); + continue; + } + + simulationNameToId.emplace(event.GetSimulationName(), simulationId); + continue; + } + + const auto it{simulationNameToId.find(event.GetSimulationName())}; + if (it == simulationNameToId.end()) + { + _logger->MakeMessage(Level::Warn, TopicOf(*this)) + .SetMessage("Dashboard: Simulation {} is unknown", event.GetSimulationName()) + .Dispatch(); + continue; + } + + const auto simulationId{it->second}; + auto& bulkUpdate{simulationBulkUpdates[simulationId]}; + + switch (event.Type()) + { + case SilKitEventType::OnSimulationStart: + break; // handled above + + case SilKitEventType::OnParticipantConnected: + bulkUpdate.participantConnectionInformations.emplace_back( + event.GetParticipantConnectionInformation()); + break; + + case SilKitEventType::OnSystemStateChanged: + bulkUpdate.systemStates.emplace_back(event.GetSystemState()); + break; + + case SilKitEventType::OnParticipantStatusChanged: + bulkUpdate.participantStatuses.emplace_back(event.GetParticipantStatus()); + break; + + case SilKitEventType::OnServiceDiscoveryEvent: + bulkUpdate.serviceDatas.emplace_back(event.GetServiceData()); + break; + + case SilKitEventType::OnSimulationEnd: + bulkUpdate.stopped = event.GetSimulationEnd().time; + simulationNameToId.erase(it); + break; + + case SilKitEventType::OnMetricUpdate: + { + // Metrics are not batched; they go out on their own endpoint immediately. + const auto& data = event.GetMetricsUpdate(); + _dashboardRestClient->OnMetricsUpdate(simulationId, data.first, data.second); + break; + } + } + } + + events.clear(); + FlushAccumulated(simulationBulkUpdates); + } +} + +} // namespace VSilKit diff --git a/SilKit/source/dashboard/EventQueueWorkerThread.hpp b/SilKit/source/dashboard/EventQueueWorkerThread.hpp new file mode 100644 index 000000000..d899370d5 --- /dev/null +++ b/SilKit/source/dashboard/EventQueueWorkerThread.hpp @@ -0,0 +1,62 @@ +// SPDX-FileCopyrightText: 2026 Vector Informatik GmbH +// +// SPDX-License-Identifier: MIT + +#pragma once + +#include +#include +#include +#include + +#include "services/logging/ILoggerInternal.hpp" + +#include "dashboard/DashboardBulkUpdate.hpp" +#include "dashboard/IRestClient.hpp" +#include "dashboard/LockedQueue.hpp" +#include "dashboard/SilKitEvent.hpp" + +namespace VSilKit { + +/*! Drains the dashboard event queue, batches the events per simulation and issues the requests. + * + * Normally run on its own thread by DashboardInstance, which owns everything referenced here and + * joins the thread before those objects die. All of the worker's own state is local to + * ProcessEvents(), so an instance is cheap and holds nothing between runs. + * + * Batching: DequeueAllInto() blocks until at least one event is available, then takes everything + * queued so far. Those events are folded into one DashboardBulkUpdate per simulation, and the + * accumulated updates are flushed once at the end of the batch. Batch size is therefore whatever + * accumulated while the previous batch was being sent - it self-tunes, with no timer. + * + * Two events are not batched: OnSimulationStart, which must first obtain the simulation id that + * every other event needs, and OnMetricUpdate, which has its own endpoint. + */ +class EventQueueWorkerThread +{ +public: + EventQueueWorkerThread(SilKit::Services::Logging::ILoggerInternal* logger, IRestClient* dashboardRestClient, + LockedQueue* eventQueue, const std::atomic* abort); + + //! Runs until the queue is stopped or the abort flag is set. Never throws. + void operator()() const; + +private: + auto IsAborted() const -> bool; + + /*! Sends every non-empty accumulated update. + * + * An update carrying `stopped` is the last one for that simulation, so its entry is dropped + * afterwards instead of being kept around empty for the rest of the process's life. + */ + void FlushAccumulated(std::unordered_map& bulkUpdates) const; + + void ProcessEvents() const; + + SilKit::Services::Logging::ILoggerInternal* _logger{nullptr}; + IRestClient* _dashboardRestClient{nullptr}; + LockedQueue* _eventQueue{nullptr}; + const std::atomic* _abort{nullptr}; +}; + +} // namespace VSilKit diff --git a/SilKit/source/dashboard/IDashboardInstance.hpp b/SilKit/source/dashboard/IDashboardInstance.hpp index 4a0aadd14..8593fbfa4 100644 --- a/SilKit/source/dashboard/IDashboardInstance.hpp +++ b/SilKit/source/dashboard/IDashboardInstance.hpp @@ -5,9 +5,6 @@ #pragma once -#include - - namespace SilKit { namespace Core { struct IRegistryEventListener; @@ -18,13 +15,26 @@ struct IRegistryEventListener; namespace VSilKit { +/*! A live connection from the registry to the SIL Kit Dashboard. + * + * Deliberately minimal: the registry only needs to hand the listener to VAsioRegistry and then keep + * the instance alive. Everything else - connecting, batching, shutting down - is driven by the + * registry events themselves and by the destructor. + * + * This is also the seam that SILKIT_BUILD_DASHBOARD switches: in a build without dashboard support + * DashboardInstance does not exist, and this abstract base is the only type that can name the + * registry's member. + */ struct IDashboardInstance { virtual ~IDashboardInstance() = default; + /*! The listener to pass to the registry. + * + * Remains owned by this instance, which must outlive the registry. + */ virtual auto GetRegistryEventListener() -> SilKit::Core::IRegistryEventListener* = 0; - virtual void SetupDashboardConnection(const std::string& dashboardUri) = 0; }; -} // namespace VSilKit \ No newline at end of file +} // namespace VSilKit diff --git a/SilKit/source/dashboard/IRestClient.hpp b/SilKit/source/dashboard/IRestClient.hpp index a7e3fbd18..df237fbcb 100644 --- a/SilKit/source/dashboard/IRestClient.hpp +++ b/SilKit/source/dashboard/IRestClient.hpp @@ -26,8 +26,6 @@ class IRestClient virtual void OnBulkUpdate(uint64_t simulationId, const SilKit::Dashboard::DashboardBulkUpdate& bulkUpdate) = 0; virtual void OnMetricsUpdate(uint64_t simulationId, const std::string& origin, const VSilKit::MetricsUpdate& metricsUpdate) = 0; - virtual bool IsBulkUpdateSupported() = 0; - /*! Unblock any in-flight request and make all further ones fail fast. Idempotent. * * Needed on shutdown: a dashboard server that accepts connections but never answers would diff --git a/SilKit/source/dashboard/Mocks/MockRestClient.hpp b/SilKit/source/dashboard/Mocks/MockRestClient.hpp new file mode 100644 index 000000000..17c49001c --- /dev/null +++ b/SilKit/source/dashboard/Mocks/MockRestClient.hpp @@ -0,0 +1,27 @@ +// SPDX-FileCopyrightText: 2026 Vector Informatik GmbH +// +// SPDX-License-Identifier: MIT + +#pragma once + +#include "gmock/gmock.h" + +#include "dashboard/DashboardBulkUpdate.hpp" +#include "dashboard/IRestClient.hpp" +#include "services/metrics/MetricsDatatypes.hpp" + +namespace VSilKit { + +class MockRestClient : public IRestClient +{ +public: + MOCK_METHOD(uint64_t, OnSimulationStart, (const std::string& connectUri, uint64_t time), (override)); + MOCK_METHOD(void, OnBulkUpdate, (uint64_t simulationId, const SilKit::Dashboard::DashboardBulkUpdate& bulkUpdate), + (override)); + MOCK_METHOD(void, OnMetricsUpdate, + (uint64_t simulationId, const std::string& origin, const VSilKit::MetricsUpdate& metricsUpdate), + (override)); + MOCK_METHOD(void, Abort, (), (override)); +}; + +} // namespace VSilKit diff --git a/SilKit/source/dashboard/SilKitEvent.hpp b/SilKit/source/dashboard/SilKitEvent.hpp index 75bec52c1..8cb84d115 100644 --- a/SilKit/source/dashboard/SilKitEvent.hpp +++ b/SilKit/source/dashboard/SilKitEvent.hpp @@ -4,7 +4,14 @@ #pragma once +#include +#include +#include +#include +#include + #include "silkit/services/orchestration/OrchestrationDatatypes.hpp" + #include "core/service/ServiceDatatypes.hpp" #include "services/metrics/MetricsDatatypes.hpp" @@ -27,6 +34,20 @@ struct SimulationEnd uint64_t time; }; +using MetricsUpdatePair = std::pair; + +//! Payload of a SilKitEvent. The alternatives are in the same order as SilKitEventType. +using SilKitEventData = + std::variant; + +//! Discriminator for SilKitEventData. Enumerator values must match the variant alternative order; +//! the static_asserts below enforce that. enum class SilKitEventType { OnSimulationStart, @@ -38,184 +59,105 @@ enum class SilKitEventType OnMetricUpdate, }; -template -struct TypeIdTrait -{ - static const SilKitEventType typeId = id; -}; - -template -struct SilKitEventTrait; - -#define SILKIT_EVENT(TYPENAME, SILKIT_EVENT_TYPE_ENUMERATOR) \ - template <> \ - struct SilKitEventTrait : TypeIdTrait \ - { \ - }; - -using MetricsUpdatePair = std::pair; - -SILKIT_EVENT(SimulationStart, OnSimulationStart) -SILKIT_EVENT(SilKit::Services::Orchestration::ParticipantConnectionInformation, OnParticipantConnected) -SILKIT_EVENT(SilKit::Services::Orchestration::SystemState, OnSystemStateChanged) -SILKIT_EVENT(SilKit::Services::Orchestration::ParticipantStatus, OnParticipantStatusChanged) -SILKIT_EVENT(ServiceData, OnServiceDiscoveryEvent) -SILKIT_EVENT(SimulationEnd, OnSimulationEnd) -SILKIT_EVENT(MetricsUpdatePair, OnMetricUpdate) +namespace Detail { -#undef SILKIT_EVENT +template +constexpr auto EventTypeMatchesAlternative() -> bool +{ + return std::is_same(kType), SilKitEventData>, T>::value; +} +} // namespace Detail + +static_assert(std::variant_size::value == 7, "SilKitEventType and SilKitEventData disagree"); +static_assert(Detail::EventTypeMatchesAlternative(), ""); +static_assert(Detail::EventTypeMatchesAlternative< + SilKitEventType::OnParticipantConnected, + SilKit::Services::Orchestration::ParticipantConnectionInformation>(), + ""); +static_assert(Detail::EventTypeMatchesAlternative(), + ""); +static_assert(Detail::EventTypeMatchesAlternative(), + ""); +static_assert(Detail::EventTypeMatchesAlternative(), ""); +static_assert(Detail::EventTypeMatchesAlternative(), ""); +static_assert(Detail::EventTypeMatchesAlternative(), ""); + +/*! One thing that happened in one simulation, queued for the dashboard worker thread. + * + * Copyable and movable with the compiler-generated operations; the payload lives inline in the + * variant, so enqueueing an event costs no allocation beyond the payload's own. + */ class SilKitEvent { public: SilKitEvent() = delete; - SilKitEvent(const SilKitEvent& other) - : _type(other._type) - , _simulationName{other._simulationName} - , _data(other._clone(other._data)) - , _clone(other._clone) - , _destroy(other._destroy) - { - } - - SilKitEvent(SilKitEvent&& other) noexcept - { - swap(other); - } - - ~SilKitEvent() - { - if (_destroy != nullptr) - { - _destroy(_data); - } - }; - template - explicit SilKitEvent(std::string simulationName, const T& value) - : _type{getTypeId()} - , _simulationName{std::move(simulationName)} - , _data{new T{value}} - , _clone([](void* otherData) -> void* { return new T(*static_cast(otherData)); }) - , _destroy([](void* data) { delete static_cast(data); }) + explicit SilKitEvent(std::string simulationName, T&& value) + : _simulationName{std::move(simulationName)} + , _data{std::forward(value)} { } - SilKitEvent& operator=(const SilKitEvent& other) + auto Type() const -> SilKitEventType { - if (this == &other) - { - return *this; - } - if (_destroy != nullptr) - { - _destroy(_data); - } - _type = other._type; - _simulationName = other._simulationName; - _data = other._clone(other._data); - _clone = other._clone; - _destroy = other._destroy; - return *this; + return static_cast(_data.index()); } - SilKitEvent& operator=(SilKitEvent&& other) noexcept + auto GetSimulationName() const -> const std::string& { - if (this == &other) - { - return *this; - } - swap(other); - return *this; + return _simulationName; } - auto Type() const -> SilKitEventType + auto Data() const -> const SilKitEventData& { - return _type; + return _data; } - auto GetSimulationName() const -> const std::string& - { - return _simulationName; - } + // Typed accessors. Each throws std::bad_variant_access if the event holds a different type. auto GetSimulationStart() const -> const SimulationStart& { - return Get(); + return std::get(_data); } auto GetParticipantConnectionInformation() const -> const SilKit::Services::Orchestration::ParticipantConnectionInformation& { - return Get(); + return std::get(_data); } auto GetParticipantStatus() const -> const SilKit::Services::Orchestration::ParticipantStatus& { - return Get(); + return std::get(_data); } auto GetSystemState() const -> const SilKit::Services::Orchestration::SystemState& { - return Get(); + return std::get(_data); } auto GetServiceData() const -> const ServiceData& { - return Get(); + return std::get(_data); } auto GetSimulationEnd() const -> const SimulationEnd& { - return Get(); + return std::get(_data); } - auto GetMetricsUpdate() const -> const std::pair& + auto GetMetricsUpdate() const -> const MetricsUpdatePair& { - return Get>(); + return std::get(_data); } private: - template - constexpr SilKitEventType getTypeId() const - { - return SilKitEventTrait::typeId; - } - - template - inline const T& Get() const; - - inline void swap(SilKitEvent& other) noexcept; - -private: - SilKitEventType _type{}; std::string _simulationName; - void* _data{nullptr}; - void* (*_clone)(void* otherData){nullptr}; - void (*_destroy)(void* data){nullptr}; + SilKitEventData _data; }; -template -const T& SilKitEvent::Get() const -{ - const auto tag = getTypeId(); - if (tag != _type) - { - throw SilKit::SilKitError("SilKitEvent::Get() Requested type does not match stored type."); - } - - return *(reinterpret_cast(_data)); -} - -void SilKitEvent::swap(SilKitEvent& other) noexcept -{ - using std::swap; - swap(_type, other._type); - swap(_simulationName, other._simulationName); - swap(_data, other._data); - swap(_clone, other._clone); - swap(_destroy, other._destroy); -} - } // namespace VSilKit diff --git a/SilKit/source/dashboard/Test_DashboardEventQueueWorker.cpp b/SilKit/source/dashboard/Test_DashboardEventQueueWorker.cpp new file mode 100644 index 000000000..52acd4e26 --- /dev/null +++ b/SilKit/source/dashboard/Test_DashboardEventQueueWorker.cpp @@ -0,0 +1,342 @@ +// SPDX-FileCopyrightText: 2026 Vector Informatik GmbH +// +// SPDX-License-Identifier: MIT + +/*! Tests for the dashboard's event batching. + * + * The worker is driven synchronously: the queue is filled, then stopped, then the worker is run on + * the test's own thread. LockedQueue::Stop() makes DequeueAllInto() hand over whatever is queued + * and return false on the next call, so the worker processes everything and returns - no threads + * and no timing in these tests. + */ + +#include "dashboard/EventQueueWorkerThread.hpp" + +#include +#include +#include + +#include "gmock/gmock.h" +#include "gtest/gtest.h" + +#include "core/mock/participant/MockParticipant.hpp" + +#include "dashboard/Mocks/MockRestClient.hpp" + +using namespace testing; +using SilKit::Dashboard::DashboardBulkUpdate; + +namespace VSilKit { +namespace { + +namespace orchestration = SilKit::Services::Orchestration; + +constexpr uint64_t kSimulationId{42}; +constexpr uint64_t kOtherSimulationId{43}; + +auto MakeParticipantStatus(const std::string& participantName, + orchestration::ParticipantState state) -> orchestration::ParticipantStatus +{ + orchestration::ParticipantStatus status{}; + status.participantName = participantName; + status.state = state; + status.enterReason = "because"; + return status; +} + +auto MakeServiceData() -> ServiceData +{ + ServiceData serviceData{}; + serviceData.discoveryType = SilKit::Core::Discovery::ServiceDiscoveryEvent::Type::ServiceCreated; + serviceData.serviceDescriptor.SetServiceName("aService"); + return serviceData; +} + +class Test_DashboardEventQueueWorker : public Test +{ +public: + void SetUp() override + { + EXPECT_CALL(_dummyLogger, GetLogLevel).WillRepeatedly(Return(SilKit::Services::Logging::Level::Off)); + } + + template + void Enqueue(const std::string& simulationName, T&& payload) + { + _queue.Enqueue(SilKitEvent{simulationName, std::forward(payload)}); + } + + void EnqueueSimulationStart(const std::string& simulationName) + { + Enqueue(simulationName, SimulationStart{"silkit://localhost:8500/" + simulationName, 1000}); + } + + //! Stops the queue so the worker drains it, then runs the worker to completion inline. + void RunWorkerToCompletion() + { + _queue.Stop(); + EventQueueWorkerThread worker{&_dummyLogger, &_restClient, &_queue, &_abort}; + worker(); + } + + NiceMock _dummyLogger; + StrictMock _restClient; + LockedQueue _queue; + std::atomic _abort{false}; +}; + +// --- simulation lifecycle --------------------------------------------------------------------- + +TEST_F(Test_DashboardEventQueueWorker, SimulationStart_CreatesTheSimulation) +{ + EXPECT_CALL(_restClient, OnSimulationStart("silkit://localhost:8500/sim", 1000)).WillOnce(Return(kSimulationId)); + + EnqueueSimulationStart("sim"); + RunWorkerToCompletion(); +} + +/*! Queuing lets the same simulation be announced twice, which must not create it twice - a second + * id would split the simulation's data across two dashboard entries. + */ +TEST_F(Test_DashboardEventQueueWorker, SimulationStart_Twice_CreatesTheSimulationOnce) +{ + EXPECT_CALL(_restClient, OnSimulationStart(_, _)).WillOnce(Return(kSimulationId)); + + EnqueueSimulationStart("sim"); + EnqueueSimulationStart("sim"); + RunWorkerToCompletion(); +} + +TEST_F(Test_DashboardEventQueueWorker, SimulationStart_ForDistinctNames_CreatesEachSimulation) +{ + EXPECT_CALL(_restClient, OnSimulationStart("silkit://localhost:8500/a", _)).WillOnce(Return(kSimulationId)); + EXPECT_CALL(_restClient, OnSimulationStart("silkit://localhost:8500/b", _)).WillOnce(Return(kOtherSimulationId)); + + EnqueueSimulationStart("a"); + EnqueueSimulationStart("b"); + RunWorkerToCompletion(); +} + +//! Id 0 is the failure sentinel, so nothing more may be sent for that simulation. +TEST_F(Test_DashboardEventQueueWorker, SimulationStart_Failing_DropsTheSimulationsEvents) +{ + EXPECT_CALL(_restClient, OnSimulationStart(_, _)).WillOnce(Return(0)); + EXPECT_CALL(_restClient, OnBulkUpdate(_, _)).Times(0); + + EnqueueSimulationStart("sim"); + Enqueue("sim", orchestration::ParticipantConnectionInformation{"P1"}); + RunWorkerToCompletion(); +} + +//! An event for a simulation that was never started is dropped, not attributed to another id. +TEST_F(Test_DashboardEventQueueWorker, EventsForAnUnknownSimulation_AreDropped) +{ + EXPECT_CALL(_restClient, OnBulkUpdate(_, _)).Times(0); + + Enqueue("neverStarted", orchestration::ParticipantConnectionInformation{"P1"}); + RunWorkerToCompletion(); +} + +// --- batching --------------------------------------------------------------------------------- + +TEST_F(Test_DashboardEventQueueWorker, Events_AreBatchedIntoASingleBulkUpdate) +{ + EXPECT_CALL(_restClient, OnSimulationStart(_, _)).WillOnce(Return(kSimulationId)); + + DashboardBulkUpdate captured; + EXPECT_CALL(_restClient, OnBulkUpdate(kSimulationId, _)) + .WillOnce(WithArg<1>([&captured](const DashboardBulkUpdate& update) { + captured.participantConnectionInformations = update.participantConnectionInformations; + captured.participantStatuses = update.participantStatuses; + captured.systemStates = update.systemStates; + captured.serviceDatas = update.serviceDatas; + captured.stopped = update.stopped; + })); + + EnqueueSimulationStart("sim"); + Enqueue("sim", orchestration::ParticipantConnectionInformation{"P1"}); + Enqueue("sim", orchestration::ParticipantConnectionInformation{"P2"}); + Enqueue("sim", MakeParticipantStatus("P1", orchestration::ParticipantState::Running)); + Enqueue("sim", orchestration::SystemState::Running); + Enqueue("sim", MakeServiceData()); + RunWorkerToCompletion(); + + EXPECT_EQ(captured.participantConnectionInformations.size(), 2u); + EXPECT_EQ(captured.participantStatuses.size(), 1u); + EXPECT_EQ(captured.systemStates.size(), 1u); + EXPECT_EQ(captured.serviceDatas.size(), 1u); + EXPECT_FALSE(captured.stopped.has_value()); +} + +TEST_F(Test_DashboardEventQueueWorker, EachSimulation_GetsItsOwnBulkUpdate) +{ + EXPECT_CALL(_restClient, OnSimulationStart("silkit://localhost:8500/a", _)).WillOnce(Return(kSimulationId)); + EXPECT_CALL(_restClient, OnSimulationStart("silkit://localhost:8500/b", _)).WillOnce(Return(kOtherSimulationId)); + + size_t participantsForA{0}; + size_t participantsForB{0}; + EXPECT_CALL(_restClient, OnBulkUpdate(kSimulationId, _)) + .WillOnce(WithArg<1>([&](const DashboardBulkUpdate& u) { + participantsForA = u.participantConnectionInformations.size(); + })); + EXPECT_CALL(_restClient, OnBulkUpdate(kOtherSimulationId, _)) + .WillOnce(WithArg<1>([&](const DashboardBulkUpdate& u) { + participantsForB = u.participantConnectionInformations.size(); + })); + + EnqueueSimulationStart("a"); + EnqueueSimulationStart("b"); + Enqueue("a", orchestration::ParticipantConnectionInformation{"P1"}); + Enqueue("b", orchestration::ParticipantConnectionInformation{"P2"}); + Enqueue("b", orchestration::ParticipantConnectionInformation{"P3"}); + RunWorkerToCompletion(); + + EXPECT_EQ(participantsForA, 1u); + EXPECT_EQ(participantsForB, 2u); +} + +//! Nothing to report means no request at all, rather than an empty bulk update every batch. +TEST_F(Test_DashboardEventQueueWorker, ASimulationWithNothingToReport_SendsNoBulkUpdate) +{ + EXPECT_CALL(_restClient, OnSimulationStart(_, _)).WillOnce(Return(kSimulationId)); + EXPECT_CALL(_restClient, OnBulkUpdate(_, _)).Times(0); + + EnqueueSimulationStart("sim"); + RunWorkerToCompletion(); +} + +/*! A new simulation start flushes first, so events accumulated before it are not attributed to a + * batch that also contains the new simulation. + */ +TEST_F(Test_DashboardEventQueueWorker, ASimulationStart_FlushesWhatWasAlreadyAccumulated) +{ + InSequence sequence; + + EXPECT_CALL(_restClient, OnSimulationStart("silkit://localhost:8500/a", _)).WillOnce(Return(kSimulationId)); + EXPECT_CALL(_restClient, OnBulkUpdate(kSimulationId, _)); + EXPECT_CALL(_restClient, OnSimulationStart("silkit://localhost:8500/b", _)).WillOnce(Return(kOtherSimulationId)); + + EnqueueSimulationStart("a"); + Enqueue("a", orchestration::ParticipantConnectionInformation{"P1"}); + EnqueueSimulationStart("b"); + RunWorkerToCompletion(); +} + +// --- simulation end --------------------------------------------------------------------------- + +TEST_F(Test_DashboardEventQueueWorker, SimulationEnd_SetsStopped) +{ + EXPECT_CALL(_restClient, OnSimulationStart(_, _)).WillOnce(Return(kSimulationId)); + + std::optional stopped; + EXPECT_CALL(_restClient, OnBulkUpdate(kSimulationId, _)) + .WillOnce(WithArg<1>([&stopped](const DashboardBulkUpdate& u) { stopped = u.stopped; })); + + EnqueueSimulationStart("sim"); + Enqueue("sim", SimulationEnd{7777}); + RunWorkerToCompletion(); + + ASSERT_TRUE(stopped.has_value()); + EXPECT_EQ(*stopped, 7777u); +} + +//! After the end the name is forgotten, so late events are dropped rather than reusing the id. +TEST_F(Test_DashboardEventQueueWorker, EventsAfterSimulationEnd_AreDropped) +{ + EXPECT_CALL(_restClient, OnSimulationStart(_, _)).WillOnce(Return(kSimulationId)); + EXPECT_CALL(_restClient, OnBulkUpdate(kSimulationId, _)).Times(1); + + EnqueueSimulationStart("sim"); + Enqueue("sim", SimulationEnd{1}); + Enqueue("sim", orchestration::ParticipantConnectionInformation{"tooLate"}); + RunWorkerToCompletion(); +} + +//! A simulation may start again under the same name after it ended. +TEST_F(Test_DashboardEventQueueWorker, ASimulationNameCanBeReusedAfterItEnded) +{ + EXPECT_CALL(_restClient, OnSimulationStart(_, _)) + .WillOnce(Return(kSimulationId)) + .WillOnce(Return(kOtherSimulationId)); + EXPECT_CALL(_restClient, OnBulkUpdate(kSimulationId, _)); + EXPECT_CALL(_restClient, OnBulkUpdate(kOtherSimulationId, _)); + + EnqueueSimulationStart("sim"); + Enqueue("sim", SimulationEnd{1}); + EnqueueSimulationStart("sim"); + Enqueue("sim", orchestration::ParticipantConnectionInformation{"P1"}); + RunWorkerToCompletion(); +} + +// --- metrics ---------------------------------------------------------------------------------- + +//! Metrics have their own endpoint and are sent as they arrive rather than folded into the batch. +TEST_F(Test_DashboardEventQueueWorker, MetricsUpdates_AreSentImmediatelyAndNotBatched) +{ + EXPECT_CALL(_restClient, OnSimulationStart(_, _)).WillOnce(Return(kSimulationId)); + EXPECT_CALL(_restClient, OnMetricsUpdate(kSimulationId, "P1", _)).Times(1); + EXPECT_CALL(_restClient, OnBulkUpdate(_, _)).Times(0); + + EnqueueSimulationStart("sim"); + Enqueue("sim", MetricsUpdatePair{"P1", MetricsUpdate{}}); + RunWorkerToCompletion(); +} + +// --- abort ------------------------------------------------------------------------------------ + +/*! Abort still flushes what was already accumulated. + * + * The shutdown grace period in DashboardInstance exists precisely so the last accumulated update + * reaches the dashboard, which only works if the worker sends it on the way out. The flag is + * tripped from the metrics call, which is the one request issued while a batch is accumulating. + */ +TEST_F(Test_DashboardEventQueueWorker, Abort_FlushesWhatWasAccumulated) +{ + EXPECT_CALL(_restClient, OnSimulationStart(_, _)).WillOnce(Return(kSimulationId)); + EXPECT_CALL(_restClient, OnMetricsUpdate(kSimulationId, "P1", _)).WillOnce([this](auto&&...) { + _abort.store(true); + }); + + size_t participants{0}; + EXPECT_CALL(_restClient, OnBulkUpdate(kSimulationId, _)) + .WillOnce(WithArg<1>([&](const DashboardBulkUpdate& update) { + participants = update.participantConnectionInformations.size(); + })); + + EnqueueSimulationStart("sim"); + Enqueue("sim", orchestration::ParticipantConnectionInformation{"P1"}); + Enqueue("sim", MetricsUpdatePair{"P1", MetricsUpdate{}}); // trips the abort flag + Enqueue("sim", orchestration::ParticipantConnectionInformation{"neverProcessed"}); + + RunWorkerToCompletion(); + + // P1 was accumulated before the abort and must still have been sent; the event queued after the + // flag was tripped must not have been. + EXPECT_EQ(participants, 1u); +} + +TEST_F(Test_DashboardEventQueueWorker, Abort_BeforeAnyEvent_SendsNothing) +{ + EXPECT_CALL(_restClient, OnSimulationStart(_, _)).Times(0); + EXPECT_CALL(_restClient, OnBulkUpdate(_, _)).Times(0); + + EnqueueSimulationStart("sim"); + _abort.store(true); + + RunWorkerToCompletion(); +} + +// --- robustness ------------------------------------------------------------------------------- + +//! A throwing REST client must not escape the worker; DashboardInstance relies on it returning. +TEST_F(Test_DashboardEventQueueWorker, AThrowingRestClient_DoesNotEscapeTheWorker) +{ + EXPECT_CALL(_restClient, OnSimulationStart(_, _)).WillOnce(Throw(std::runtime_error{"boom"})); + + EnqueueSimulationStart("sim"); + + EXPECT_NO_THROW(RunWorkerToCompletion()); +} + +} // namespace +} // namespace VSilKit diff --git a/SilKit/source/dashboard/client/DashboardSystemServiceClient.cpp b/SilKit/source/dashboard/client/DashboardSystemServiceClient.cpp index 1cc0a10d3..0d96eeff3 100644 --- a/SilKit/source/dashboard/client/DashboardSystemServiceClient.cpp +++ b/SilKit/source/dashboard/client/DashboardSystemServiceClient.cpp @@ -50,14 +50,6 @@ void DashboardSystemServiceClient::UpdateSimulationMetrics(uint64_t simulationId Log(result, "updating simulation metrics"); } -auto DashboardSystemServiceClient::CheckBulkUpdateSupported() -> bool -{ - // Deliberately unlogged: the previous implementation probed through the raw api client, which - // did not log either, and a failure here is reported by the caller instead. - const auto result = _httpClient->Post(Paths::UpdateSimulation(0), ToJson(BulkSimulationDto{})); - return !result.transportError && 200 <= result.statusCode && result.statusCode < 300; -} - void DashboardSystemServiceClient::Log(const VSilKit::HttpResult& result, const std::string& message) { if (result.transportError) diff --git a/SilKit/source/dashboard/client/DashboardSystemServiceClient.hpp b/SilKit/source/dashboard/client/DashboardSystemServiceClient.hpp index 2ce2e5175..7c707337e 100644 --- a/SilKit/source/dashboard/client/DashboardSystemServiceClient.hpp +++ b/SilKit/source/dashboard/client/DashboardSystemServiceClient.hpp @@ -24,7 +24,6 @@ class DashboardSystemServiceClient : public IDashboardSystemServiceClient auto CreateSimulation(const SimulationCreationRequestDto& simulation) -> std::optional override; void UpdateSimulation(uint64_t simulationId, const BulkSimulationDto& bulkSimulation) override; void UpdateSimulationMetrics(uint64_t simulationId, const MetricsUpdateDto& metrics) override; - auto CheckBulkUpdateSupported() -> bool override; private: void Log(const VSilKit::HttpResult& result, const std::string& message); diff --git a/SilKit/source/dashboard/client/IDashboardSystemServiceClient.hpp b/SilKit/source/dashboard/client/IDashboardSystemServiceClient.hpp index cc9818007..95f51a5c8 100644 --- a/SilKit/source/dashboard/client/IDashboardSystemServiceClient.hpp +++ b/SilKit/source/dashboard/client/IDashboardSystemServiceClient.hpp @@ -28,9 +28,6 @@ class IDashboardSystemServiceClient virtual void UpdateSimulation(uint64_t simulationId, const BulkSimulationDto& bulkSimulation) = 0; virtual void UpdateSimulationMetrics(uint64_t simulationId, const MetricsUpdateDto& metrics) = 0; - - //! Probe whether the dashboard service supports the bulk-update endpoint. - virtual auto CheckBulkUpdateSupported() -> bool = 0; }; } // namespace Dashboard diff --git a/SilKit/source/dashboard/client/Mocks/MockDashboardSystemServiceClient.hpp b/SilKit/source/dashboard/client/Mocks/MockDashboardSystemServiceClient.hpp index 6571dc050..373fa97de 100644 --- a/SilKit/source/dashboard/client/Mocks/MockDashboardSystemServiceClient.hpp +++ b/SilKit/source/dashboard/client/Mocks/MockDashboardSystemServiceClient.hpp @@ -18,7 +18,6 @@ class MockDashboardSystemServiceClient : public IDashboardSystemServiceClient (override)); MOCK_METHOD(void, UpdateSimulation, (uint64_t simulationId, const BulkSimulationDto& bulkSimulation), (override)); MOCK_METHOD(void, UpdateSimulationMetrics, (uint64_t simulationId, const MetricsUpdateDto& metrics), (override)); - MOCK_METHOD(bool, CheckBulkUpdateSupported, (), (override)); }; } // namespace Dashboard diff --git a/SilKit/source/dashboard/client/Test_DashboardSystemServiceClient.cpp b/SilKit/source/dashboard/client/Test_DashboardSystemServiceClient.cpp index 60c457d14..b1b25f5a9 100644 --- a/SilKit/source/dashboard/client/Test_DashboardSystemServiceClient.cpp +++ b/SilKit/source/dashboard/client/Test_DashboardSystemServiceClient.cpp @@ -157,42 +157,6 @@ TEST_F(Test_DashboardSystemServiceClient, UpdateSimulationMetrics_UsesTheMetrics EXPECT_EQ(actualBody, ToJson(MetricsUpdateDto{})); } -// --- CheckBulkUpdateSupported ----------------------------------------------------------------- - -/*! Probes the bulk endpoint with simulation id 0 and an empty payload, and deliberately does not - * log: the previous implementation issued this request through the raw api client, bypassing the - * logging wrapper, and the caller reports the outcome instead. - */ -TEST_F(Test_DashboardSystemServiceClient, CheckBulkUpdateSupported_SuccessStatusMeansSupported) -{ - std::string actualBody; - EXPECT_CALL(*_mockHttpClient, Post("system-service/v1.1/simulations/0", _)) - .WillOnce(DoAll(SaveArg<1>(&actualBody), Return(Responded(200)))); - - const auto service = CreateService(); - - EXPECT_TRUE(service->CheckBulkUpdateSupported()); - EXPECT_EQ(actualBody, "{\"stopped\": null,\"system\": {\"statuses\": []},\"participants\": []}"); -} - -TEST_F(Test_DashboardSystemServiceClient, CheckBulkUpdateSupported_NonSuccessStatusMeansUnsupported) -{ - EXPECT_CALL(*_mockHttpClient, Post(_, _)).WillOnce(Return(Responded(404))); - - const auto service = CreateService(); - - EXPECT_FALSE(service->CheckBulkUpdateSupported()); -} - -TEST_F(Test_DashboardSystemServiceClient, CheckBulkUpdateSupported_TransportFailureMeansUnsupported) -{ - EXPECT_CALL(*_mockHttpClient, Post(_, _)).WillOnce(Return(Unavailable())); - - const auto service = CreateService(); - - EXPECT_FALSE(service->CheckBulkUpdateSupported()); -} - } // namespace } // namespace Dashboard } // namespace SilKit diff --git a/SilKit/source/dashboard/json/Test_DashboardJsonWriter.cpp b/SilKit/source/dashboard/json/Test_DashboardJsonWriter.cpp index c558f313f..360e4b7d7 100644 --- a/SilKit/source/dashboard/json/Test_DashboardJsonWriter.cpp +++ b/SilKit/source/dashboard/json/Test_DashboardJsonWriter.cpp @@ -53,9 +53,9 @@ auto MakeController(uint64_t id, std::string name, std::string networkName) -> B return controller; } -// --- the payload IsBulkUpdateSupported() probes with ------------------------------------------ +// --- an empty bulk update --------------------------------------------------------------------- -TEST(Test_DashboardJsonWriter, BulkSimulationDto_Default_MatchesTheBulkUpdateProbePayload) +TEST(Test_DashboardJsonWriter, BulkSimulationDto_Default) { EXPECT_EQ(ToJson(BulkSimulationDto{}), "{\"stopped\": null,\"system\": {\"statuses\": []},\"participants\": []}"); diff --git a/SilKit/source/dashboard/service/DashboardRestClient.cpp b/SilKit/source/dashboard/service/DashboardRestClient.cpp index 9a7e8df0e..43add4581 100644 --- a/SilKit/source/dashboard/service/DashboardRestClient.cpp +++ b/SilKit/source/dashboard/service/DashboardRestClient.cpp @@ -60,11 +60,6 @@ void DashboardRestClient::Abort() } } -bool DashboardRestClient::IsBulkUpdateSupported() -{ - return _serviceClient->CheckBulkUpdateSupported(); -} - uint64_t DashboardRestClient::OnSimulationStart(const std::string& connectUri, uint64_t time) { _logger->MakeMessage(Level::Info, TopicOf(*this)) diff --git a/SilKit/source/dashboard/service/DashboardRestClient.hpp b/SilKit/source/dashboard/service/DashboardRestClient.hpp index 67692d52c..91d77b77b 100644 --- a/SilKit/source/dashboard/service/DashboardRestClient.hpp +++ b/SilKit/source/dashboard/service/DashboardRestClient.hpp @@ -32,7 +32,6 @@ class DashboardRestClient : public VSilKit::IRestClient std::shared_ptr mapper); public: // IRestClient - bool IsBulkUpdateSupported() override; uint64_t OnSimulationStart(const std::string& connectUri, uint64_t time) override; void OnBulkUpdate(uint64_t simulationId, const DashboardBulkUpdate& bulkUpdate) override; diff --git a/SilKit/source/dashboard/service/Test_DashboardDtoMapper.cpp b/SilKit/source/dashboard/service/Test_DashboardDtoMapper.cpp index 8eaf4077f..a0f8865ac 100644 --- a/SilKit/source/dashboard/service/Test_DashboardDtoMapper.cpp +++ b/SilKit/source/dashboard/service/Test_DashboardDtoMapper.cpp @@ -266,7 +266,7 @@ TEST_F(Test_DashboardDtoMapper, CreateBulkSimulationDto) // Arrange DashboardBulkUpdate expectedBulkUpdate; - expectedBulkUpdate.stopped = std::make_unique(12345u); + expectedBulkUpdate.stopped = std::uint64_t{12345u}; const auto expectedSystemState0 = SilKit::Dashboard::SystemState::Shutdown; expectedBulkUpdate.systemStates.emplace_back(SilKit::Services::Orchestration::SystemState::Shutdown); diff --git a/SilKit/source/dashboard/service/Test_DashboardRestClient.cpp b/SilKit/source/dashboard/service/Test_DashboardRestClient.cpp index 31084f192..d33323899 100644 --- a/SilKit/source/dashboard/service/Test_DashboardRestClient.cpp +++ b/SilKit/source/dashboard/service/Test_DashboardRestClient.cpp @@ -127,15 +127,6 @@ TEST_F(Test_DashboardRestClient, OnMetricsUpdate_ForwardsTheMappedDtoWithTheSimu EXPECT_EQ(actualDtoJson, ToJson(expectedDto)); } -TEST_F(Test_DashboardRestClient, IsBulkUpdateSupported_DelegatesToTheServiceClient) -{ - EXPECT_CALL(*_mockServiceClient, CheckBulkUpdateSupported()).WillOnce(Return(true)); - - const auto service = CreateService(); - - EXPECT_TRUE(service->IsBulkUpdateSupported()); -} - // The test constructor has no transport, so Abort() must still be safe to call. TEST_F(Test_DashboardRestClient, Abort_WithoutATransport_IsANoOp) { diff --git a/SilKit/source/dashboard/service/Test_DashboardShutdown.cpp b/SilKit/source/dashboard/service/Test_DashboardShutdown.cpp index 2f0367f25..b7bde29f4 100644 --- a/SilKit/source/dashboard/service/Test_DashboardShutdown.cpp +++ b/SilKit/source/dashboard/service/Test_DashboardShutdown.cpp @@ -8,11 +8,10 @@ * over RetryingHttpClient over AsioHttpClient - against a loopback server, rather than mocks. * * The case that matters is a dashboard server that accepts the connection and then never answers. - * IsBulkUpdateSupported() is the very first thing the registry's dashboard worker thread does, so - * before this rework that request blocked forever: oatpp set no socket timeouts, and the abort hook - * the destructor called ran only after the worker thread had already been joined. The registry - * therefore hung on shutdown. Both halves of the fix are covered here: the request is now bounded - * by a read deadline, and Abort() can cut it short from another thread. + * Before this rework such a request blocked forever: oatpp set no socket timeouts, and the abort + * hook the destructor called ran only after the worker thread had already been joined, so the + * registry hung on shutdown. Both halves of the fix are covered here: the request is bounded by a + * read deadline, and Abort() can cut it short from another thread. */ #include @@ -55,7 +54,7 @@ class Test_DashboardShutdown : public Test NiceMock _dummyLogger; }; -TEST_F(Test_DashboardShutdown, IsBulkUpdateSupported_AgainstASilentServer_CanBeAborted) +TEST_F(Test_DashboardShutdown, OnSimulationStart_AgainstASilentServer_CanBeAborted) { FakeHttpServer server{FakeHttpServer::Always("")}; // accepts, never answers @@ -67,39 +66,39 @@ TEST_F(Test_DashboardShutdown, IsBulkUpdateSupported_AgainstASilentServer_CanBeA }}; const auto start = std::chrono::steady_clock::now(); - const auto supported = client->IsBulkUpdateSupported(); + const auto simulationId = client->OnSimulationStart("silkit://localhost:8500", 0); const auto elapsed = std::chrono::steady_clock::now() - start; aborter.join(); - EXPECT_FALSE(supported); - EXPECT_LT(elapsed, kGenerousBound) << "Abort() must unblock the in-flight probe"; + EXPECT_EQ(simulationId, 0u); + EXPECT_LT(elapsed, kGenerousBound) << "Abort() must unblock the in-flight request"; } -TEST_F(Test_DashboardShutdown, IsBulkUpdateSupported_WithNothingListening_FailsWithoutHanging) +TEST_F(Test_DashboardShutdown, OnSimulationStart_WithNothingListening_FailsWithoutHanging) { // Port 1 is reserved and never has a listener. const auto client = CreateClient(1); const auto start = std::chrono::steady_clock::now(); - const auto supported = client->IsBulkUpdateSupported(); + const auto simulationId = client->OnSimulationStart("silkit://localhost:8500", 0); const auto elapsed = std::chrono::steady_clock::now() - start; - EXPECT_FALSE(supported); + EXPECT_EQ(simulationId, 0u); EXPECT_LT(elapsed, kGenerousBound); } TEST_F(Test_DashboardShutdown, Abort_IsIdempotentAndSafeBeforeAnyRequest) { - FakeHttpServer server{FakeHttpServer::Always(FakeHttpServer::MakeReply(200, "{}"))}; + FakeHttpServer server{FakeHttpServer::Always(FakeHttpServer::MakeReply(201, R"({"id":1})"))}; const auto client = CreateClient(server.Port()); client->Abort(); client->Abort(); - // Once aborted the client stays aborted, so the probe fails fast instead of contacting a server + // Once aborted the client stays aborted, so the request fails fast instead of reaching a server // that would have answered. - EXPECT_FALSE(client->IsBulkUpdateSupported()); + EXPECT_EQ(client->OnSimulationStart("silkit://localhost:8500", 0), 0u); } TEST_F(Test_DashboardShutdown, Destruction_AfterAnAbortedRequest_DoesNotBlock) @@ -113,7 +112,7 @@ TEST_F(Test_DashboardShutdown, Destruction_AfterAnAbortedRequest_DoesNotBlock) std::this_thread::sleep_for(200ms); client->Abort(); }}; - client->IsBulkUpdateSupported(); + client->OnSimulationStart("silkit://localhost:8500", 0); aborter.join(); } const auto elapsed = std::chrono::steady_clock::now() - start; diff --git a/Utilities/SilKitRegistry/Registry.cpp b/Utilities/SilKitRegistry/Registry.cpp index 114ffed73..f9e0ec603 100644 --- a/Utilities/SilKitRegistry/Registry.cpp +++ b/Utilities/SilKitRegistry/Registry.cpp @@ -199,11 +199,18 @@ auto StartRegistry(std::shared_ptr co { std::unique_ptr dashboard; + if (enableDashboard && !VSilKit::IsDashboardAvailable()) + { + std::cerr << "SIL Kit Dashboard support is not compiled into this build, ignoring the requested dashboard URI " + << dashboardUri << std::endl; + enableDashboard = false; + } + if (enableDashboard) { try { - dashboard = VSilKit::CreateDashboardInstance(); + dashboard = VSilKit::CreateDashboardInstance(dashboardUri); } catch (const std::exception& exception) { @@ -241,24 +248,6 @@ auto StartRegistry(std::shared_ptr co throw; } - try - { - if (enableDashboard) - { - dashboard->SetupDashboardConnection(dashboardUri); - } - } - catch (const std::exception& exception) - { - std::cerr << "Error during connection to dashboard backend: " << exception.what() << std::endl; - throw; - } - catch (...) - { - std::cerr << "Unknown error during connection to dashboard backend" << std::endl; - throw; - } - const auto chosenListenUri = registry->StartListening(listenUri); std::cout << "SIL Kit Registry listening on " << chosenListenUri << std::endl; diff --git a/Utilities/SilKitRegistry/Registry.hpp b/Utilities/SilKitRegistry/Registry.hpp index 32fe075ce..e56f232d4 100644 --- a/Utilities/SilKitRegistry/Registry.hpp +++ b/Utilities/SilKitRegistry/Registry.hpp @@ -13,10 +13,17 @@ namespace SilKitRegistry { +/*! Owns a running registry and, optionally, the dashboard attached to it. + * + * Declaration order is load-bearing. VAsioRegistry holds the dashboard as a raw, never-reset + * IRegistryEventListener* and keeps delivering events to it from its I/O thread for as long as it + * lives, so the registry has to be torn down first. Members are destroyed in reverse declaration + * order, hence _dashboard first here and _registry second. + */ struct RegistryInstance { - std::unique_ptr _registry; std::unique_ptr _dashboard; + std::unique_ptr _registry; }; diff --git a/docs/changelog/versions/latest.md b/docs/changelog/versions/latest.md index a73073b19..1c3db35de 100644 --- a/docs/changelog/versions/latest.md +++ b/docs/changelog/versions/latest.md @@ -12,6 +12,16 @@ an in-flight request is aborted after a grace period so that shutdown is always bounded. - A malformed or unexpected response from the dashboard service no longer terminates the registry's dashboard worker thread; unknown fields in the response are now ignored. +- The SIL Kit Registry could call into a destroyed dashboard instance while still serving traffic, + because the dashboard was torn down before the registry that holds a pointer to it. +- If the dashboard service did not support the bulk-update endpoint, the registry's dashboard worker + thread stopped while events kept accumulating in an unbounded queue. The capability probe has been + removed; the worker now keeps draining the queue and reports the failing requests instead. This + also removes one request during registry startup. +- Dashboard events accumulated but not yet sent are now flushed when the registry shuts down, + instead of being discarded. +- Log messages from the dashboard instance and its worker thread carry the `Dashboard` log topic + again; they were previously emitted without a topic and so escaped topic filtering. - Fix ITest_AsyncSimTask (test failed when run repeatedly) - Fix the `TimeSyncService` warning about an exceeded soft time limit, which showed a literal `{}` instead of the measured timeout in milliseconds @@ -25,6 +35,9 @@ the JSON encoding: a space follows each `:` separator, forward slashes are no longer escaped as `\/`, and non-ASCII characters are sent as UTF-8 rather than `\uXXXX` escapes. Control characters that cannot be escaped are replaced with U+FFFD. +- Running a build without dashboard support (`SILKIT_BUILD_DASHBOARD=OFF`) and passing + `--dashboard-uri` now reports plainly that this build has no dashboard support, instead of printing + an error about a failed dashboard instance creation. - Changes to the SIL KIT MSI installer: - Default installation path changed from `\Vector SIL Kit ` to `\SIL Kit ` - Windows System Service Name changed from `VectorSilKitRegistry` to `SilKitRegistry` From db13626ad7981ba156a5dd88dc011ed4ec1df301 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marius=20B=C3=B6rschig?= Date: Wed, 2 Sep 2026 09:48:36 +0200 Subject: [PATCH 3/5] test fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Marius Börschig --- .../source/dashboard/http/FakeHttpServer.hpp | 61 +++++++++- .../dashboard/http/Test_AsioHttpClient.cpp | 63 ++++++----- .../http/Test_RetryingHttpClient.cpp | 32 +++--- .../dashboard/service/DashboardRestClient.cpp | 8 +- .../dashboard/service/DashboardRestClient.hpp | 10 +- .../service/Test_DashboardShutdown.cpp | 104 ++++++++++++------ 6 files changed, 189 insertions(+), 89 deletions(-) diff --git a/SilKit/source/dashboard/http/FakeHttpServer.hpp b/SilKit/source/dashboard/http/FakeHttpServer.hpp index aa6db1d67..8f06ad42d 100644 --- a/SilKit/source/dashboard/http/FakeHttpServer.hpp +++ b/SilKit/source/dashboard/http/FakeHttpServer.hpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -24,6 +25,32 @@ namespace VSilKit { namespace Tests { +/*! A one-shot signal for coordinating test threads without sleeping. + * + * Sleeping to "let the other thread get there" is a race that a loaded CI worker loses: the test + * still passes, but it exercises a different path than intended. Waiting on an actual event does + * not care how slow the machine is. + */ +class Latch +{ +public: + void Signal() + { + std::call_once(_once, [this] { _promise.set_value(); }); + } + + void Wait() const + { + _future.wait(); + } + +private: + std::promise _promise; + std::shared_future _future{_promise.get_future()}; + std::once_flag _once; +}; + + /*! A minimal scripted HTTP server for driving the dashboard's HTTP client. * * The handler receives the full request (head plus body) and returns the raw bytes to reply with. @@ -47,12 +74,20 @@ class FakeHttpServer ~FakeHttpServer() { _stop = true; - std::error_code ignored; - _acceptor.close(ignored); + + /* Closing the acceptor here would be wrong twice over: only Winsock wakes a thread that is + * blocked in accept(), POSIX close() leaves it parked forever, and touching the acceptor + * from this thread while the server thread is inside accept() is a data race on it. Nudge + * the acceptor with a throwaway connection instead, and close it once the thread is gone. */ + WakeAcceptor(); + if (_thread.joinable()) { _thread.join(); } + + std::error_code ignored; + _acceptor.close(ignored); } FakeHttpServer(const FakeHttpServer&) = delete; @@ -89,6 +124,22 @@ class FakeHttpServer } private: + //! Unblocks a server thread sitting in accept() by connecting once and hanging up. + void WakeAcceptor() + { + try + { + asio::io_context ioContext; + asio::ip::tcp::socket socket{ioContext}; + std::error_code ignored; + socket.connect(asio::ip::tcp::endpoint{asio::ip::make_address("127.0.0.1"), _port}, ignored); + } + catch (...) + { + // Best effort: if the connection fails the thread was not in accept() anyway. + } + } + void Serve() { while (!_stop) @@ -96,9 +147,11 @@ class FakeHttpServer asio::ip::tcp::socket socket{_ioContext}; std::error_code ec; _acceptor.accept(socket, ec); - if (ec) + if (ec || _stop) { - return; // the destructor closed the acceptor + // Either shutting down, or this is the destructor's wake-up connection, which must + // not be counted as a real accept. + return; } ++_acceptCount; ServeConnection(socket); diff --git a/SilKit/source/dashboard/http/Test_AsioHttpClient.cpp b/SilKit/source/dashboard/http/Test_AsioHttpClient.cpp index 754be0f10..9b28bf3ea 100644 --- a/SilKit/source/dashboard/http/Test_AsioHttpClient.cpp +++ b/SilKit/source/dashboard/http/Test_AsioHttpClient.cpp @@ -24,11 +24,13 @@ namespace { using namespace std::chrono_literals; -// Timing assertions are deliberately loose: CI machines are slow and oversubscribed. Each one only -// has to distinguish "bounded" from "hung", not measure latency. -constexpr auto kGenerousBound = 5s; +/* No test here measures wall-clock time. Where a deadline is the feature under test the deadline is + * set short and only its effect is asserted; where a deadline must NOT be what ends the request it + * is set out of reach, so a request that returns can only have been aborted. Threads hand over + * through a Latch, never a sleep. */ using VSilKit::Tests::FakeHttpServer; +using VSilKit::Tests::Latch; auto Reply(int statusCode, const std::string& body, const std::string& extraHeaders = {}) -> std::string { @@ -48,7 +50,7 @@ class Test_AsioHttpClient : public testing::Test AsioHttpClientTimeouts timeouts{}; timeouts.connect = 1s; timeouts.write = 1s; - timeouts.read = 500ms; + timeouts.read = 150ms; return timeouts; } }; @@ -154,51 +156,56 @@ TEST_F(Test_AsioHttpClient, Post_ReportsATransportErrorForAMalformedResponse) TEST_F(Test_AsioHttpClient, Post_ReportsATransportErrorWhenNothingIsListening) { - // Port 1 is reserved and never has a listener; the point is that we fail rather than hang. - AsioHttpClient client{nullptr, "127.0.0.1", 1, FastTimeouts()}; + // Port 1 is reserved and never has a listener. The connect deadline is out of reach, so a + // refused connection is the only thing that can end this call. + AsioHttpClientTimeouts unreachableDeadlines{}; + unreachableDeadlines.connect = 1h; + AsioHttpClient client{nullptr, "127.0.0.1", 1, unreachableDeadlines}; - const auto start = std::chrono::steady_clock::now(); - const auto result = client.Post("a", "{}"); - const auto elapsed = std::chrono::steady_clock::now() - start; - - EXPECT_TRUE(result.transportError); - EXPECT_LT(elapsed, kGenerousBound); + EXPECT_TRUE(client.Post("a", "{}").transportError); } -// oatpp set no socket timeouts at all, so this case used to hang forever. +/*! oatpp set no socket timeouts at all, so this case used to hang forever. + * + * Here the read deadline is the feature under test, so it is deliberately short. The assertion is + * on its effect - the request came back and reported failure - not on how long it took. + */ TEST_F(Test_AsioHttpClient, Post_TimesOutWhenTheServerNeverAnswers) { FakeHttpServer server{AlwaysReply("")}; AsioHttpClient client{nullptr, "127.0.0.1", server.Port(), FastTimeouts()}; - const auto start = std::chrono::steady_clock::now(); - const auto result = client.Post("a", "{}"); - const auto elapsed = std::chrono::steady_clock::now() - start; - - EXPECT_TRUE(result.transportError); - EXPECT_LT(elapsed, kGenerousBound) << "the read deadline should have fired"; + EXPECT_TRUE(client.Post("a", "{}").transportError) << "the read deadline should have fired"; } +/*! Abort() cancels a read that nothing else could end. + * + * The deadline is out of reach and the server never answers, so if Abort() fails to cancel the + * pending read this blocks and the harness timeout reports it - a verdict that does not depend on + * how fast the machine is. The handler signals once the request has arrived, which is the moment + * the client is committed to the read. + */ TEST_F(Test_AsioHttpClient, Abort_UnblocksAnInFlightRequest) { - FakeHttpServer server{AlwaysReply("")}; + Latch requestReceived; + FakeHttpServer server{[&requestReceived](const std::string&) { + requestReceived.Signal(); + return std::string{}; + }}; - AsioHttpClientTimeouts patient{}; - patient.read = 60s; // long enough that only Abort() can end the wait in time - AsioHttpClient client{nullptr, "127.0.0.1", server.Port(), patient}; + AsioHttpClientTimeouts unreachableDeadlines{}; + unreachableDeadlines.read = 1h; + AsioHttpClient client{nullptr, "127.0.0.1", server.Port(), unreachableDeadlines}; - std::thread aborter{[&client] { - std::this_thread::sleep_for(100ms); + std::thread aborter{[&] { + requestReceived.Wait(); client.Abort(); }}; - const auto start = std::chrono::steady_clock::now(); const auto result = client.Post("a", "{}"); - const auto elapsed = std::chrono::steady_clock::now() - start; aborter.join(); EXPECT_TRUE(result.transportError); - EXPECT_LT(elapsed, kGenerousBound) << "Abort() must cancel the pending read"; EXPECT_TRUE(client.Post("a", "{}").transportError) << "an aborted client stays aborted"; } diff --git a/SilKit/source/dashboard/http/Test_RetryingHttpClient.cpp b/SilKit/source/dashboard/http/Test_RetryingHttpClient.cpp index c2db46d27..b0ff48ccb 100644 --- a/SilKit/source/dashboard/http/Test_RetryingHttpClient.cpp +++ b/SilKit/source/dashboard/http/Test_RetryingHttpClient.cpp @@ -6,7 +6,6 @@ #include #include -#include #include "gmock/gmock.h" #include "gtest/gtest.h" @@ -39,7 +38,7 @@ class Test_RetryingHttpClient : public testing::Test return RetryingHttpClient{_inner, policy}; } - //! A policy with a negligible backoff, so timing does not dominate the test run. + //! A negligible backoff, so the retry paths do not spend real time. static auto FastPolicy() -> HttpRetryPolicy { HttpRetryPolicy policy{}; @@ -135,30 +134,25 @@ TEST_F(Test_RetryingHttpClient, Post_AfterAbort_DoesNotCallInner) EXPECT_TRUE(result.transportError); } +/*! Abort() cuts the retry backoff short. + * + * Reset() is called immediately before the client enters its backoff, so aborting from there is a + * precise handover with no sleeping: the abort lands exactly when the wait begins. The verdict is + * the inner call count, not the clock - if the backoff were not interruptible the client would wake + * after an hour and Post() a second time, which this StrictMock rejects. + */ TEST_F(Test_RetryingHttpClient, Abort_DuringBackoff_ReturnsWithoutWaitingOutTheBackoff) { - // A long backoff: if it were not interruptible, this test would take 30 s. HttpRetryPolicy policy{}; - policy.backoff = 30s; - - EXPECT_CALL(*_inner, Post("path", "{}")).WillOnce(Return(Ok(503))); - EXPECT_CALL(*_inner, Reset()).Times(1); - EXPECT_CALL(*_inner, Abort()).Times(1); + policy.backoff = 1h; auto client = CreateClient(policy); - std::thread aborter{[&client] { - std::this_thread::sleep_for(50ms); - client.Abort(); - }}; - - const auto start = std::chrono::steady_clock::now(); - const auto result = client.Post("path", "{}"); - const auto elapsed = std::chrono::steady_clock::now() - start; - aborter.join(); + EXPECT_CALL(*_inner, Post("path", "{}")).Times(1).WillOnce(Return(Ok(503))); + EXPECT_CALL(*_inner, Reset()).Times(1).WillOnce([&client] { client.Abort(); }); + EXPECT_CALL(*_inner, Abort()).Times(1); - EXPECT_TRUE(result.transportError); - EXPECT_LT(elapsed, 5s) << "Abort() must cut the backoff short"; + EXPECT_TRUE(client.Post("path", "{}").transportError); } TEST_F(Test_RetryingHttpClient, Post_RespectsAConfiguredAttemptLimit) diff --git a/SilKit/source/dashboard/service/DashboardRestClient.cpp b/SilKit/source/dashboard/service/DashboardRestClient.cpp index 43add4581..c77763267 100644 --- a/SilKit/source/dashboard/service/DashboardRestClient.cpp +++ b/SilKit/source/dashboard/service/DashboardRestClient.cpp @@ -24,14 +24,16 @@ namespace SilKit { namespace Dashboard { DashboardRestClient::DashboardRestClient(Services::Logging::ILoggerInternal* logger, - const std::string& dashboardServerUri) + const std::string& dashboardServerUri, + VSilKit::AsioHttpClientTimeouts timeouts, + VSilKit::HttpRetryPolicy retryPolicy) : _logger(logger) { _dtoMapper = std::make_shared(); const auto uri = SilKit::Core::Uri::Parse(dashboardServerUri); - auto transport = std::make_shared(logger, uri.Host(), uri.Port()); - _httpClient = std::make_shared(std::move(transport)); + auto transport = std::make_shared(logger, uri.Host(), uri.Port(), timeouts); + _httpClient = std::make_shared(std::move(transport), retryPolicy); _serviceClient = std::make_shared(_logger, _httpClient); } diff --git a/SilKit/source/dashboard/service/DashboardRestClient.hpp b/SilKit/source/dashboard/service/DashboardRestClient.hpp index 91d77b77b..f5777b58f 100644 --- a/SilKit/source/dashboard/service/DashboardRestClient.hpp +++ b/SilKit/source/dashboard/service/DashboardRestClient.hpp @@ -13,6 +13,8 @@ #include "dashboard/DashboardBulkUpdate.hpp" #include "dashboard/IRestClient.hpp" #include "dashboard/client/IDashboardSystemServiceClient.hpp" +#include "dashboard/http/AsioHttpClient.hpp" +#include "dashboard/http/HttpRetryPolicy.hpp" #include "dashboard/http/IHttpClient.hpp" #include "dashboard/service/IDashboardDtoMapper.hpp" #include "services/metrics/MetricsDatatypes.hpp" @@ -23,7 +25,13 @@ namespace Dashboard { class DashboardRestClient : public VSilKit::IRestClient { public: - DashboardRestClient(Services::Logging::ILoggerInternal* logger, const std::string& dashboardServerUri); + /*! Connect to the dashboard at dashboardServerUri. + * + * The timeouts and retry policy are parameters so that tests can drive the assembled stack + * without waiting out real deadlines; production uses the defaults. + */ + DashboardRestClient(Services::Logging::ILoggerInternal* logger, const std::string& dashboardServerUri, + VSilKit::AsioHttpClientTimeouts timeouts = {}, VSilKit::HttpRetryPolicy retryPolicy = {}); ~DashboardRestClient() override; public: // For testing diff --git a/SilKit/source/dashboard/service/Test_DashboardShutdown.cpp b/SilKit/source/dashboard/service/Test_DashboardShutdown.cpp index b7bde29f4..cff3636cb 100644 --- a/SilKit/source/dashboard/service/Test_DashboardShutdown.cpp +++ b/SilKit/source/dashboard/service/Test_DashboardShutdown.cpp @@ -10,8 +10,14 @@ * The case that matters is a dashboard server that accepts the connection and then never answers. * Before this rework such a request blocked forever: oatpp set no socket timeouts, and the abort * hook the destructor called ran only after the worker thread had already been joined, so the - * registry hung on shutdown. Both halves of the fix are covered here: the request is bounded by a - * read deadline, and Abort() can cut it short from another thread. + * registry hung on shutdown. + * + * Nothing here measures wall-clock time or sleeps to synchronise. The read deadline is set far + * higher than these tests could ever legitimately take, so a request that returns at all can only + * have been ended by Abort(); if Abort() stops working the test blocks and the harness timeout + * reports it, which is a verdict that does not change with how loaded the machine is. Threads hand + * over through a Latch tied to the server actually receiving the request, so the client is + * guaranteed to be blocked in the read before the abort fires. */ #include @@ -27,16 +33,13 @@ #include "dashboard/service/DashboardRestClient.hpp" using namespace testing; -using namespace std::chrono_literals; using VSilKit::Tests::FakeHttpServer; +using VSilKit::Tests::Latch; namespace SilKit { namespace Dashboard { namespace { -// Loose on purpose: these only need to separate "bounded" from "hung". -constexpr auto kGenerousBound = 60s; - class Test_DashboardShutdown : public Test { public: @@ -45,46 +48,73 @@ class Test_DashboardShutdown : public Test EXPECT_CALL(_dummyLogger, GetLogLevel).WillRepeatedly(Return(Services::Logging::Level::Off)); } - auto CreateClient(uint16_t port) -> std::shared_ptr + /*! A client whose deadlines cannot plausibly fire during a test. + * + * An hour-long read deadline means "the request came back" is only ever attributable to + * Abort(), never to a timeout that happened to elapse on a slow worker. The zero backoff keeps + * the retry path from spending real time. + */ + auto CreateClient(uint16_t port, std::size_t maxAttempts = 3) -> std::shared_ptr { - return std::make_shared(&_dummyLogger, - "http://127.0.0.1:" + std::to_string(port)); + VSilKit::AsioHttpClientTimeouts timeouts{}; + timeouts.connect = std::chrono::hours{1}; + timeouts.write = std::chrono::hours{1}; + timeouts.read = std::chrono::hours{1}; + + VSilKit::HttpRetryPolicy retryPolicy{}; + retryPolicy.maxAttempts = maxAttempts; + retryPolicy.backoff = std::chrono::milliseconds{0}; + + return std::make_shared(&_dummyLogger, "http://127.0.0.1:" + std::to_string(port), + timeouts, retryPolicy); + } + + /*! A client pointed at a port nothing listens on, with a connect deadline that will not fire. + * + * One attempt only: this is about a refused connection being reported, not about retrying, and + * some platforms take seconds to refuse a loopback connection. + */ + auto CreateClientWithNoServer() -> std::shared_ptr + { + return CreateClient(1, 1); // port 1 is reserved and never has a listener } NiceMock _dummyLogger; }; -TEST_F(Test_DashboardShutdown, OnSimulationStart_AgainstASilentServer_CanBeAborted) +/*! A silent server plus an abort is the shutdown hang, reproduced. + * + * The handler signals once the request has arrived, which is the moment the client is committed to + * reading a response that will never come. Only Abort() can end that read. + */ +TEST_F(Test_DashboardShutdown, OnSimulationStart_AgainstASilentServer_IsEndedByAbort) { - FakeHttpServer server{FakeHttpServer::Always("")}; // accepts, never answers + Latch requestReceived; + FakeHttpServer server{[&requestReceived](const std::string&) { + requestReceived.Signal(); + return std::string{}; // go silent + }}; const auto client = CreateClient(server.Port()); - std::thread aborter{[&client] { - std::this_thread::sleep_for(200ms); + std::thread aborter{[&] { + requestReceived.Wait(); client->Abort(); }}; - const auto start = std::chrono::steady_clock::now(); const auto simulationId = client->OnSimulationStart("silkit://localhost:8500", 0); - const auto elapsed = std::chrono::steady_clock::now() - start; aborter.join(); EXPECT_EQ(simulationId, 0u); - EXPECT_LT(elapsed, kGenerousBound) << "Abort() must unblock the in-flight request"; + EXPECT_EQ(server.Requests().size(), 1u) << "the abort must not have triggered a retry"; } -TEST_F(Test_DashboardShutdown, OnSimulationStart_WithNothingListening_FailsWithoutHanging) +//! A refused connection is reported, rather than retried until the connect deadline expires. +TEST_F(Test_DashboardShutdown, OnSimulationStart_WithNothingListening_ReportsFailure) { - // Port 1 is reserved and never has a listener. - const auto client = CreateClient(1); - - const auto start = std::chrono::steady_clock::now(); - const auto simulationId = client->OnSimulationStart("silkit://localhost:8500", 0); - const auto elapsed = std::chrono::steady_clock::now() - start; + const auto client = CreateClientWithNoServer(); - EXPECT_EQ(simulationId, 0u); - EXPECT_LT(elapsed, kGenerousBound); + EXPECT_EQ(client->OnSimulationStart("silkit://localhost:8500", 0), 0u); } TEST_F(Test_DashboardShutdown, Abort_IsIdempotentAndSafeBeforeAnyRequest) @@ -96,28 +126,34 @@ TEST_F(Test_DashboardShutdown, Abort_IsIdempotentAndSafeBeforeAnyRequest) client->Abort(); client->Abort(); - // Once aborted the client stays aborted, so the request fails fast instead of reaching a server - // that would have answered. + // An aborted client stays aborted, so this fails fast instead of reaching a server that would + // have answered - which is what makes the assertion on AcceptCount meaningful. EXPECT_EQ(client->OnSimulationStart("silkit://localhost:8500", 0), 0u); + EXPECT_EQ(server.AcceptCount(), 0); } -TEST_F(Test_DashboardShutdown, Destruction_AfterAnAbortedRequest_DoesNotBlock) +//! Destroying a client whose request was aborted must not block on the dead request. +TEST_F(Test_DashboardShutdown, Destruction_AfterAnAbortedRequest_Completes) { - FakeHttpServer server{FakeHttpServer::Always("")}; + Latch requestReceived; + FakeHttpServer server{[&requestReceived](const std::string&) { + requestReceived.Signal(); + return std::string{}; + }}; - const auto start = std::chrono::steady_clock::now(); { const auto client = CreateClient(server.Port()); - std::thread aborter{[&client] { - std::this_thread::sleep_for(200ms); + + std::thread aborter{[&] { + requestReceived.Wait(); client->Abort(); }}; + client->OnSimulationStart("silkit://localhost:8500", 0); aborter.join(); } - const auto elapsed = std::chrono::steady_clock::now() - start; - EXPECT_LT(elapsed, kGenerousBound); + SUCCEED() << "the client was destroyed without blocking"; } // The happy path over the real stack, so the shutdown cases above are not the only coverage of the From 6ee96cfe5f345eb9d5617e99cc1535bfa39bb0cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marius=20B=C3=B6rschig?= Date: Wed, 2 Sep 2026 13:44:15 +0200 Subject: [PATCH 4/5] tested against dashboard v1.2.2. fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Marius Börschig --- SilKit/source/core/internal/internal_fwd.hpp | 1 + .../internal/traits/SilKitLoggingTraits.hpp | 1 + SilKit/source/core/vasio/VAsioRegistry.cpp | 13 ++ .../dashboard/EventQueueWorkerThread.cpp | 200 ++++++++++-------- .../dashboard/EventQueueWorkerThread.hpp | 19 +- .../Test_DashboardEventQueueWorker.cpp | 71 +++++++ .../dashboard/service/DashboardDtoMapper.cpp | 19 +- .../dashboard/service/DashboardDtoMapper.hpp | 9 + .../dashboard/service/DashboardRestClient.cpp | 2 +- .../service/Test_DashboardDtoMapper.cpp | 54 +++++ 10 files changed, 301 insertions(+), 88 deletions(-) diff --git a/SilKit/source/core/internal/internal_fwd.hpp b/SilKit/source/core/internal/internal_fwd.hpp index c90f28e23..479bbce7c 100644 --- a/SilKit/source/core/internal/internal_fwd.hpp +++ b/SilKit/source/core/internal/internal_fwd.hpp @@ -23,6 +23,7 @@ class ReplayScheduler; namespace Dashboard { class DashboardRestClient; class DashboardSystemServiceClient; +class DashboardDtoMapper; } // namespace Dashboard namespace Experimental { namespace NetworkSimulation { diff --git a/SilKit/source/core/internal/traits/SilKitLoggingTraits.hpp b/SilKit/source/core/internal/traits/SilKitLoggingTraits.hpp index 32ea94854..5b0f1a37b 100644 --- a/SilKit/source/core/internal/traits/SilKitLoggingTraits.hpp +++ b/SilKit/source/core/internal/traits/SilKitLoggingTraits.hpp @@ -80,6 +80,7 @@ DefineSilKitLoggingTrait_Topic(VSilKit::AsioGenericRawByteStream, SilKit::Servic DefineSilKitLoggingTrait_Topic(SilKit::Dashboard::DashboardRestClient, SilKit::Services::Logging::Topic::Dashboard); DefineSilKitLoggingTrait_Topic(SilKit::Dashboard::DashboardSystemServiceClient, SilKit::Services::Logging::Topic::Dashboard); +DefineSilKitLoggingTrait_Topic(SilKit::Dashboard::DashboardDtoMapper, SilKit::Services::Logging::Topic::Dashboard); DefineSilKitLoggingTrait_Topic(VSilKit::DashboardInstance, SilKit::Services::Logging::Topic::Dashboard); DefineSilKitLoggingTrait_Topic(VSilKit::EventQueueWorkerThread, SilKit::Services::Logging::Topic::Dashboard); DefineSilKitLoggingTrait_Topic(VSilKit::AsioHttpClient, SilKit::Services::Logging::Topic::Dashboard); diff --git a/SilKit/source/core/vasio/VAsioRegistry.cpp b/SilKit/source/core/vasio/VAsioRegistry.cpp index 6f8826ad3..07f6374bc 100644 --- a/SilKit/source/core/vasio/VAsioRegistry.cpp +++ b/SilKit/source/core/vasio/VAsioRegistry.cpp @@ -159,6 +159,19 @@ auto VAsioRegistry::StartListening(const std::string& listenUri) -> std::string if (_registryEventListener != nullptr) { + if (!_vasioConfig->experimental.metrics.collectFromRemote.value_or(false)) + { + /* Without this the registry never receives a MetricsUpdate, so the dashboard shows no + * metrics and - because attributes travel as metrics - no participant attributes + * either. Nothing else reports the combination, and the result is indistinguishable + * from a broken dashboard connection. */ + GetLoggerInternal() + ->MakeMessage(Log::Level::Warn, Log::Topic::Dashboard) + .SetMessage("SIL Kit Registry: the dashboard is enabled but Experimental.Metrics.CollectFromRemote " + "is disabled, so no metrics and no participant attributes will reach the dashboard") + .Dispatch(); + } + _registryEventListener->OnRegistryUri(uri.EncodedString()); } diff --git a/SilKit/source/dashboard/EventQueueWorkerThread.cpp b/SilKit/source/dashboard/EventQueueWorkerThread.cpp index b1f007fb9..ea5cec094 100644 --- a/SilKit/source/dashboard/EventQueueWorkerThread.cpp +++ b/SilKit/source/dashboard/EventQueueWorkerThread.cpp @@ -11,7 +11,6 @@ namespace VSilKit { -using SilKit::Dashboard::DashboardBulkUpdate; using SilKit::Services::Logging::ILoggerInternal; using SilKit::Services::Logging::Level; @@ -48,7 +47,29 @@ auto EventQueueWorkerThread::IsAborted() const -> bool return _abort != nullptr && _abort->load(std::memory_order_acquire); } -void EventQueueWorkerThread::FlushAccumulated(std::unordered_map& bulkUpdates) const +template +auto EventQueueWorkerThread::WithoutPropagating(const char* what, Action&& action) const -> bool +try +{ + action(); + return true; +} +catch (const std::exception& exception) +{ + _logger->MakeMessage(Level::Error, TopicOf(*this)) + .SetMessage("Dashboard: {} failed and was skipped: {}", what, exception.what()) + .Dispatch(); + return false; +} +catch (...) +{ + _logger->MakeMessage(Level::Error, TopicOf(*this)) + .SetMessage("Dashboard: {} failed and was skipped: unknown exception", what) + .Dispatch(); + return false; +} + +void EventQueueWorkerThread::FlushAccumulated(BulkUpdates& bulkUpdates) const { for (auto it = bulkUpdates.begin(); it != bulkUpdates.end();) { @@ -61,7 +82,10 @@ void EventQueueWorkerThread::FlushAccumulated(std::unordered_mapOnBulkUpdate(it->first, bulkUpdate); + + // An update that cannot be sent is still cleared below: keeping it would mean retrying the + // same unusable content on every following batch, and losing everything queued behind it. + WithoutPropagating("sending a bulk update", [&] { _dashboardRestClient->OnBulkUpdate(it->first, bulkUpdate); }); if (simulationEnded) { @@ -75,104 +99,110 @@ void EventQueueWorkerThread::FlushAccumulated(std::unordered_map simulationNameToId; - std::unordered_map simulationBulkUpdates; - - std::vector events; - while (_eventQueue->DequeueAllInto(events)) + // OnSimulationStart is handled separately: it establishes the simulation id that every + // other event for that simulation needs. + if (event.Type() == SilKitEventType::OnSimulationStart) { - for (const auto& event : events) + FlushAccumulated(bulkUpdates); + + const auto known{simulationIds.find(event.GetSimulationName())}; + if (known != simulationIds.end()) { - if (IsAborted()) - { - // Send what has already been accumulated; the shutdown grace period exists precisely - // so this last update still reaches the dashboard. - FlushAccumulated(simulationBulkUpdates); - return; - } + // Queuing means a simulation can be announced more than once. + _logger->MakeMessage(Level::Debug, TopicOf(*this)) + .SetMessage("Dashboard: Simulation {} already has id {}", event.GetSimulationName(), known->second) + .Dispatch(); + return; + } - // OnSimulationStart is handled separately: it establishes the simulation id that every - // other event for that simulation needs. - if (event.Type() == SilKitEventType::OnSimulationStart) - { - FlushAccumulated(simulationBulkUpdates); + const auto& simulationStart = event.GetSimulationStart(); + const auto simulationId = + _dashboardRestClient->OnSimulationStart(simulationStart.connectUri, simulationStart.time); - const auto known{simulationNameToId.find(event.GetSimulationName())}; - if (known != simulationNameToId.end()) - { - // Queuing means a simulation can be announced more than once. - _logger->MakeMessage(Level::Debug, TopicOf(*this)) - .SetMessage("Dashboard: Simulation {} already has id {}", event.GetSimulationName(), - known->second) - .Dispatch(); - continue; - } - - const auto& simulationStart = event.GetSimulationStart(); - const auto simulationId = - _dashboardRestClient->OnSimulationStart(simulationStart.connectUri, simulationStart.time); - - if (simulationId == 0) - { - _logger->MakeMessage(Level::Warn, TopicOf(*this)) - .SetMessage("Dashboard: Simulation {} could not be created", event.GetSimulationName()) - .Dispatch(); - continue; - } - - simulationNameToId.emplace(event.GetSimulationName(), simulationId); - continue; - } + if (simulationId == 0) + { + _logger->MakeMessage(Level::Warn, TopicOf(*this)) + .SetMessage("Dashboard: Simulation {} could not be created", event.GetSimulationName()) + .Dispatch(); + return; + } - const auto it{simulationNameToId.find(event.GetSimulationName())}; - if (it == simulationNameToId.end()) - { - _logger->MakeMessage(Level::Warn, TopicOf(*this)) - .SetMessage("Dashboard: Simulation {} is unknown", event.GetSimulationName()) - .Dispatch(); - continue; - } + simulationIds.emplace(event.GetSimulationName(), simulationId); + return; + } + + const auto it{simulationIds.find(event.GetSimulationName())}; + if (it == simulationIds.end()) + { + _logger->MakeMessage(Level::Warn, TopicOf(*this)) + .SetMessage("Dashboard: Simulation {} is unknown", event.GetSimulationName()) + .Dispatch(); + return; + } - const auto simulationId{it->second}; - auto& bulkUpdate{simulationBulkUpdates[simulationId]}; + const auto simulationId{it->second}; + auto& bulkUpdate{bulkUpdates[simulationId]}; - switch (event.Type()) - { - case SilKitEventType::OnSimulationStart: - break; // handled above + switch (event.Type()) + { + case SilKitEventType::OnSimulationStart: + break; // handled above + + case SilKitEventType::OnParticipantConnected: + bulkUpdate.participantConnectionInformations.emplace_back(event.GetParticipantConnectionInformation()); + break; - case SilKitEventType::OnParticipantConnected: - bulkUpdate.participantConnectionInformations.emplace_back( - event.GetParticipantConnectionInformation()); - break; + case SilKitEventType::OnSystemStateChanged: + bulkUpdate.systemStates.emplace_back(event.GetSystemState()); + break; - case SilKitEventType::OnSystemStateChanged: - bulkUpdate.systemStates.emplace_back(event.GetSystemState()); - break; + case SilKitEventType::OnParticipantStatusChanged: + bulkUpdate.participantStatuses.emplace_back(event.GetParticipantStatus()); + break; - case SilKitEventType::OnParticipantStatusChanged: - bulkUpdate.participantStatuses.emplace_back(event.GetParticipantStatus()); - break; + case SilKitEventType::OnServiceDiscoveryEvent: + bulkUpdate.serviceDatas.emplace_back(event.GetServiceData()); + break; - case SilKitEventType::OnServiceDiscoveryEvent: - bulkUpdate.serviceDatas.emplace_back(event.GetServiceData()); - break; + case SilKitEventType::OnSimulationEnd: + bulkUpdate.stopped = event.GetSimulationEnd().time; + simulationIds.erase(it); + break; - case SilKitEventType::OnSimulationEnd: - bulkUpdate.stopped = event.GetSimulationEnd().time; - simulationNameToId.erase(it); - break; + case SilKitEventType::OnMetricUpdate: + { + // Metrics are not batched; they go out on their own endpoint immediately. + const auto& data = event.GetMetricsUpdate(); + _dashboardRestClient->OnMetricsUpdate(simulationId, data.first, data.second); + break; + } + } +} - case SilKitEventType::OnMetricUpdate: +void EventQueueWorkerThread::ProcessEvents() const +{ + SimulationIds simulationIds; + BulkUpdates simulationBulkUpdates; + + std::vector events; + while (_eventQueue->DequeueAllInto(events)) + { + for (const auto& event : events) + { + if (IsAborted()) { - // Metrics are not batched; they go out on their own endpoint immediately. - const auto& data = event.GetMetricsUpdate(); - _dashboardRestClient->OnMetricsUpdate(simulationId, data.first, data.second); - break; - } + // Send what has already been accumulated; the shutdown grace period exists precisely + // so this last update still reaches the dashboard. + FlushAccumulated(simulationBulkUpdates); + return; } + + WithoutPropagating("processing an event", [&] { + ProcessEvent(event, simulationIds, simulationBulkUpdates); + }); } events.clear(); diff --git a/SilKit/source/dashboard/EventQueueWorkerThread.hpp b/SilKit/source/dashboard/EventQueueWorkerThread.hpp index d899370d5..56559a908 100644 --- a/SilKit/source/dashboard/EventQueueWorkerThread.hpp +++ b/SilKit/source/dashboard/EventQueueWorkerThread.hpp @@ -42,6 +42,9 @@ class EventQueueWorkerThread void operator()() const; private: + using SimulationIds = std::unordered_map; + using BulkUpdates = std::unordered_map; + auto IsAborted() const -> bool; /*! Sends every non-empty accumulated update. @@ -49,10 +52,24 @@ class EventQueueWorkerThread * An update carrying `stopped` is the last one for that simulation, so its entry is dropped * afterwards instead of being kept around empty for the rest of the process's life. */ - void FlushAccumulated(std::unordered_map& bulkUpdates) const; + void FlushAccumulated(BulkUpdates& bulkUpdates) const; + + //! Folds one event into the accumulated state, sending immediately where the event needs it. + void ProcessEvent(const SilKitEvent& event, SimulationIds& simulationIds, BulkUpdates& bulkUpdates) const; void ProcessEvents() const; + /*! Runs one step, turning a failure into a log line rather than the end of all reporting. + * + * Mapping an event or sending an update can throw on data the dashboard has no representation + * for. Letting that escape would end the worker: the queue would then grow without a consumer + * and the dashboard would silently freeze at whatever it had received - which is exactly what + * the symptom looks like from the outside. A single unusable event is not worth that, so it is + * reported and skipped. Returns true when the step completed. + */ + template + auto WithoutPropagating(const char* what, Action&& action) const -> bool; + SilKit::Services::Logging::ILoggerInternal* _logger{nullptr}; IRestClient* _dashboardRestClient{nullptr}; LockedQueue* _eventQueue{nullptr}; diff --git a/SilKit/source/dashboard/Test_DashboardEventQueueWorker.cpp b/SilKit/source/dashboard/Test_DashboardEventQueueWorker.cpp index 52acd4e26..a50937e97 100644 --- a/SilKit/source/dashboard/Test_DashboardEventQueueWorker.cpp +++ b/SilKit/source/dashboard/Test_DashboardEventQueueWorker.cpp @@ -338,5 +338,76 @@ TEST_F(Test_DashboardEventQueueWorker, AThrowingRestClient_DoesNotEscapeTheWorke EXPECT_NO_THROW(RunWorkerToCompletion()); } +/*! One unmappable event must not end all reporting. + * + * This is the regression that made the dashboard look frozen: the mapper throws on data it has no + * representation for, that escaped ProcessEvents(), and the worker thread ended. Nothing restarts + * it, so every later event was queued to a consumer that no longer existed and the dashboard kept + * showing whatever had arrived before - no metrics, no attributes, no status changes. + */ +TEST_F(Test_DashboardEventQueueWorker, AFailedMetricsUpdate_DoesNotStopLaterEvents) +{ + InSequence sequence; + EXPECT_CALL(_restClient, OnSimulationStart(_, _)).WillOnce(Return(kSimulationId)); + EXPECT_CALL(_restClient, OnMetricsUpdate(kSimulationId, "P1", _)) + .WillOnce(Throw(SilKit::SilKitError{"Unexpected controller type Something"})); + EXPECT_CALL(_restClient, OnMetricsUpdate(kSimulationId, "P2", _)).Times(1); + EXPECT_CALL(_restClient, OnBulkUpdate(kSimulationId, _)).Times(1); + + EnqueueSimulationStart("sim"); + Enqueue("sim", MetricsUpdatePair{"P1", MetricsUpdate{}}); + Enqueue("sim", MetricsUpdatePair{"P2", MetricsUpdate{}}); + Enqueue("sim", orchestration::ParticipantConnectionInformation{"P1"}); + + RunWorkerToCompletion(); +} + +//! A bulk update that cannot be sent must not be retried forever, blocking everything behind it. +TEST_F(Test_DashboardEventQueueWorker, AFailedBulkUpdate_IsDroppedRatherThanRetried) +{ + EXPECT_CALL(_restClient, OnSimulationStart(_, _)).WillOnce(Return(kSimulationId)); + + std::vector secondFlush; + EXPECT_CALL(_restClient, OnBulkUpdate(kSimulationId, _)) + .Times(2) + // The first flush queues the next batch, ends the queue and then fails. + .WillOnce([&](uint64_t, const DashboardBulkUpdate&) { + Enqueue("sim", orchestration::ParticipantConnectionInformation{"P2"}); + _queue.Stop(); + throw SilKit::SilKitError{"cannot map this"}; + }) + .WillOnce(WithArg<1>([&](const DashboardBulkUpdate& update) { + for (const auto& connection : update.participantConnectionInformations) + { + secondFlush.push_back(connection.participantName); + } + })); + + EnqueueSimulationStart("sim"); + Enqueue("sim", orchestration::ParticipantConnectionInformation{"P1"}); + + EventQueueWorkerThread worker{&_dummyLogger, &_restClient, &_queue, &_abort}; + worker(); + + // P1 is gone with the update that could not be sent, rather than being retried on every + // following flush - which would have stalled everything queued behind it. + EXPECT_THAT(secondFlush, ElementsAre("P2")); +} + +//! Every event after a failure is still processed, so the queue cannot grow without a consumer. +TEST_F(Test_DashboardEventQueueWorker, AFailedSimulationStart_DoesNotStopLaterSimulations) +{ + EXPECT_CALL(_restClient, OnSimulationStart("silkit://localhost:8500/bad", _)) + .WillOnce(Throw(std::runtime_error{"boom"})); + EXPECT_CALL(_restClient, OnSimulationStart("silkit://localhost:8500/good", _)).WillOnce(Return(kSimulationId)); + EXPECT_CALL(_restClient, OnBulkUpdate(kSimulationId, _)).Times(1); + + EnqueueSimulationStart("bad"); + EnqueueSimulationStart("good"); + Enqueue("good", orchestration::ParticipantConnectionInformation{"P1"}); + + RunWorkerToCompletion(); +} + } // namespace } // namespace VSilKit diff --git a/SilKit/source/dashboard/service/DashboardDtoMapper.cpp b/SilKit/source/dashboard/service/DashboardDtoMapper.cpp index a10f0bc9b..e9978141e 100644 --- a/SilKit/source/dashboard/service/DashboardDtoMapper.cpp +++ b/SilKit/source/dashboard/service/DashboardDtoMapper.cpp @@ -9,6 +9,8 @@ #include #include "config/YamlParser.hpp" +#include "core/internal/traits/SilKitLoggingTraits.hpp" +#include "services/logging/LoggerMessage.hpp" #include "util/StringHelpers.hpp" namespace SilKit { @@ -189,6 +191,11 @@ auto CreateRpcSpecDto(const Core::ServiceDescriptor& serviceDescriptor, const st } // namespace +DashboardDtoMapper::DashboardDtoMapper(Services::Logging::ILoggerInternal* logger) + : _logger{logger} +{ +} + auto DashboardDtoMapper::CreateSimulationCreationRequestDto(const std::string& connectUri, uint64_t start) -> SimulationCreationRequestDto { @@ -495,7 +502,17 @@ void DashboardDtoMapper::ProcessControllerDiscovery(BulkParticipantDto& dto, // Everything Else else { - throw SilKitError{"Unexpected controller type " + controllerType}; + /* Not a bus or pub/sub service, so there is nothing to show for it. This must not throw: + * every controller type SIL Kit ever adds would otherwise take down all dashboard + * reporting for the rest of the process, since the worker cannot resume a batch it failed + * to map. Report it and move on. */ + if (_logger != nullptr) + { + _logger->MakeMessage(Services::Logging::Level::Debug, TopicOf(*this)) + .SetMessage("Dashboard: ignoring service {} of unhandled controller type {}", + serviceDescriptor.GetServiceName(), controllerType) + .Dispatch(); + } } } diff --git a/SilKit/source/dashboard/service/DashboardDtoMapper.hpp b/SilKit/source/dashboard/service/DashboardDtoMapper.hpp index 37e2c6356..31c954734 100644 --- a/SilKit/source/dashboard/service/DashboardDtoMapper.hpp +++ b/SilKit/source/dashboard/service/DashboardDtoMapper.hpp @@ -5,6 +5,7 @@ #pragma once #include "core/internal/ServiceDescriptor.hpp" +#include "services/logging/ILoggerInternal.hpp" #include "dashboard/service/IDashboardDtoMapper.hpp" @@ -16,6 +17,12 @@ class DashboardDtoMapper : public IDashboardDtoMapper using ServiceDescriptor = SilKit::Core::ServiceDescriptor; public: + /*! \param logger used to report services that carry nothing for the dashboard; may be null. + * + * The tests construct the mapper without one. + */ + explicit DashboardDtoMapper(Services::Logging::ILoggerInternal* logger = nullptr); + auto CreateSimulationCreationRequestDto(const std::string& connectUri, uint64_t start) -> SimulationCreationRequestDto override; auto CreateBulkSimulationDto(const DashboardBulkUpdate& bulkUpdate) -> BulkSimulationDto override; @@ -35,6 +42,8 @@ class DashboardDtoMapper : public IDashboardDtoMapper void ProcessServiceDiscovery(BulkParticipantDto& dto, const ServiceDescriptor& serviceDescriptor); void ProcessControllerDiscovery(BulkParticipantDto& dto, const ServiceDescriptor& serviceDescriptor); void ProcessLinkDiscovery(BulkParticipantDto& dto, const ServiceDescriptor& serviceDescriptor); + + Services::Logging::ILoggerInternal* _logger{nullptr}; }; } // namespace Dashboard diff --git a/SilKit/source/dashboard/service/DashboardRestClient.cpp b/SilKit/source/dashboard/service/DashboardRestClient.cpp index c77763267..3805330dd 100644 --- a/SilKit/source/dashboard/service/DashboardRestClient.cpp +++ b/SilKit/source/dashboard/service/DashboardRestClient.cpp @@ -29,7 +29,7 @@ DashboardRestClient::DashboardRestClient(Services::Logging::ILoggerInternal* log VSilKit::HttpRetryPolicy retryPolicy) : _logger(logger) { - _dtoMapper = std::make_shared(); + _dtoMapper = std::make_shared(logger); const auto uri = SilKit::Core::Uri::Parse(dashboardServerUri); auto transport = std::make_shared(logger, uri.Host(), uri.Port(), timeouts); diff --git a/SilKit/source/dashboard/service/Test_DashboardDtoMapper.cpp b/SilKit/source/dashboard/service/Test_DashboardDtoMapper.cpp index a0f8865ac..bf18cdb95 100644 --- a/SilKit/source/dashboard/service/Test_DashboardDtoMapper.cpp +++ b/SilKit/source/dashboard/service/Test_DashboardDtoMapper.cpp @@ -406,5 +406,59 @@ TEST_F(Test_DashboardDtoMapper, CreateBulkSimulationDto) ASSERT_NE(cParticipantDto, nullptr); } +/*! A controller type the dashboard has no view for is skipped, not treated as fatal. + * + * Throwing here used to end the dashboard's worker thread, which silently stopped all further + * reporting for the life of the process. Every controller type added to SIL Kit that nobody + * remembers to list in ProcessControllerDiscovery would have that effect, so an unhandled type has + * to cost only the service it describes. + */ +TEST_F(Test_DashboardDtoMapper, CreateBulkSimulationDto_SkipsUnhandledControllerTypes) +{ + ServiceData serviceData; + serviceData.discoveryType = Core::Discovery::ServiceDiscoveryEvent::Type::ServiceCreated; + serviceData.serviceDescriptor.SetParticipantNameAndComputeId("A"); + serviceData.serviceDescriptor.SetServiceType(Core::ServiceType::Controller); + serviceData.serviceDescriptor.SetServiceName("SomethingNew"); + serviceData.serviceDescriptor.SetSupplementalDataItem(SilKit::Core::Discovery::controllerType, + "SomeTypeAddedLater"); + + DashboardBulkUpdate bulkUpdate; + bulkUpdate.serviceDatas.emplace_back(serviceData); + + const auto dataMapper = CreateService(); + + BulkSimulationDto dto; + ASSERT_NO_THROW(dto = dataMapper->CreateBulkSimulationDto(bulkUpdate)); + + // The participant is still reported; only the unknown service is left out. + ASSERT_EQ(dto.participants.size(), 1u); + EXPECT_EQ(dto.participants[0].name, "A"); + EXPECT_TRUE(dto.participants[0].canControllers.empty()); + EXPECT_TRUE(dto.participants[0].dataPublishers.empty()); + EXPECT_TRUE(dto.participants[0].rpcClients.empty()); +} + +//! A descriptor missing the supplemental data the dashboard needs must not be silently accepted. +TEST_F(Test_DashboardDtoMapper, CreateBulkSimulationDto_ThrowsOnAMalformedDescriptor) +{ + ServiceData serviceData; + serviceData.discoveryType = Core::Discovery::ServiceDiscoveryEvent::Type::ServiceCreated; + serviceData.serviceDescriptor.SetParticipantNameAndComputeId("A"); + serviceData.serviceDescriptor.SetServiceType(Core::ServiceType::Controller); + serviceData.serviceDescriptor.SetServiceName("aPublisher"); + serviceData.serviceDescriptor.SetSupplementalDataItem(SilKit::Core::Discovery::controllerType, + SilKit::Core::Discovery::controllerTypeDataPublisher); + // no supplKeyDataPublisherTopic + + DashboardBulkUpdate bulkUpdate; + bulkUpdate.serviceDatas.emplace_back(serviceData); + + const auto dataMapper = CreateService(); + + // The worker contains this; see Test_DashboardEventQueueWorker. + ASSERT_THROW(dataMapper->CreateBulkSimulationDto(bulkUpdate), SilKitError); +} + } // namespace Dashboard } // namespace SilKit From 41885167612f5f640492d0aa3957309ae4158c5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marius=20B=C3=B6rschig?= Date: Wed, 2 Sep 2026 14:01:54 +0200 Subject: [PATCH 5/5] scrub hungarian notation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Marius Börschig --- SilKit/source/dashboard/DashboardInstance.cpp | 12 +- .../dashboard/EventQueueWorkerThread.cpp | 8 +- SilKit/source/dashboard/SilKitEvent.hpp | 28 ++-- .../Test_DashboardEventQueueWorker.cpp | 84 +++++----- .../Test_DashboardSystemServiceClient.cpp | 3 +- .../source/dashboard/http/AsioHttpClient.cpp | 117 ++++++-------- .../source/dashboard/http/FakeHttpServer.hpp | 7 +- .../dashboard/http/HttpResponseParser.cpp | 2 +- .../dashboard/http/HttpResponseParser.hpp | 2 +- .../dashboard/http/RetryingHttpClient.cpp | 3 +- .../dashboard/http/Test_AsioHttpClient.cpp | 9 +- .../http/Test_HttpResponseParser.cpp | 148 +++++++++--------- .../http/Test_RetryingHttpClient.cpp | 5 +- .../dashboard/json/DashboardJsonWriter.cpp | 4 +- .../json/Test_DashboardJsonWriter.cpp | 19 +-- .../dashboard/service/DashboardDtoMapper.cpp | 24 ++- .../dashboard/service/DashboardDtoMapper.hpp | 8 +- .../dashboard/service/DashboardRestClient.cpp | 7 +- .../dashboard/service/IDashboardDtoMapper.hpp | 8 +- .../service/Test_DashboardDtoMapper.cpp | 4 +- .../service/Test_DashboardRestClient.cpp | 6 +- 21 files changed, 234 insertions(+), 274 deletions(-) diff --git a/SilKit/source/dashboard/DashboardInstance.cpp b/SilKit/source/dashboard/DashboardInstance.cpp index ccae6cc27..c893db94c 100644 --- a/SilKit/source/dashboard/DashboardInstance.cpp +++ b/SilKit/source/dashboard/DashboardInstance.cpp @@ -20,7 +20,7 @@ namespace { /// How long the destructor lets an in-flight dashboard request finish before aborting it. -constexpr auto kShutdownGracePeriod = std::chrono::seconds{5}; +constexpr auto shutdownGracePeriod = std::chrono::seconds{5}; uint64_t GetCurrentSystemTime() @@ -77,7 +77,7 @@ DashboardInstance::~DashboardInstance() std::promise workerFinished; auto workerFinishedFuture = workerFinished.get_future(); auto watchdog = std::async(std::launch::async, [this, &workerFinishedFuture] { - if (workerFinishedFuture.wait_for(kShutdownGracePeriod) == std::future_status::timeout + if (workerFinishedFuture.wait_for(shutdownGracePeriod) == std::future_status::timeout && _dashboardRestClient != nullptr) { _dashboardRestClient->Abort(); @@ -129,8 +129,8 @@ void DashboardInstance::OnRegistryUri(const std::string& registryUri) void DashboardInstance::OnParticipantConnected(const std::string& simulationName, const std::string& participantName) { _logger->MakeMessage(Level::Trace, TopicOf(*this)) - .SetMessage("DashboardInstance::OnParticipantConnected: simulationName={} participantName={}", - simulationName, participantName) + .SetMessage("DashboardInstance::OnParticipantConnected: simulationName={} participantName={}", simulationName, + participantName) .Dispatch(); auto& systemStateTracker{_systemStateTrackers[simulationName]}; @@ -240,8 +240,8 @@ void DashboardInstance::OnMetricsUpdate(const std::string& simulationName, const const VSilKit::MetricsUpdate& metricsUpdate) { _logger->MakeMessage(Level::Trace, TopicOf(*this)) - .SetMessage("DashboardInstance::OnMetricsUpdate: simulationName={} origin={} metricsUpdate={}", - simulationName, origin, metricsUpdate) + .SetMessage("DashboardInstance::OnMetricsUpdate: simulationName={} origin={} metricsUpdate={}", simulationName, + origin, metricsUpdate) .Dispatch(); _silKitEventQueue.Enqueue(SilKitEvent{simulationName, MetricsUpdatePair{origin, metricsUpdate}}); diff --git a/SilKit/source/dashboard/EventQueueWorkerThread.cpp b/SilKit/source/dashboard/EventQueueWorkerThread.cpp index ea5cec094..61ff049af 100644 --- a/SilKit/source/dashboard/EventQueueWorkerThread.cpp +++ b/SilKit/source/dashboard/EventQueueWorkerThread.cpp @@ -15,8 +15,7 @@ using SilKit::Services::Logging::ILoggerInternal; using SilKit::Services::Logging::Level; EventQueueWorkerThread::EventQueueWorkerThread(ILoggerInternal* logger, IRestClient* dashboardRestClient, - LockedQueue* eventQueue, - const std::atomic* abort) + LockedQueue* eventQueue, const std::atomic* abort) : _logger{logger} , _dashboardRestClient{dashboardRestClient} , _eventQueue{eventQueue} @@ -200,9 +199,8 @@ void EventQueueWorkerThread::ProcessEvents() const return; } - WithoutPropagating("processing an event", [&] { - ProcessEvent(event, simulationIds, simulationBulkUpdates); - }); + WithoutPropagating("processing an event", + [&] { ProcessEvent(event, simulationIds, simulationBulkUpdates); }); } events.clear(); diff --git a/SilKit/source/dashboard/SilKitEvent.hpp b/SilKit/source/dashboard/SilKitEvent.hpp index 8cb84d115..bc51ce041 100644 --- a/SilKit/source/dashboard/SilKitEvent.hpp +++ b/SilKit/source/dashboard/SilKitEvent.hpp @@ -37,14 +37,13 @@ struct SimulationEnd using MetricsUpdatePair = std::pair; //! Payload of a SilKitEvent. The alternatives are in the same order as SilKitEventType. -using SilKitEventData = - std::variant; +using SilKitEventData = std::variant; //! Discriminator for SilKitEventData. Enumerator values must match the variant alternative order; //! the static_asserts below enforce that. @@ -61,25 +60,24 @@ enum class SilKitEventType namespace Detail { -template +template constexpr auto EventTypeMatchesAlternative() -> bool { - return std::is_same(kType), SilKitEventData>, T>::value; + return std::is_same(eventType), SilKitEventData>, T>::value; } } // namespace Detail static_assert(std::variant_size::value == 7, "SilKitEventType and SilKitEventData disagree"); static_assert(Detail::EventTypeMatchesAlternative(), ""); -static_assert(Detail::EventTypeMatchesAlternative< - SilKitEventType::OnParticipantConnected, - SilKit::Services::Orchestration::ParticipantConnectionInformation>(), +static_assert(Detail::EventTypeMatchesAlternative(), ""); static_assert(Detail::EventTypeMatchesAlternative(), + SilKit::Services::Orchestration::SystemState>(), ""); static_assert(Detail::EventTypeMatchesAlternative(), + SilKit::Services::Orchestration::ParticipantStatus>(), ""); static_assert(Detail::EventTypeMatchesAlternative(), ""); static_assert(Detail::EventTypeMatchesAlternative(), ""); diff --git a/SilKit/source/dashboard/Test_DashboardEventQueueWorker.cpp b/SilKit/source/dashboard/Test_DashboardEventQueueWorker.cpp index a50937e97..f0e39e991 100644 --- a/SilKit/source/dashboard/Test_DashboardEventQueueWorker.cpp +++ b/SilKit/source/dashboard/Test_DashboardEventQueueWorker.cpp @@ -31,11 +31,11 @@ namespace { namespace orchestration = SilKit::Services::Orchestration; -constexpr uint64_t kSimulationId{42}; -constexpr uint64_t kOtherSimulationId{43}; +constexpr uint64_t simulationId{42}; +constexpr uint64_t otherSimulationId{43}; -auto MakeParticipantStatus(const std::string& participantName, - orchestration::ParticipantState state) -> orchestration::ParticipantStatus +auto MakeParticipantStatus(const std::string& participantName, orchestration::ParticipantState state) + -> orchestration::ParticipantStatus { orchestration::ParticipantStatus status{}; status.participantName = participantName; @@ -89,7 +89,7 @@ class Test_DashboardEventQueueWorker : public Test TEST_F(Test_DashboardEventQueueWorker, SimulationStart_CreatesTheSimulation) { - EXPECT_CALL(_restClient, OnSimulationStart("silkit://localhost:8500/sim", 1000)).WillOnce(Return(kSimulationId)); + EXPECT_CALL(_restClient, OnSimulationStart("silkit://localhost:8500/sim", 1000)).WillOnce(Return(simulationId)); EnqueueSimulationStart("sim"); RunWorkerToCompletion(); @@ -100,7 +100,7 @@ TEST_F(Test_DashboardEventQueueWorker, SimulationStart_CreatesTheSimulation) */ TEST_F(Test_DashboardEventQueueWorker, SimulationStart_Twice_CreatesTheSimulationOnce) { - EXPECT_CALL(_restClient, OnSimulationStart(_, _)).WillOnce(Return(kSimulationId)); + EXPECT_CALL(_restClient, OnSimulationStart(_, _)).WillOnce(Return(simulationId)); EnqueueSimulationStart("sim"); EnqueueSimulationStart("sim"); @@ -109,8 +109,8 @@ TEST_F(Test_DashboardEventQueueWorker, SimulationStart_Twice_CreatesTheSimulatio TEST_F(Test_DashboardEventQueueWorker, SimulationStart_ForDistinctNames_CreatesEachSimulation) { - EXPECT_CALL(_restClient, OnSimulationStart("silkit://localhost:8500/a", _)).WillOnce(Return(kSimulationId)); - EXPECT_CALL(_restClient, OnSimulationStart("silkit://localhost:8500/b", _)).WillOnce(Return(kOtherSimulationId)); + EXPECT_CALL(_restClient, OnSimulationStart("silkit://localhost:8500/a", _)).WillOnce(Return(simulationId)); + EXPECT_CALL(_restClient, OnSimulationStart("silkit://localhost:8500/b", _)).WillOnce(Return(otherSimulationId)); EnqueueSimulationStart("a"); EnqueueSimulationStart("b"); @@ -141,10 +141,10 @@ TEST_F(Test_DashboardEventQueueWorker, EventsForAnUnknownSimulation_AreDropped) TEST_F(Test_DashboardEventQueueWorker, Events_AreBatchedIntoASingleBulkUpdate) { - EXPECT_CALL(_restClient, OnSimulationStart(_, _)).WillOnce(Return(kSimulationId)); + EXPECT_CALL(_restClient, OnSimulationStart(_, _)).WillOnce(Return(simulationId)); DashboardBulkUpdate captured; - EXPECT_CALL(_restClient, OnBulkUpdate(kSimulationId, _)) + EXPECT_CALL(_restClient, OnBulkUpdate(simulationId, _)) .WillOnce(WithArg<1>([&captured](const DashboardBulkUpdate& update) { captured.participantConnectionInformations = update.participantConnectionInformations; captured.participantStatuses = update.participantStatuses; @@ -170,17 +170,15 @@ TEST_F(Test_DashboardEventQueueWorker, Events_AreBatchedIntoASingleBulkUpdate) TEST_F(Test_DashboardEventQueueWorker, EachSimulation_GetsItsOwnBulkUpdate) { - EXPECT_CALL(_restClient, OnSimulationStart("silkit://localhost:8500/a", _)).WillOnce(Return(kSimulationId)); - EXPECT_CALL(_restClient, OnSimulationStart("silkit://localhost:8500/b", _)).WillOnce(Return(kOtherSimulationId)); + EXPECT_CALL(_restClient, OnSimulationStart("silkit://localhost:8500/a", _)).WillOnce(Return(simulationId)); + EXPECT_CALL(_restClient, OnSimulationStart("silkit://localhost:8500/b", _)).WillOnce(Return(otherSimulationId)); size_t participantsForA{0}; size_t participantsForB{0}; - EXPECT_CALL(_restClient, OnBulkUpdate(kSimulationId, _)) - .WillOnce(WithArg<1>([&](const DashboardBulkUpdate& u) { + EXPECT_CALL(_restClient, OnBulkUpdate(simulationId, _)).WillOnce(WithArg<1>([&](const DashboardBulkUpdate& u) { participantsForA = u.participantConnectionInformations.size(); })); - EXPECT_CALL(_restClient, OnBulkUpdate(kOtherSimulationId, _)) - .WillOnce(WithArg<1>([&](const DashboardBulkUpdate& u) { + EXPECT_CALL(_restClient, OnBulkUpdate(otherSimulationId, _)).WillOnce(WithArg<1>([&](const DashboardBulkUpdate& u) { participantsForB = u.participantConnectionInformations.size(); })); @@ -198,7 +196,7 @@ TEST_F(Test_DashboardEventQueueWorker, EachSimulation_GetsItsOwnBulkUpdate) //! Nothing to report means no request at all, rather than an empty bulk update every batch. TEST_F(Test_DashboardEventQueueWorker, ASimulationWithNothingToReport_SendsNoBulkUpdate) { - EXPECT_CALL(_restClient, OnSimulationStart(_, _)).WillOnce(Return(kSimulationId)); + EXPECT_CALL(_restClient, OnSimulationStart(_, _)).WillOnce(Return(simulationId)); EXPECT_CALL(_restClient, OnBulkUpdate(_, _)).Times(0); EnqueueSimulationStart("sim"); @@ -212,9 +210,9 @@ TEST_F(Test_DashboardEventQueueWorker, ASimulationStart_FlushesWhatWasAlreadyAcc { InSequence sequence; - EXPECT_CALL(_restClient, OnSimulationStart("silkit://localhost:8500/a", _)).WillOnce(Return(kSimulationId)); - EXPECT_CALL(_restClient, OnBulkUpdate(kSimulationId, _)); - EXPECT_CALL(_restClient, OnSimulationStart("silkit://localhost:8500/b", _)).WillOnce(Return(kOtherSimulationId)); + EXPECT_CALL(_restClient, OnSimulationStart("silkit://localhost:8500/a", _)).WillOnce(Return(simulationId)); + EXPECT_CALL(_restClient, OnBulkUpdate(simulationId, _)); + EXPECT_CALL(_restClient, OnSimulationStart("silkit://localhost:8500/b", _)).WillOnce(Return(otherSimulationId)); EnqueueSimulationStart("a"); Enqueue("a", orchestration::ParticipantConnectionInformation{"P1"}); @@ -226,10 +224,10 @@ TEST_F(Test_DashboardEventQueueWorker, ASimulationStart_FlushesWhatWasAlreadyAcc TEST_F(Test_DashboardEventQueueWorker, SimulationEnd_SetsStopped) { - EXPECT_CALL(_restClient, OnSimulationStart(_, _)).WillOnce(Return(kSimulationId)); + EXPECT_CALL(_restClient, OnSimulationStart(_, _)).WillOnce(Return(simulationId)); std::optional stopped; - EXPECT_CALL(_restClient, OnBulkUpdate(kSimulationId, _)) + EXPECT_CALL(_restClient, OnBulkUpdate(simulationId, _)) .WillOnce(WithArg<1>([&stopped](const DashboardBulkUpdate& u) { stopped = u.stopped; })); EnqueueSimulationStart("sim"); @@ -243,8 +241,8 @@ TEST_F(Test_DashboardEventQueueWorker, SimulationEnd_SetsStopped) //! After the end the name is forgotten, so late events are dropped rather than reusing the id. TEST_F(Test_DashboardEventQueueWorker, EventsAfterSimulationEnd_AreDropped) { - EXPECT_CALL(_restClient, OnSimulationStart(_, _)).WillOnce(Return(kSimulationId)); - EXPECT_CALL(_restClient, OnBulkUpdate(kSimulationId, _)).Times(1); + EXPECT_CALL(_restClient, OnSimulationStart(_, _)).WillOnce(Return(simulationId)); + EXPECT_CALL(_restClient, OnBulkUpdate(simulationId, _)).Times(1); EnqueueSimulationStart("sim"); Enqueue("sim", SimulationEnd{1}); @@ -256,10 +254,10 @@ TEST_F(Test_DashboardEventQueueWorker, EventsAfterSimulationEnd_AreDropped) TEST_F(Test_DashboardEventQueueWorker, ASimulationNameCanBeReusedAfterItEnded) { EXPECT_CALL(_restClient, OnSimulationStart(_, _)) - .WillOnce(Return(kSimulationId)) - .WillOnce(Return(kOtherSimulationId)); - EXPECT_CALL(_restClient, OnBulkUpdate(kSimulationId, _)); - EXPECT_CALL(_restClient, OnBulkUpdate(kOtherSimulationId, _)); + .WillOnce(Return(simulationId)) + .WillOnce(Return(otherSimulationId)); + EXPECT_CALL(_restClient, OnBulkUpdate(simulationId, _)); + EXPECT_CALL(_restClient, OnBulkUpdate(otherSimulationId, _)); EnqueueSimulationStart("sim"); Enqueue("sim", SimulationEnd{1}); @@ -273,8 +271,8 @@ TEST_F(Test_DashboardEventQueueWorker, ASimulationNameCanBeReusedAfterItEnded) //! Metrics have their own endpoint and are sent as they arrive rather than folded into the batch. TEST_F(Test_DashboardEventQueueWorker, MetricsUpdates_AreSentImmediatelyAndNotBatched) { - EXPECT_CALL(_restClient, OnSimulationStart(_, _)).WillOnce(Return(kSimulationId)); - EXPECT_CALL(_restClient, OnMetricsUpdate(kSimulationId, "P1", _)).Times(1); + EXPECT_CALL(_restClient, OnSimulationStart(_, _)).WillOnce(Return(simulationId)); + EXPECT_CALL(_restClient, OnMetricsUpdate(simulationId, "P1", _)).Times(1); EXPECT_CALL(_restClient, OnBulkUpdate(_, _)).Times(0); EnqueueSimulationStart("sim"); @@ -292,14 +290,13 @@ TEST_F(Test_DashboardEventQueueWorker, MetricsUpdates_AreSentImmediatelyAndNotBa */ TEST_F(Test_DashboardEventQueueWorker, Abort_FlushesWhatWasAccumulated) { - EXPECT_CALL(_restClient, OnSimulationStart(_, _)).WillOnce(Return(kSimulationId)); - EXPECT_CALL(_restClient, OnMetricsUpdate(kSimulationId, "P1", _)).WillOnce([this](auto&&...) { + EXPECT_CALL(_restClient, OnSimulationStart(_, _)).WillOnce(Return(simulationId)); + EXPECT_CALL(_restClient, OnMetricsUpdate(simulationId, "P1", _)).WillOnce([this](auto&&...) { _abort.store(true); }); size_t participants{0}; - EXPECT_CALL(_restClient, OnBulkUpdate(kSimulationId, _)) - .WillOnce(WithArg<1>([&](const DashboardBulkUpdate& update) { + EXPECT_CALL(_restClient, OnBulkUpdate(simulationId, _)).WillOnce(WithArg<1>([&](const DashboardBulkUpdate& update) { participants = update.participantConnectionInformations.size(); })); @@ -348,11 +345,11 @@ TEST_F(Test_DashboardEventQueueWorker, AThrowingRestClient_DoesNotEscapeTheWorke TEST_F(Test_DashboardEventQueueWorker, AFailedMetricsUpdate_DoesNotStopLaterEvents) { InSequence sequence; - EXPECT_CALL(_restClient, OnSimulationStart(_, _)).WillOnce(Return(kSimulationId)); - EXPECT_CALL(_restClient, OnMetricsUpdate(kSimulationId, "P1", _)) + EXPECT_CALL(_restClient, OnSimulationStart(_, _)).WillOnce(Return(simulationId)); + EXPECT_CALL(_restClient, OnMetricsUpdate(simulationId, "P1", _)) .WillOnce(Throw(SilKit::SilKitError{"Unexpected controller type Something"})); - EXPECT_CALL(_restClient, OnMetricsUpdate(kSimulationId, "P2", _)).Times(1); - EXPECT_CALL(_restClient, OnBulkUpdate(kSimulationId, _)).Times(1); + EXPECT_CALL(_restClient, OnMetricsUpdate(simulationId, "P2", _)).Times(1); + EXPECT_CALL(_restClient, OnBulkUpdate(simulationId, _)).Times(1); EnqueueSimulationStart("sim"); Enqueue("sim", MetricsUpdatePair{"P1", MetricsUpdate{}}); @@ -365,18 +362,17 @@ TEST_F(Test_DashboardEventQueueWorker, AFailedMetricsUpdate_DoesNotStopLaterEven //! A bulk update that cannot be sent must not be retried forever, blocking everything behind it. TEST_F(Test_DashboardEventQueueWorker, AFailedBulkUpdate_IsDroppedRatherThanRetried) { - EXPECT_CALL(_restClient, OnSimulationStart(_, _)).WillOnce(Return(kSimulationId)); + EXPECT_CALL(_restClient, OnSimulationStart(_, _)).WillOnce(Return(simulationId)); std::vector secondFlush; - EXPECT_CALL(_restClient, OnBulkUpdate(kSimulationId, _)) + EXPECT_CALL(_restClient, OnBulkUpdate(simulationId, _)) .Times(2) // The first flush queues the next batch, ends the queue and then fails. .WillOnce([&](uint64_t, const DashboardBulkUpdate&) { Enqueue("sim", orchestration::ParticipantConnectionInformation{"P2"}); _queue.Stop(); throw SilKit::SilKitError{"cannot map this"}; - }) - .WillOnce(WithArg<1>([&](const DashboardBulkUpdate& update) { + }).WillOnce(WithArg<1>([&](const DashboardBulkUpdate& update) { for (const auto& connection : update.participantConnectionInformations) { secondFlush.push_back(connection.participantName); @@ -399,8 +395,8 @@ TEST_F(Test_DashboardEventQueueWorker, AFailedSimulationStart_DoesNotStopLaterSi { EXPECT_CALL(_restClient, OnSimulationStart("silkit://localhost:8500/bad", _)) .WillOnce(Throw(std::runtime_error{"boom"})); - EXPECT_CALL(_restClient, OnSimulationStart("silkit://localhost:8500/good", _)).WillOnce(Return(kSimulationId)); - EXPECT_CALL(_restClient, OnBulkUpdate(kSimulationId, _)).Times(1); + EXPECT_CALL(_restClient, OnSimulationStart("silkit://localhost:8500/good", _)).WillOnce(Return(simulationId)); + EXPECT_CALL(_restClient, OnBulkUpdate(simulationId, _)).Times(1); EnqueueSimulationStart("bad"); EnqueueSimulationStart("good"); diff --git a/SilKit/source/dashboard/client/Test_DashboardSystemServiceClient.cpp b/SilKit/source/dashboard/client/Test_DashboardSystemServiceClient.cpp index b1b25f5a9..c4af4ffce 100644 --- a/SilKit/source/dashboard/client/Test_DashboardSystemServiceClient.cpp +++ b/SilKit/source/dashboard/client/Test_DashboardSystemServiceClient.cpp @@ -114,8 +114,7 @@ TEST_F(Test_DashboardSystemServiceClient, CreateSimulation_SendsTheSerializedReq service->CreateSimulation(request); EXPECT_EQ(actualBody, ToJson(request)); - EXPECT_EQ(actualBody, - "{\"started\": 17,\"configuration\": {\"connectUri\": \"silkit://localhost:8500\"}}"); + EXPECT_EQ(actualBody, "{\"started\": 17,\"configuration\": {\"connectUri\": \"silkit://localhost:8500\"}}"); } // --- UpdateSimulation ------------------------------------------------------------------------- diff --git a/SilKit/source/dashboard/http/AsioHttpClient.cpp b/SilKit/source/dashboard/http/AsioHttpClient.cpp index 59fb61332..64165cfcc 100644 --- a/SilKit/source/dashboard/http/AsioHttpClient.cpp +++ b/SilKit/source/dashboard/http/AsioHttpClient.cpp @@ -28,12 +28,12 @@ namespace VSilKit { namespace { //! Sentinel no asio operation ever completes with, so it doubles as "still pending". -const std::error_code kPending = asio::error::would_block; +const std::error_code pending = asio::error::would_block; //! How often the deadline loop wakes to notice an Abort(); also bounds abort latency. -constexpr auto kPollInterval = std::chrono::milliseconds{50}; +constexpr auto pollInterval = std::chrono::milliseconds{50}; -constexpr size_t kMaxHeadSize = 64 * 1024; +constexpr size_t maxHeadSize = 64 * 1024; } // namespace @@ -93,13 +93,13 @@ struct AsioHttpClient::Impl template auto RunWithDeadline(Initiate&& initiate, std::chrono::milliseconds timeout) -> std::error_code { - auto opError = kPending; + auto opError = pending; initiate([&opError](const std::error_code& ec) { opError = ec; }); ioContext.restart(); const auto deadline = std::chrono::steady_clock::now() + timeout; - while (opError == kPending) + while (opError == pending) { if (aborted.load(std::memory_order_acquire)) { @@ -110,7 +110,7 @@ struct AsioHttpClient::Impl { return DrainAndFail(asio::error::timed_out); } - ioContext.run_one_for(std::min(remaining, kPollInterval)); + ioContext.run_one_for(std::min(remaining, pollInterval)); } return opError; } @@ -138,19 +138,17 @@ struct AsioHttpClient::Impl // be reliably cancelled, so we want to be exposed to it at most once. asio::ip::tcp::resolver resolver{ioContext}; asio::ip::tcp::resolver::results_type results; - const auto ec = RunWithDeadline( - [&](auto handler) { - resolver.async_resolve(host, std::to_string(port), - [handler, &results](const std::error_code& e, - asio::ip::tcp::resolver::results_type r) { - if (!e) - { - results = std::move(r); - } - handler(e); - }); - }, - timeouts.connect); + const auto ec = RunWithDeadline([&](auto handler) { + resolver.async_resolve( + host, std::to_string(port), + [handler, &results](const std::error_code& e, asio::ip::tcp::resolver::results_type r) { + if (!e) + { + results = std::move(r); + } + handler(e); + }); + }, timeouts.connect); if (ec) { return ec; @@ -191,14 +189,10 @@ struct AsioHttpClient::Impl } socket.emplace(ioContext); - const auto ec = RunWithDeadline( - [&](auto handler) { - asio::async_connect(*socket, endpoints, - [handler](const std::error_code& e, const asio::ip::tcp::endpoint&) { - handler(e); - }); - }, - timeouts.connect); + const auto ec = RunWithDeadline([&](auto handler) { + asio::async_connect(*socket, endpoints, + [handler](const std::error_code& e, const asio::ip::tcp::endpoint&) { handler(e); }); + }, timeouts.connect); if (ec) { DropSocket(); @@ -229,32 +223,28 @@ struct AsioHttpClient::Impl auto WriteAll(const std::string& request) -> std::error_code { - return RunWithDeadline( - [&](auto handler) { - asio::async_write(*socket, asio::buffer(request), - [handler](const std::error_code& e, size_t) { handler(e); }); - }, - timeouts.write); + return RunWithDeadline([&](auto handler) { + asio::async_write(*socket, asio::buffer(request), + [handler](const std::error_code& e, size_t) { handler(e); }); + }, timeouts.write); } //! Reads up to and including the blank line terminating the response head. auto ReadHead(std::string& head) -> std::error_code { size_t headSize = 0; - const auto ec = RunWithDeadline( - [&](auto handler) { - asio::async_read_until(*socket, readBuffer, "\r\n\r\n", - [handler, &headSize](const std::error_code& e, size_t n) { - headSize = n; - handler(e); - }); - }, - timeouts.read); + const auto ec = RunWithDeadline([&](auto handler) { + asio::async_read_until(*socket, readBuffer, "\r\n\r\n", + [handler, &headSize](const std::error_code& e, size_t n) { + headSize = n; + handler(e); + }); + }, timeouts.read); if (ec) { return ec; } - if (headSize > kMaxHeadSize) + if (headSize > maxHeadSize) { return asio::error::message_size; } @@ -267,7 +257,7 @@ struct AsioHttpClient::Impl //! Reads exactly `count` bytes of body, serving from the buffer first. auto ReadExactly(uint64_t count, std::string& out) -> std::error_code { - if (count > kMaxHttpBodySize) + if (count > maxHttpBodySize) { return asio::error::message_size; } @@ -275,12 +265,10 @@ struct AsioHttpClient::Impl if (readBuffer.size() < wanted) { const auto missing = wanted - readBuffer.size(); - const auto ec = RunWithDeadline( - [&](auto handler) { - asio::async_read(*socket, readBuffer, asio::transfer_exactly(missing), - [handler](const std::error_code& e, size_t) { handler(e); }); - }, - timeouts.read); + const auto ec = RunWithDeadline([&](auto handler) { + asio::async_read(*socket, readBuffer, asio::transfer_exactly(missing), + [handler](const std::error_code& e, size_t) { handler(e); }); + }, timeouts.read); if (ec) { return ec; @@ -296,15 +284,13 @@ struct AsioHttpClient::Impl auto ReadLine(std::string& line) -> std::error_code { size_t lineSize = 0; - const auto ec = RunWithDeadline( - [&](auto handler) { - asio::async_read_until(*socket, readBuffer, "\r\n", - [handler, &lineSize](const std::error_code& e, size_t n) { - lineSize = n; - handler(e); - }); - }, - timeouts.read); + const auto ec = RunWithDeadline([&](auto handler) { + asio::async_read_until(*socket, readBuffer, "\r\n", + [handler, &lineSize](const std::error_code& e, size_t n) { + lineSize = n; + handler(e); + }); + }, timeouts.read); if (ec) { return ec; @@ -333,7 +319,7 @@ struct AsioHttpClient::Impl { break; } - if (out.size() + chunkSize > kMaxHttpBodySize) + if (out.size() + chunkSize > maxHttpBodySize) { return asio::error::message_size; } @@ -368,17 +354,14 @@ struct AsioHttpClient::Impl auto ReadUntilClose(std::string& out) -> std::error_code { - const auto ec = RunWithDeadline( - [&](auto handler) { - asio::async_read(*socket, readBuffer, - [handler](const std::error_code& e, size_t) { handler(e); }); - }, - timeouts.read); + const auto ec = RunWithDeadline([&](auto handler) { + asio::async_read(*socket, readBuffer, [handler](const std::error_code& e, size_t) { handler(e); }); + }, timeouts.read); if (ec && ec != asio::error::eof) { return ec; } - if (readBuffer.size() > kMaxHttpBodySize) + if (readBuffer.size() > maxHttpBodySize) { return asio::error::message_size; } diff --git a/SilKit/source/dashboard/http/FakeHttpServer.hpp b/SilKit/source/dashboard/http/FakeHttpServer.hpp index 8f06ad42d..9d65b0e39 100644 --- a/SilKit/source/dashboard/http/FakeHttpServer.hpp +++ b/SilKit/source/dashboard/http/FakeHttpServer.hpp @@ -110,11 +110,10 @@ class FakeHttpServer } //! Convenience: a well-formed response with a Content-Length body. - static auto MakeReply(int statusCode, const std::string& body, - const std::string& extraHeaders = {}) -> std::string + static auto MakeReply(int statusCode, const std::string& body, const std::string& extraHeaders = {}) -> std::string { - return "HTTP/1.1 " + std::to_string(statusCode) + " Status\r\n" + extraHeaders + "Content-Length: " - + std::to_string(body.size()) + "\r\n\r\n" + body; + return "HTTP/1.1 " + std::to_string(statusCode) + " Status\r\n" + extraHeaders + + "Content-Length: " + std::to_string(body.size()) + "\r\n\r\n" + body; } //! Convenience: a handler that always answers with the same bytes. diff --git a/SilKit/source/dashboard/http/HttpResponseParser.cpp b/SilKit/source/dashboard/http/HttpResponseParser.cpp index c3664ef46..c7e509d12 100644 --- a/SilKit/source/dashboard/http/HttpResponseParser.cpp +++ b/SilKit/source/dashboard/http/HttpResponseParser.cpp @@ -214,7 +214,7 @@ auto ParseResponseHead(std::string_view head, ResponseHead& out) -> bool if (haveContentLength) { - if (contentLength > kMaxHttpBodySize) + if (contentLength > maxHttpBodySize) { return false; } diff --git a/SilKit/source/dashboard/http/HttpResponseParser.hpp b/SilKit/source/dashboard/http/HttpResponseParser.hpp index 61eafeb48..ac3e57bee 100644 --- a/SilKit/source/dashboard/http/HttpResponseParser.hpp +++ b/SilKit/source/dashboard/http/HttpResponseParser.hpp @@ -11,7 +11,7 @@ namespace VSilKit { //! Upper bound on a response body we are willing to buffer. -constexpr uint64_t kMaxHttpBodySize = 1u << 20; // 1 MiB +constexpr uint64_t maxHttpBodySize = 1u << 20; // 1 MiB //! How the body of a response is framed. enum class HttpBodyFraming diff --git a/SilKit/source/dashboard/http/RetryingHttpClient.cpp b/SilKit/source/dashboard/http/RetryingHttpClient.cpp index 6e9821cac..2d1d1e1cd 100644 --- a/SilKit/source/dashboard/http/RetryingHttpClient.cpp +++ b/SilKit/source/dashboard/http/RetryingHttpClient.cpp @@ -66,8 +66,7 @@ void RetryingHttpClient::Abort() auto RetryingHttpClient::SleepInterruptible(std::chrono::milliseconds duration) -> bool { std::unique_lock lock{_mutex}; - const bool aborted = - _abortCv.wait_for(lock, duration, [this] { return _aborted.load(std::memory_order_acquire); }); + const bool aborted = _abortCv.wait_for(lock, duration, [this] { return _aborted.load(std::memory_order_acquire); }); return !aborted; } diff --git a/SilKit/source/dashboard/http/Test_AsioHttpClient.cpp b/SilKit/source/dashboard/http/Test_AsioHttpClient.cpp index 9b28bf3ea..3fad0f108 100644 --- a/SilKit/source/dashboard/http/Test_AsioHttpClient.cpp +++ b/SilKit/source/dashboard/http/Test_AsioHttpClient.cpp @@ -122,8 +122,8 @@ TEST_F(Test_AsioHttpClient, Post_HandlesABodylessResponseAndKeepsTheConnection) TEST_F(Test_AsioHttpClient, Post_ReassemblesAChunkedResponseBody) { - const std::string chunked = std::string{"HTTP/1.1 201 Created\r\nTransfer-Encoding: chunked\r\n\r\n"} - + "5\r\n" + R"({"id")" + "\r\n" + "4\r\n" + R"(:42})" + "\r\n" + "0\r\n\r\n"; + const std::string chunked = std::string{"HTTP/1.1 201 Created\r\nTransfer-Encoding: chunked\r\n\r\n"} + "5\r\n" + + R"({"id")" + "\r\n" + "4\r\n" + R"(:42})" + "\r\n" + "0\r\n\r\n"; FakeHttpServer server{AlwaysReply(chunked)}; AsioHttpClient client{nullptr, "127.0.0.1", server.Port()}; @@ -212,9 +212,8 @@ TEST_F(Test_AsioHttpClient, Abort_UnblocksAnInFlightRequest) TEST_F(Test_AsioHttpClient, RetryingHttpClient_OverTheRealTransport_RecoversFromServiceUnavailable) { std::atomic attempts{0}; - FakeHttpServer server{[&attempts](const std::string&) { - return ++attempts <= 2 ? Reply(503, "") : Reply(200, "{}"); - }}; + FakeHttpServer server{ + [&attempts](const std::string&) { return ++attempts <= 2 ? Reply(503, "") : Reply(200, "{}"); }}; auto transport = std::make_shared(nullptr, "127.0.0.1", server.Port()); HttpRetryPolicy policy{}; diff --git a/SilKit/source/dashboard/http/Test_HttpResponseParser.cpp b/SilKit/source/dashboard/http/Test_HttpResponseParser.cpp index b937376ea..1a24700dc 100644 --- a/SilKit/source/dashboard/http/Test_HttpResponseParser.cpp +++ b/SilKit/source/dashboard/http/Test_HttpResponseParser.cpp @@ -23,9 +23,9 @@ struct HeadCase bool interim; }; -// A valid head, for the cases where only one attribute is under test. -constexpr auto kOk = true; -constexpr auto kBad = false; +// Names for the `valid` column, so the case tables below read as prose. +constexpr auto wellFormed = true; +constexpr auto malformed = false; class Test_HttpResponseParser_Head : public testing::TestWithParam { @@ -50,86 +50,84 @@ TEST_P(Test_HttpResponseParser_Head, ParseResponseHead) EXPECT_EQ(head.interim, c.interim) << c.what; } -const HeadCase kHeadCases[] = { +const HeadCase headCases[] = { // --- status line --- - {"201 with content-length", "HTTP/1.1 201 Created\r\nContent-Length: 12\r\n\r\n", kOk, 201, + {"201 with content-length", "HTTP/1.1 201 Created\r\nContent-Length: 12\r\n\r\n", wellFormed, 201, HttpBodyFraming::ContentLength, 12, false, false}, - {"no reason phrase", "HTTP/1.1 200\r\nContent-Length: 0\r\n\r\n", kOk, 200, HttpBodyFraming::ContentLength, 0, + {"no reason phrase", "HTTP/1.1 200\r\nContent-Length: 0\r\n\r\n", wellFormed, 200, HttpBodyFraming::ContentLength, + 0, false, false}, + {"HTTP/1.0", "HTTP/1.0 200 OK\r\nContent-Length: 1\r\n\r\n", wellFormed, 200, HttpBodyFraming::ContentLength, 1, false, false}, - {"HTTP/1.0", "HTTP/1.0 200 OK\r\nContent-Length: 1\r\n\r\n", kOk, 200, HttpBodyFraming::ContentLength, 1, false, - false}, - {"503", "HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\n\r\n", kOk, 503, + {"503", "HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\n\r\n", wellFormed, 503, HttpBodyFraming::ContentLength, 0, false, false}, - {"204 has no body", "HTTP/1.1 204 No Content\r\n\r\n", kOk, 204, HttpBodyFraming::None, 0, false, false}, - {"304 ignores content-length", "HTTP/1.1 304 Not Modified\r\nContent-Length: 99\r\n\r\n", kOk, 304, + {"204 has no body", "HTTP/1.1 204 No Content\r\n\r\n", wellFormed, 204, HttpBodyFraming::None, 0, false, false}, + {"304 ignores content-length", "HTTP/1.1 304 Not Modified\r\nContent-Length: 99\r\n\r\n", wellFormed, 304, HttpBodyFraming::None, 0, false, false}, - {"1xx is interim", "HTTP/1.1 100 Continue\r\n\r\n", kOk, 100, HttpBodyFraming::None, 0, false, true}, + {"1xx is interim", "HTTP/1.1 100 Continue\r\n\r\n", wellFormed, 100, HttpBodyFraming::None, 0, false, true}, - {"not http", "not http at all\r\n\r\n", kBad, 0, HttpBodyFraming::None, 0, false, false}, - {"no status code", "HTTP/1.1\r\n\r\n", kBad, 0, HttpBodyFraming::None, 0, false, false}, - {"two-digit code", "HTTP/1.1 20 OK\r\n\r\n", kBad, 0, HttpBodyFraming::None, 0, false, false}, - {"non-numeric code", "HTTP/1.1 2O1 Created\r\n\r\n", kBad, 0, HttpBodyFraming::None, 0, false, false}, - {"unsupported version", "HTTP/2.0 200 OK\r\n\r\n", kBad, 0, HttpBodyFraming::None, 0, false, false}, - {"empty head", "", kBad, 0, HttpBodyFraming::None, 0, false, false}, + {"not http", "not http at all\r\n\r\n", malformed, 0, HttpBodyFraming::None, 0, false, false}, + {"no status code", "HTTP/1.1\r\n\r\n", malformed, 0, HttpBodyFraming::None, 0, false, false}, + {"two-digit code", "HTTP/1.1 20 OK\r\n\r\n", malformed, 0, HttpBodyFraming::None, 0, false, false}, + {"non-numeric code", "HTTP/1.1 2O1 Created\r\n\r\n", malformed, 0, HttpBodyFraming::None, 0, false, false}, + {"unsupported version", "HTTP/2.0 200 OK\r\n\r\n", malformed, 0, HttpBodyFraming::None, 0, false, false}, + {"empty head", "", malformed, 0, HttpBodyFraming::None, 0, false, false}, // --- body framing --- - {"chunked", "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n", kOk, 200, HttpBodyFraming::Chunked, 0, + {"chunked", "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n", wellFormed, 200, HttpBodyFraming::Chunked, 0, false, false}, - {"chunked wins over content-length", - "HTTP/1.1 200 OK\r\nContent-Length: 5\r\nTransfer-Encoding: chunked\r\n\r\n", kOk, 200, - HttpBodyFraming::Chunked, 0, false, false}, - {"no framing header reads until close", "HTTP/1.1 200 OK\r\n\r\n", kOk, 200, HttpBodyFraming::UntilClose, 0, + {"chunked wins over content-length", "HTTP/1.1 200 OK\r\nContent-Length: 5\r\nTransfer-Encoding: chunked\r\n\r\n", + wellFormed, 200, HttpBodyFraming::Chunked, 0, false, false}, + {"no framing header reads until close", "HTTP/1.1 200 OK\r\n\r\n", wellFormed, 200, HttpBodyFraming::UntilClose, 0, false, false}, - {"agreeing duplicate content-length", "HTTP/1.1 200 OK\r\nContent-Length: 5\r\nContent-Length: 5\r\n\r\n", kOk, - 200, HttpBodyFraming::ContentLength, 5, false, false}, - {"content-length at the cap", "HTTP/1.1 200 OK\r\nContent-Length: 1048576\r\n\r\n", kOk, 200, + {"agreeing duplicate content-length", "HTTP/1.1 200 OK\r\nContent-Length: 5\r\nContent-Length: 5\r\n\r\n", + wellFormed, 200, HttpBodyFraming::ContentLength, 5, false, false}, + {"content-length at the cap", "HTTP/1.1 200 OK\r\nContent-Length: 1048576\r\n\r\n", wellFormed, 200, HttpBodyFraming::ContentLength, 1048576, false, false}, {"conflicting duplicate content-length", "HTTP/1.1 200 OK\r\nContent-Length: 5\r\nContent-Length: 6\r\n\r\n", - kBad, 0, HttpBodyFraming::None, 0, false, false}, - {"non-numeric content-length", "HTTP/1.1 200 OK\r\nContent-Length: abc\r\n\r\n", kBad, 0, + malformed, 0, HttpBodyFraming::None, 0, false, false}, + {"non-numeric content-length", "HTTP/1.1 200 OK\r\nContent-Length: abc\r\n\r\n", malformed, 0, HttpBodyFraming::None, 0, false, false}, - {"content-length over the cap", "HTTP/1.1 200 OK\r\nContent-Length: 1048577\r\n\r\n", kBad, 0, + {"content-length over the cap", "HTTP/1.1 200 OK\r\nContent-Length: 1048577\r\n\r\n", malformed, 0, HttpBodyFraming::None, 0, false, false}, - {"undecodable transfer-encoding", "HTTP/1.1 200 OK\r\nTransfer-Encoding: gzip\r\n\r\n", kBad, 0, + {"undecodable transfer-encoding", "HTTP/1.1 200 OK\r\nTransfer-Encoding: gzip\r\n\r\n", malformed, 0, HttpBodyFraming::None, 0, false, false}, // --- leniency about header syntax --- - {"lowercase header name", "HTTP/1.1 200 OK\r\ncontent-length: 3\r\n\r\n", kOk, 200, + {"lowercase header name", "HTTP/1.1 200 OK\r\ncontent-length: 3\r\n\r\n", wellFormed, 200, HttpBodyFraming::ContentLength, 3, false, false}, - {"mixed-case header name", "HTTP/1.1 200 OK\r\nCoNtEnT-LeNgTh: 3\r\n\r\n", kOk, 200, + {"mixed-case header name", "HTTP/1.1 200 OK\r\nCoNtEnT-LeNgTh: 3\r\n\r\n", wellFormed, 200, HttpBodyFraming::ContentLength, 3, false, false}, - {"surrounding whitespace", "HTTP/1.1 200 OK\r\nContent-Length: 3 \r\n\r\n", kOk, 200, + {"surrounding whitespace", "HTTP/1.1 200 OK\r\nContent-Length: 3 \r\n\r\n", wellFormed, 200, HttpBodyFraming::ContentLength, 3, false, false}, - {"bare LF line endings", "HTTP/1.1 200 OK\nContent-Length: 3\n\n", kOk, 200, HttpBodyFraming::ContentLength, 3, - false, false}, + {"bare LF line endings", "HTTP/1.1 200 OK\nContent-Length: 3\n\n", wellFormed, 200, HttpBodyFraming::ContentLength, + 3, false, false}, {"unknown headers ignored", - "HTTP/1.1 200 OK\r\nServer: nginx\r\nX-Whatever: 1\r\nDate: now\r\nContent-Length: 3\r\n\r\n", kOk, 200, + "HTTP/1.1 200 OK\r\nServer: nginx\r\nX-Whatever: 1\r\nDate: now\r\nContent-Length: 3\r\n\r\n", wellFormed, 200, HttpBodyFraming::ContentLength, 3, false, false}, - {"obs-fold continuation skipped", - "HTTP/1.1 200 OK\r\nX-Long: a\r\n continued\r\nContent-Length: 3\r\n\r\n", kOk, 200, - HttpBodyFraming::ContentLength, 3, false, false}, - {"connection close", "HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 3\r\n\r\n", kOk, 200, + {"obs-fold continuation skipped", "HTTP/1.1 200 OK\r\nX-Long: a\r\n continued\r\nContent-Length: 3\r\n\r\n", + wellFormed, 200, HttpBodyFraming::ContentLength, 3, false, false}, + {"connection close", "HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 3\r\n\r\n", wellFormed, 200, HttpBodyFraming::ContentLength, 3, true, false}, - {"connection keep-alive", "HTTP/1.1 200 OK\r\nConnection: keep-alive\r\nContent-Length: 3\r\n\r\n", kOk, 200, + {"connection keep-alive", "HTTP/1.1 200 OK\r\nConnection: keep-alive\r\nContent-Length: 3\r\n\r\n", wellFormed, 200, HttpBodyFraming::ContentLength, 3, false, false}, - {"header without a colon", "HTTP/1.1 200 OK\r\nnonsense\r\n\r\n", kBad, 0, HttpBodyFraming::None, 0, false, + {"header without a colon", "HTTP/1.1 200 OK\r\nnonsense\r\n\r\n", malformed, 0, HttpBodyFraming::None, 0, false, false}, }; -INSTANTIATE_TEST_SUITE_P(Cases, Test_HttpResponseParser_Head, testing::ValuesIn(kHeadCases), +INSTANTIATE_TEST_SUITE_P(Cases, Test_HttpResponseParser_Head, testing::ValuesIn(headCases), [](const testing::TestParamInfo& info) { - std::string name{info.param.what}; - for (auto& c : name) - { - if (!std::isalnum(static_cast(c))) - { - c = '_'; - } - } - return name; -}); + std::string name{info.param.what}; + for (auto& c : name) + { + if (!std::isalnum(static_cast(c))) + { + c = '_'; + } + } + return name; + }); struct ChunkCase { @@ -157,30 +155,30 @@ TEST_P(Test_HttpResponseParser_ChunkSize, ParseChunkSize) } } -const ChunkCase kChunkCases[] = { - {"lowercase hex", "1a3", kOk, 0x1a3}, - {"uppercase hex", "1A3", kOk, 0x1a3}, - {"terminator", "0", kOk, 0}, - {"chunk extension stripped", "1a3;ext=val", kOk, 0x1a3}, - {"surrounding whitespace", " 1a3 ", kOk, 0x1a3}, - {"largest representable", "FFFFFFFFFFFFFFFF", kOk, 0xFFFFFFFFFFFFFFFFULL}, - {"empty", "", kBad, 0}, - {"not hex", "xyz", kBad, 0}, - {"overflows uint64", "FFFFFFFFFFFFFFFFF", kBad, 0}, +const ChunkCase chunkCases[] = { + {"lowercase hex", "1a3", wellFormed, 0x1a3}, + {"uppercase hex", "1A3", wellFormed, 0x1a3}, + {"terminator", "0", wellFormed, 0}, + {"chunk extension stripped", "1a3;ext=val", wellFormed, 0x1a3}, + {"surrounding whitespace", " 1a3 ", wellFormed, 0x1a3}, + {"largest representable", "FFFFFFFFFFFFFFFF", wellFormed, 0xFFFFFFFFFFFFFFFFULL}, + {"empty", "", malformed, 0}, + {"not hex", "xyz", malformed, 0}, + {"overflows uint64", "FFFFFFFFFFFFFFFFF", malformed, 0}, }; -INSTANTIATE_TEST_SUITE_P(Cases, Test_HttpResponseParser_ChunkSize, testing::ValuesIn(kChunkCases), - [](const testing::TestParamInfo& info) { - std::string name{info.param.what}; - for (auto& c : name) - { - if (!std::isalnum(static_cast(c))) - { - c = '_'; - } - } - return name; -}); +INSTANTIATE_TEST_SUITE_P(Cases, Test_HttpResponseParser_ChunkSize, testing::ValuesIn(chunkCases), + [](const testing::TestParamInfo& info) { + std::string name{info.param.what}; + for (auto& c : name) + { + if (!std::isalnum(static_cast(c))) + { + c = '_'; + } + } + return name; + }); } // namespace } // namespace VSilKit diff --git a/SilKit/source/dashboard/http/Test_RetryingHttpClient.cpp b/SilKit/source/dashboard/http/Test_RetryingHttpClient.cpp index b0ff48ccb..f6996ca27 100644 --- a/SilKit/source/dashboard/http/Test_RetryingHttpClient.cpp +++ b/SilKit/source/dashboard/http/Test_RetryingHttpClient.cpp @@ -31,7 +31,10 @@ auto Unavailable() -> HttpResult class Test_RetryingHttpClient : public testing::Test { public: - void SetUp() override { _inner = std::make_shared(); } + void SetUp() override + { + _inner = std::make_shared(); + } auto CreateClient(HttpRetryPolicy policy = {}) -> RetryingHttpClient { diff --git a/SilKit/source/dashboard/json/DashboardJsonWriter.cpp b/SilKit/source/dashboard/json/DashboardJsonWriter.cpp index 8f4f61246..e30cad852 100644 --- a/SilKit/source/dashboard/json/DashboardJsonWriter.cpp +++ b/SilKit/source/dashboard/json/DashboardJsonWriter.cpp @@ -12,7 +12,7 @@ namespace Dashboard { namespace { //! U+FFFD REPLACEMENT CHARACTER, as UTF-8. -constexpr std::string_view kReplacementCharacter = "\xEF\xBF\xBD"; +constexpr std::string_view replacementCharacter = "\xEF\xBF\xBD"; /*! True for the control bytes ryml's JSON emitter would pass through unescaped. * @@ -59,7 +59,7 @@ void DashboardJsonWriter::WriteQuoted(std::string_view value) { if (NeedsReplacement(static_cast(c))) { - sanitized += kReplacementCharacter; + sanitized += replacementCharacter; } else { diff --git a/SilKit/source/dashboard/json/Test_DashboardJsonWriter.cpp b/SilKit/source/dashboard/json/Test_DashboardJsonWriter.cpp index 360e4b7d7..51d7387d5 100644 --- a/SilKit/source/dashboard/json/Test_DashboardJsonWriter.cpp +++ b/SilKit/source/dashboard/json/Test_DashboardJsonWriter.cpp @@ -57,8 +57,7 @@ auto MakeController(uint64_t id, std::string name, std::string networkName) -> B TEST(Test_DashboardJsonWriter, BulkSimulationDto_Default) { - EXPECT_EQ(ToJson(BulkSimulationDto{}), - "{\"stopped\": null,\"system\": {\"statuses\": []},\"participants\": []}"); + EXPECT_EQ(ToJson(BulkSimulationDto{}), "{\"stopped\": null,\"system\": {\"statuses\": []},\"participants\": []}"); } // --- simulation creation ---------------------------------------------------------------------- @@ -118,11 +117,12 @@ TEST(Test_DashboardJsonWriter, ParticipantStatusDto_NonAsciiIsEmittedAsRawUtf8) { ParticipantStatusDto status{}; status.state = ParticipantState::Stopped; - status.enterReason = "Fahrzeug-S\xc3\xbc" "d \xe2\x82\xac"; // "Fahrzeug-Sued EUR" in UTF-8 + status.enterReason = "Fahrzeug-S\xc3\xbc" + "d \xe2\x82\xac"; // "Fahrzeug-Sued EUR" in UTF-8 status.enterTime = 1; - EXPECT_EQ(ToJson(status), - "{\"state\": \"stopped\",\"enterReason\": \"Fahrzeug-S\xc3\xbc" "d \xe2\x82\xac\",\"enterTime\": 1}"); + EXPECT_EQ(ToJson(status), "{\"state\": \"stopped\",\"enterReason\": \"Fahrzeug-S\xc3\xbc" + "d \xe2\x82\xac\",\"enterTime\": 1}"); } // rapidyaml escapes only \b \f \n \r \t, so any other C0 byte would be emitted raw and break the @@ -156,8 +156,7 @@ TEST(Test_DashboardJsonWriter, MatchingLabelDto_KindIsEmittedAsItsName) */ TEST(Test_DashboardJsonWriter, StringFields_ThatLookLikeNumbers_StayQuoted) { - EXPECT_EQ(ToJson(MakeController(0, "12345", "0")), - "{\"id\": 0,\"name\": \"12345\",\"networkName\": \"0\"}"); + EXPECT_EQ(ToJson(MakeController(0, "12345", "0")), "{\"id\": 0,\"name\": \"12345\",\"networkName\": \"0\"}"); } TEST(Test_DashboardJsonWriter, StringFields_ThatLookLikeOtherJsonLiterals_StayQuoted) @@ -292,8 +291,7 @@ TEST(Test_DashboardJsonWriter, AttributeDataDto_AStringListValueStaysANestedStri attribute.mn = {"names"}; attribute.mv = "[\"a\",\"b\"]"; - EXPECT_EQ(ToJson(attribute), - "{\"ts\": 1,\"pn\": \"P1\",\"mn\": [\"names\"],\"mv\": \"[\\\"a\\\",\\\"b\\\"]\"}"); + EXPECT_EQ(ToJson(attribute), "{\"ts\": 1,\"pn\": \"P1\",\"mn\": [\"names\"],\"mv\": \"[\\\"a\\\",\\\"b\\\"]\"}"); } TEST(Test_DashboardJsonWriter, CounterDataDto_HandlesTheFullInt64Range) @@ -379,8 +377,7 @@ TEST(Test_DashboardJsonWriter, ParseSimulationCreationResponse_ReadsTheId) TEST(Test_DashboardJsonWriter, ParseSimulationCreationResponse_HandlesTheFullUint64Range) { - EXPECT_EQ(ParseSimulationCreationResponse(R"({"id":18446744073709551615})"), - std::numeric_limits::max()); + EXPECT_EQ(ParseSimulationCreationResponse(R"({"id":18446744073709551615})"), std::numeric_limits::max()); } /*! oatpp rejected unknown fields, and the resulting exception propagated out of the dashboard's diff --git a/SilKit/source/dashboard/service/DashboardDtoMapper.cpp b/SilKit/source/dashboard/service/DashboardDtoMapper.cpp index e9978141e..ba1af1ded 100644 --- a/SilKit/source/dashboard/service/DashboardDtoMapper.cpp +++ b/SilKit/source/dashboard/service/DashboardDtoMapper.cpp @@ -38,8 +38,7 @@ auto ToUInt64(const std::string& value) -> std::uint64_t } } -auto GetSupplementalDataValue(const Core::ServiceDescriptor& serviceDescriptor, - const std::string& key) -> std::string +auto GetSupplementalDataValue(const Core::ServiceDescriptor& serviceDescriptor, const std::string& key) -> std::string { std::string str; if (!serviceDescriptor.GetSupplementalDataItem(key, str)) @@ -152,8 +151,8 @@ auto CreateMatchingLabelDto(const Services::MatchingLabel& matchingLabel) -> Mat return label; } -auto CreateMatchingLabels(const Core::ServiceDescriptor& serviceDescriptor, - const std::string& labelsKey) -> std::vector +auto CreateMatchingLabels(const Core::ServiceDescriptor& serviceDescriptor, const std::string& labelsKey) + -> std::vector { std::string labelsStr; if (!serviceDescriptor.GetSupplementalDataItem(labelsKey, labelsStr)) @@ -196,8 +195,8 @@ DashboardDtoMapper::DashboardDtoMapper(Services::Logging::ILoggerInternal* logge { } -auto DashboardDtoMapper::CreateSimulationCreationRequestDto(const std::string& connectUri, - uint64_t start) -> SimulationCreationRequestDto +auto DashboardDtoMapper::CreateSimulationCreationRequestDto(const std::string& connectUri, uint64_t start) + -> SimulationCreationRequestDto { SimulationCreationRequestDto simulation{}; simulation.started = start; @@ -212,15 +211,14 @@ auto DashboardDtoMapper::CreateSystemStatusDto(Services::Orchestration::SystemSt return status; } -auto DashboardDtoMapper::CreateParticipantStatusDto( - const Services::Orchestration::ParticipantStatus& participantStatus) -> ParticipantStatusDto +auto DashboardDtoMapper::CreateParticipantStatusDto(const Services::Orchestration::ParticipantStatus& participantStatus) + -> ParticipantStatusDto { ParticipantStatusDto status{}; status.state = MapParticipantState(participantStatus.state); status.enterReason = participantStatus.enterReason; status.enterTime = static_cast( - std::chrono::duration_cast(participantStatus.enterTime.time_since_epoch()) - .count()); + std::chrono::duration_cast(participantStatus.enterTime.time_since_epoch()).count()); return status; } @@ -337,8 +335,7 @@ auto DashboardDtoMapper::CreateBulkSimulationDto(const DashboardBulkUpdate& bulk std::unordered_map nameToBulkParticipantDto; - const auto getOrCreateParticipantDto = [&nameToBulkParticipantDto](const std::string& name) - -> BulkParticipantDto& { + const auto getOrCreateParticipantDto = [&nameToBulkParticipantDto](const std::string& name) -> BulkParticipantDto& { auto it = nameToBulkParticipantDto.find(name); if (it == nameToBulkParticipantDto.end()) { @@ -451,8 +448,7 @@ void DashboardDtoMapper::ProcessServiceDiscovery(BulkParticipantDto& dto, const } } -void DashboardDtoMapper::ProcessControllerDiscovery(BulkParticipantDto& dto, - const ServiceDescriptor& serviceDescriptor) +void DashboardDtoMapper::ProcessControllerDiscovery(BulkParticipantDto& dto, const ServiceDescriptor& serviceDescriptor) { const auto controllerType = GetControllerType(serviceDescriptor); diff --git a/SilKit/source/dashboard/service/DashboardDtoMapper.hpp b/SilKit/source/dashboard/service/DashboardDtoMapper.hpp index 31c954734..dadf26404 100644 --- a/SilKit/source/dashboard/service/DashboardDtoMapper.hpp +++ b/SilKit/source/dashboard/service/DashboardDtoMapper.hpp @@ -23,11 +23,11 @@ class DashboardDtoMapper : public IDashboardDtoMapper */ explicit DashboardDtoMapper(Services::Logging::ILoggerInternal* logger = nullptr); - auto CreateSimulationCreationRequestDto(const std::string& connectUri, - uint64_t start) -> SimulationCreationRequestDto override; + auto CreateSimulationCreationRequestDto(const std::string& connectUri, uint64_t start) + -> SimulationCreationRequestDto override; auto CreateBulkSimulationDto(const DashboardBulkUpdate& bulkUpdate) -> BulkSimulationDto override; - auto CreateMetricsUpdateDto(const std::string& participantName, - const VSilKit::MetricsUpdate& metricsUpdate) -> MetricsUpdateDto override; + auto CreateMetricsUpdateDto(const std::string& participantName, const VSilKit::MetricsUpdate& metricsUpdate) + -> MetricsUpdateDto override; public: // exercised directly by the tests auto CreateSystemStatusDto(Services::Orchestration::SystemState systemState) -> SystemStatusDto; diff --git a/SilKit/source/dashboard/service/DashboardRestClient.cpp b/SilKit/source/dashboard/service/DashboardRestClient.cpp index 3805330dd..6e6147b7a 100644 --- a/SilKit/source/dashboard/service/DashboardRestClient.cpp +++ b/SilKit/source/dashboard/service/DashboardRestClient.cpp @@ -25,8 +25,7 @@ namespace Dashboard { DashboardRestClient::DashboardRestClient(Services::Logging::ILoggerInternal* logger, const std::string& dashboardServerUri, - VSilKit::AsioHttpClientTimeouts timeouts, - VSilKit::HttpRetryPolicy retryPolicy) + VSilKit::AsioHttpClientTimeouts timeouts, VSilKit::HttpRetryPolicy retryPolicy) : _logger(logger) { _dtoMapper = std::make_shared(logger); @@ -76,9 +75,7 @@ uint64_t DashboardRestClient::OnSimulationStart(const std::string& connectUri, u .Dispatch(); return *simulationId; } - _logger->MakeMessage(Level::Warn, TopicOf(*this)) - .SetMessage("Dashboard: creating simulation failed") - .Dispatch(); + _logger->MakeMessage(Level::Warn, TopicOf(*this)).SetMessage("Dashboard: creating simulation failed").Dispatch(); return 0; } diff --git a/SilKit/source/dashboard/service/IDashboardDtoMapper.hpp b/SilKit/source/dashboard/service/IDashboardDtoMapper.hpp index cf4cc0e8e..1d3b843df 100644 --- a/SilKit/source/dashboard/service/IDashboardDtoMapper.hpp +++ b/SilKit/source/dashboard/service/IDashboardDtoMapper.hpp @@ -28,11 +28,11 @@ class IDashboardDtoMapper public: virtual ~IDashboardDtoMapper() = default; - virtual auto CreateSimulationCreationRequestDto(const std::string& connectUri, - uint64_t start) -> SimulationCreationRequestDto = 0; + virtual auto CreateSimulationCreationRequestDto(const std::string& connectUri, uint64_t start) + -> SimulationCreationRequestDto = 0; virtual auto CreateBulkSimulationDto(const DashboardBulkUpdate& bulkUpdate) -> BulkSimulationDto = 0; - virtual auto CreateMetricsUpdateDto(const std::string& participantName, - const VSilKit::MetricsUpdate& metricsUpdate) -> MetricsUpdateDto = 0; + virtual auto CreateMetricsUpdateDto(const std::string& participantName, const VSilKit::MetricsUpdate& metricsUpdate) + -> MetricsUpdateDto = 0; }; } // namespace Dashboard diff --git a/SilKit/source/dashboard/service/Test_DashboardDtoMapper.cpp b/SilKit/source/dashboard/service/Test_DashboardDtoMapper.cpp index bf18cdb95..819489d9b 100644 --- a/SilKit/source/dashboard/service/Test_DashboardDtoMapper.cpp +++ b/SilKit/source/dashboard/service/Test_DashboardDtoMapper.cpp @@ -144,7 +144,8 @@ TEST_F(Test_DashboardDtoMapper, CreateBulkDataServiceDto_MapNetworkNameAndTopicA expectedLabel.value = "myValue"; expectedLabel.kind = Services::MatchingLabel::Kind::Mandatory; auto labels = std::vector{expectedLabel}; - descriptor.SetSupplementalDataItem(Core::Discovery::supplKeyDataSubscriberSubLabels, Config::SerializeAsJson(labels)); + descriptor.SetSupplementalDataItem(Core::Discovery::supplKeyDataSubscriberSubLabels, + Config::SerializeAsJson(labels)); // Act const auto dataMapper = CreateService(); @@ -355,7 +356,6 @@ TEST_F(Test_DashboardDtoMapper, CreateBulkSimulationDto) for (const auto& participantDto : dto.participants) { - if (participantDto.name == "A") { aParticipantDto = &participantDto; diff --git a/SilKit/source/dashboard/service/Test_DashboardRestClient.cpp b/SilKit/source/dashboard/service/Test_DashboardRestClient.cpp index d33323899..3fe5ea362 100644 --- a/SilKit/source/dashboard/service/Test_DashboardRestClient.cpp +++ b/SilKit/source/dashboard/service/Test_DashboardRestClient.cpp @@ -48,8 +48,7 @@ TEST_F(Test_DashboardRestClient, Create) TEST_F(Test_DashboardRestClient, OnSimulationStart_CreateSimulationSuccess) { constexpr uint64_t expectedSimulationId = 123; - EXPECT_CALL(*_mockDtoMapper, CreateSimulationCreationRequestDto) - .WillOnce(Return(SimulationCreationRequestDto{})); + EXPECT_CALL(*_mockDtoMapper, CreateSimulationCreationRequestDto).WillOnce(Return(SimulationCreationRequestDto{})); EXPECT_CALL(*_mockServiceClient, CreateSimulation).WillOnce(Return(expectedSimulationId)); const auto service = CreateService(); @@ -60,8 +59,7 @@ TEST_F(Test_DashboardRestClient, OnSimulationStart_CreateSimulationSuccess) TEST_F(Test_DashboardRestClient, OnSimulationStart_CreateSimulationFailure) { - EXPECT_CALL(*_mockDtoMapper, CreateSimulationCreationRequestDto) - .WillOnce(Return(SimulationCreationRequestDto{})); + EXPECT_CALL(*_mockDtoMapper, CreateSimulationCreationRequestDto).WillOnce(Return(SimulationCreationRequestDto{})); EXPECT_CALL(*_mockServiceClient, CreateSimulation).WillOnce(Return(std::nullopt)); EXPECT_CALL(_dummyLogger, ProcessLoggerMessage(Services::Logging::ALoggerMessageWith( Services::Logging::Level::Warn, "Dashboard: creating simulation failed")));