diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6b9dc0b..7a6332d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -156,6 +156,7 @@ jobs: -DCMAKE_BUILD_TYPE=Release -DBUILD_SHARED_LIBS=${{ matrix.build-shared }} -DBUILD_TESTING=OFF + -DNETFT_BUILD_CLI=OFF -DCMAKE_INSTALL_PREFIX=${{ github.workspace }}/build/cmake-316-install - name: Build and install core run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fd2bf2..ad7bf07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to this project are documented in this file. +## Unreleased + +### Fixed + +- Update the pinned native core to netft-cpp 0.3.1 so fail-stop clients do not receive + stalled or backward FT-sequence samples before the corresponding fault. +- Exclude the upstream legacy CLI sources from the Python package's private core snapshot. + ## 2.1.0 - 2026-07-29 ### Added diff --git a/README.md b/README.md index 56f6bae..b813413 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ pyNetFT is a synchronous, typed Python client for ATI Industrial Automation Net F/T Ethernet force/torque sensors. It discovers the sensor calibration, streams RDT measurements through a native C++ core, and exposes raw counts, physical measurements, health, and recovery information without requiring NumPy. -- **Tested native core:** the protocol and recovery implementation is pinned to [netft-cpp 0.3.0](https://github.com/netft/netft-cpp/releases/tag/v0.3.0) and is exercised with offline fake sensors and native sanitizers. +- **Tested native core:** the protocol and recovery implementation is pinned to [netft-cpp 0.3.1](https://github.com/netft/netft-cpp/releases/tag/v0.3.1) and is exercised with offline fake sensors and native sanitizers. - **Sensor-aware data:** calibration, force and torque units, configuration revisions, sequence progress, and faults remain visible to the application. - **Self-contained typed wheels:** Linux, macOS, and Windows wheels include the native core and a minimal static HTTP-only curl build, plus inline type information for Python 3.10–3.14. diff --git a/core/CMakeLists.txt b/core/CMakeLists.txt index 89f873d..7b816fd 100644 --- a/core/CMakeLists.txt +++ b/core/CMakeLists.txt @@ -1,6 +1,6 @@ cmake_minimum_required(VERSION 3.16) -project(netft VERSION 0.3.0 LANGUAGES CXX) +project(netft VERSION 0.3.1 LANGUAGES CXX) include(CTest) include(GNUInstallDirs) diff --git a/core/SNAPSHOT.sha256 b/core/SNAPSHOT.sha256 index bbda350..3922b0d 100644 --- a/core/SNAPSHOT.sha256 +++ b/core/SNAPSHOT.sha256 @@ -1,9 +1,6 @@ -d743e29b839b38a36ab6826314e85611cb388d014729eefaf7e664e9de6dfc99 CMakeLists.txt +c493d94026a5eb53f0589341d33915b8180c34bd712f2f9f2191e41ca48dd8f5 CMakeLists.txt c71d239df91726fc519c6eb72d318ec65820627232b2f796219e87dcf35d0ab4 LICENSE -c76dd2f126d2e3b9a146e8516de1e0a000671a533e706453b34d9d5926e8789c UPSTREAM -38f545018e46fcf7c5174769021770c3e11791a3accf7e61b7505d18d0f9d2bf app/cli.cpp -91de2a75f3dfd9db88020acec1fdc72b0ddaf2667eee543d4cdfc0e27be91a2b app/cli.hpp -4963748e0ea83bf1a3ff18e8ea1e5b07d726d4aedd86ffdcbee5f1d2c5774d08 app/main.cpp +e78f98877ffe87b93dcec3d30d9c59337d3fa0e2e473ecc7134994a0c3bb7092 UPSTREAM 40701f04e67bb18052b6c8f1db60cf5d41baab09e5396f5a88c1eb14ad1a7139 cmake/netftConfig.cmake.in ebb4c53b7e7e65880580a7649e1c1a142e3a9db1fdec7d108d2d7dde0ad0d36d include/netft/client.hpp 4f7fb383da5456fd8477e1981f4ff3eba9b82ec3a98f3c395e7d482faf75c112 include/netft/discovery.hpp @@ -11,7 +8,7 @@ ebb4c53b7e7e65880580a7649e1c1a142e3a9db1fdec7d108d2d7dde0ad0d36d include/netft/ 912892bcb8bb5f95392c9c74983d91aab103b7adc5735505b0ac97e1012893b1 include/netft/status.hpp a7c899858aa7a0a28780504f14c1ecdaebb375607119a03500903f3782f45a68 include/netft/types.hpp e8a68945aaebfd19a1cb1f5cf71f4666d997fa351a41e9adcd137b50a7eb3ae7 src/client.cpp -7f7b998e4a29c358365235aeaf44531d2bc3a189187272a676c5580ac211bca2 src/detail/client_impl.cpp +a7e0bcba7a8cd5ccb1ab04658ea735f2b6a3f2bac81c3bce95578e3c14349650 src/detail/client_impl.cpp 5d194b6b6cb8b04e742ee3e1508ac7b8e7972b5bbc7a02c94003d1f402dfccf1 src/detail/client_impl.hpp ad8744385642693bb4666c428a5dbd0d6eff350b0079a6c9ff720512e89b6a53 src/detail/fault_latch.cpp be488c557e17044948cd62e6de07bf54862d389a584b40b78336479cecdaf23c src/detail/fault_latch.hpp diff --git a/core/UPSTREAM b/core/UPSTREAM index 3f85795..45b22aa 100644 --- a/core/UPSTREAM +++ b/core/UPSTREAM @@ -1,4 +1,4 @@ repository=https://github.com/netft/netft-cpp -tag=v0.3.0 -commit=46ee05639f818a17c1cfe604d0d77b1feb8f9b2b -paths=CMakeLists.txt,LICENSE,app,cmake,include,src +tag=v0.3.1 +commit=859eeda8b077093f9bc49d9c1e5506c334647e7b +paths=CMakeLists.txt,LICENSE,cmake,include,src diff --git a/core/app/cli.cpp b/core/app/cli.cpp deleted file mode 100644 index b421d34..0000000 --- a/core/app/cli.cpp +++ /dev/null @@ -1,594 +0,0 @@ -#include "cli.hpp" - -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include "netft/client.hpp" -#include "netft/discovery.hpp" -#include "netft/status.hpp" - -namespace netft::cli { -namespace { - -struct Summary { - SensorConfiguration configuration; - HealthSnapshot health; - Sample sample; - double elapsed_s{}; - std::optional requested_duration_s; - bool bias_applied{}; -}; - -double parse_positive_double(std::string_view name, const std::string &text) { - std::size_t consumed{}; - double value{}; - try { - value = std::stod(text, &consumed); - } catch (const std::exception &) { - throw UsageError(std::string{name} + " must be a positive number"); - } - if (consumed != text.size() || !std::isfinite(value) || value <= 0.0) { - throw UsageError(std::string{name} + " must be a positive number"); - } - return value; -} - -int parse_port(std::string_view name, const std::string &text) { - std::size_t consumed{}; - long value{}; - try { - value = std::stol(text, &consumed); - } catch (const std::exception &) { - throw UsageError(std::string{name} + " must be in the range 1..65535"); - } - if (consumed != text.size() || value < 1 || value > 65535) { - throw UsageError(std::string{name} + " must be in the range 1..65535"); - } - return static_cast(value); -} - -std::string take_value(const std::vector &arguments, std::size_t &index) { - if (++index >= arguments.size()) { - throw UsageError(arguments[index - 1] + " requires a value"); - } - return arguments[index]; -} - -void validate_utf8(std::string_view value) { - const auto byte_at = [&value](const std::size_t index) { - return static_cast(value[index]); - }; - const auto is_continuation = [&byte_at](const std::size_t index) { - return (byte_at(index) & 0xc0U) == 0x80U; - }; - const auto invalid = [] { throw std::runtime_error("cannot serialize invalid UTF-8 string"); }; - - for (std::size_t index = 0; index < value.size();) { - const auto first = byte_at(index); - if (first <= 0x7fU) { - ++index; - continue; - } - if (first >= 0xc2U && first <= 0xdfU) { - if (index + 1 >= value.size() || !is_continuation(index + 1)) { - invalid(); - } - index += 2; - continue; - } - if (first >= 0xe0U && first <= 0xefU) { - if (index + 2 >= value.size() || !is_continuation(index + 1) || !is_continuation(index + 2)) { - invalid(); - } - const auto second = byte_at(index + 1); - if ((first == 0xe0U && second < 0xa0U) || (first == 0xedU && second > 0x9fU)) { - invalid(); - } - index += 3; - continue; - } - if (first >= 0xf0U && first <= 0xf4U) { - if (index + 3 >= value.size() || !is_continuation(index + 1) || !is_continuation(index + 2) || - !is_continuation(index + 3)) { - invalid(); - } - const auto second = byte_at(index + 1); - if ((first == 0xf0U && second < 0x90U) || (first == 0xf4U && second > 0x8fU)) { - invalid(); - } - index += 4; - continue; - } - invalid(); - } -} - -std::string json_escape(std::string_view value) { - validate_utf8(value); - std::ostringstream escaped; - for (const unsigned char character : value) { - switch (character) { - case '"': - escaped << "\\\""; - break; - case '\\': - escaped << "\\\\"; - break; - case '\b': - escaped << "\\b"; - break; - case '\f': - escaped << "\\f"; - break; - case '\n': - escaped << "\\n"; - break; - case '\r': - escaped << "\\r"; - break; - case '\t': - escaped << "\\t"; - break; - default: - if (character < 0x20U) { - escaped << "\\u" << std::hex << std::setw(4) << std::setfill('0') - << static_cast(character) << std::dec; - } else { - escaped << static_cast(character); - } - } - } - return escaped.str(); -} - -std::string source_name(const CalibrationSource source) { - return source == CalibrationSource::Sensor ? "sensor" : "override"; -} - -void use_json_locale(std::ostringstream &output) { output.imbue(std::locale::classic()); } - -void append_json_number(std::ostringstream &output, const double value) { - if (!std::isfinite(value)) { - throw std::runtime_error("cannot serialize non-finite JSON number"); - } - use_json_locale(output); - output << std::setprecision(std::numeric_limits::max_digits10) << value; -} - -void append_configuration_json(std::ostringstream &output, const SensorConfiguration &configuration, - const Config &config) { - output << R"("product":")" << json_escape(configuration.product_name) << R"(","endpoint":")" - << json_escape(config.sensor_host) << ':' << config.rdt_port - << R"(","configuration_source":")" << source_name(configuration.source) - << R"(","force_unit":")" << to_string(configuration.calibration.force_unit) - << R"(","torque_unit":")" << to_string(configuration.calibration.torque_unit) - << R"(","counts_per_force_unit":)"; - append_json_number(output, configuration.calibration.counts_per_force_unit); - output << ",\"counts_per_torque_unit\":"; - append_json_number(output, configuration.calibration.counts_per_torque_unit); - output << ",\"configuration_revision\":" << configuration.revision; -} - -template -void append_array(std::ostringstream &output, const std::array &values) { - output << '['; - for (std::size_t index = 0; index < values.size(); ++index) { - if (index != 0) { - output << ','; - } - append_json_number(output, values[index]); - } - output << ']'; -} - -std::string serialize_json(const SensorConfiguration &configuration, const Config &config) { - std::ostringstream output; - use_json_locale(output); - output << '{'; - append_configuration_json(output, configuration, config); - output << "}\n"; - return output.str(); -} - -std::string serialize_json(const Summary &summary, const Config &config) { - const auto &health = summary.health; - std::ostringstream output; - use_json_locale(output); - output << '{'; - append_configuration_json(output, summary.configuration, config); - if (summary.requested_duration_s) { - output << ",\"requested_duration_s\":"; - append_json_number(output, *summary.requested_duration_s); - } - output << ",\"elapsed_s\":"; - append_json_number(output, summary.elapsed_s); - output << ",\"sample_count\":" << health.delivered_count - << ",\"received_count\":" << health.received_count - << ",\"delivered_count\":" << health.delivered_count - << ",\"rate_limited_count\":" << health.rate_limited_count << ",\"receive_rate_hz\":"; - append_json_number(output, health.receive_rate_hz); - output << ",\"delivery_rate_hz\":"; - append_json_number(output, health.delivery_rate_hz); - output << ",\"lost_count\":" << health.lost_count - << ",\"duplicate_count\":" << health.duplicate_count - << ",\"out_of_order_count\":" << health.out_of_order_count - << ",\"malformed_count\":" << health.malformed_count - << ",\"reconnect_count\":" << health.reconnect_count - << ",\"timeout_count\":" << health.timeout_count - << ",\"warning_count\":" << health.warning_count - << ",\"device_error_count\":" << health.device_error_count - << ",\"device_status\":" << summary.sample.status << R"(,"fault_code":")" - << to_string(health.fault_code) << R"(","last_rdt_sequence":)" - << summary.sample.rdt_sequence << ",\"last_ft_sequence\":" << summary.sample.ft_sequence - << ",\"last_force\":"; - append_array(output, summary.sample.force); - output << ",\"last_torque\":"; - append_array(output, summary.sample.torque); - if (summary.bias_applied) { - output << ",\"bias_applied\":true"; - } - output << "}\n"; - return output.str(); -} - -std::string serialize_human(const SensorConfiguration &configuration, const Config &config) { - std::ostringstream output; - output << "Product: " << configuration.product_name << '\n' - << "Endpoint: " << config.sensor_host << ':' << config.rdt_port << '\n' - << "Calibration: " << source_name(configuration.source) << ", " - << configuration.calibration.counts_per_force_unit << " counts/" - << to_string(configuration.calibration.force_unit) << ", " - << configuration.calibration.counts_per_torque_unit << " counts/" - << to_string(configuration.calibration.torque_unit) << '\n'; - return output.str(); -} - -std::string serialize_human(const Summary &summary, const Config &config) { - std::ostringstream output; - output << serialize_human(summary.configuration, config) - << "Samples: " << summary.health.delivered_count << " delivered, " - << summary.health.lost_count << " lost, " << summary.health.warning_count << " warnings, " - << summary.health.device_error_count << " errors\n" - << "Receive rate [Hz]: " << summary.health.receive_rate_hz << '\n' - << "Lost records: " << summary.health.lost_count << '\n' - << "Device status: " << summary.sample.status << '\n' - << "Reconnects: " << summary.health.reconnect_count << '\n' - << "Force [" << to_string(summary.sample.force_unit) << "]: " << summary.sample.force[0] - << ' ' << summary.sample.force[1] << ' ' << summary.sample.force[2] << "\nTorque [" - << to_string(summary.sample.torque_unit) << "]: " << summary.sample.torque[0] << ' ' - << summary.sample.torque[1] << ' ' << summary.sample.torque[2] << '\n'; - if (summary.bias_applied) { - output << "Bias applied: yes\n"; - } - return output.str(); -} - -void write_atomic(const std::string &path, const std::string &contents) { - if (path.empty()) { - throw std::invalid_argument("output path must not be empty"); - } - std::string temporary = path + ".tmp.XXXXXX"; - std::vector name(temporary.begin(), temporary.end()); - name.push_back('\0'); - const int descriptor = ::mkstemp(name.data()); - if (descriptor < 0) { - throw std::runtime_error("cannot create temporary output file: " + - std::string{std::strerror(errno)}); - } - const std::string temporary_path{name.data()}; - FILE *file = ::fdopen(descriptor, "wb"); - if (file == nullptr) { - const auto message = std::string{std::strerror(errno)}; - ::close(descriptor); - ::unlink(temporary_path.c_str()); - throw std::runtime_error("cannot open temporary output file: " + message); - } - - bool success = std::fwrite(contents.data(), 1, contents.size(), file) == contents.size(); - success = std::fflush(file) == 0 && success; - success = std::fclose(file) == 0 && success; - if (!success) { - ::unlink(temporary_path.c_str()); - throw std::runtime_error("cannot write output file"); - } - if (std::rename(temporary_path.c_str(), path.c_str()) != 0) { - const auto message = std::string{std::strerror(errno)}; - ::unlink(temporary_path.c_str()); - throw std::runtime_error("cannot replace output file: " + message); - } -} - -bool was_interrupted(const volatile std::sig_atomic_t *interrupted) { - return interrupted != nullptr && *interrupted != 0; -} - -void emit(const Options &options, std::ostream &output, const std::string &contents) { - if (options.output_path.empty()) { - output << contents; - } else { - write_atomic(options.output_path, contents); - } -} - -int result_code(const HealthSnapshot &health) { - if (health.warning_count > 0 || health.device_error_count > 0) { - return 1; - } - return health.fault_code == FaultCode::None ? 0 : 2; -} - -int run_info(const Options &options, std::ostream &output, - const volatile std::sig_atomic_t *interrupted) { - DiscoveryOptions discovery; - discovery.sensor_host = options.config.sensor_host; - discovery.http_port = options.config.http_port; - discovery.connect_timeout = options.config.configuration_connect_timeout; - discovery.total_timeout = options.config.configuration_timeout; - const auto configuration = discover_sensor(discovery); - if (was_interrupted(interrupted)) { - return 130; - } - emit(options, output, - options.json ? serialize_json(configuration, options.config) - : serialize_human(configuration, options.config)); - return 0; -} - -int run_monitor(const Options &options, std::ostream &output, std::ostream &errors, - const volatile std::sig_atomic_t *interrupted) { - auto config = options.config; - config.deliver_samples_with_error_status = true; - Client client{config}; - const auto start = std::chrono::steady_clock::now(); - client.start([](const Sample &) {}); - const auto deadline = start + options.duration; - while (std::chrono::steady_clock::now() < deadline && !client.faulted() && - !was_interrupted(interrupted)) { - std::this_thread::sleep_for(std::chrono::milliseconds{2}); - } - const bool signal_received = was_interrupted(interrupted); - client.stop(); - if (signal_received) { - return 130; - } - - const auto sample = client.latest_sample(); - const auto health = client.health(); - if (!sample || !health.sensor_configuration || health.delivered_count == 0) { - errors << "netft: no sample received"; - if (!health.last_error.empty()) { - errors << ": " << health.last_error; - } - errors << '\n'; - return 2; - } - Summary summary{*health.sensor_configuration, - health, - *sample, - std::chrono::duration(std::chrono::steady_clock::now() - start).count(), - options.duration.count(), - false}; - emit(options, output, - options.json ? serialize_json(summary, options.config) - : serialize_human(summary, options.config)); - return result_code(health); -} - -int run_bias(const Options &options, std::ostream &output, std::ostream &errors, - const volatile std::sig_atomic_t *interrupted) { - Client client{options.config}; - const auto start = std::chrono::steady_clock::now(); - client.start([](const Sample &) {}); - while (!client.wait_for_first_sample(std::chrono::milliseconds{20}) && !client.faulted() && - !was_interrupted(interrupted) && - std::chrono::steady_clock::now() - start < std::chrono::seconds{2}) { - } - if (was_interrupted(interrupted)) { - client.stop(); - return 130; - } - auto before = client.latest_sample(); - auto before_health = client.health(); - if (!before || before_health.delivered_count == 0 || client.faulted()) { - client.stop(); - errors << "netft: no sample received before bias\n"; - return 2; - } - - try { - client.bias(); - } catch (const std::exception &error) { - client.stop(); - errors << "netft: bias failed: " << error.what() << '\n'; - return 2; - } - - std::optional after; - HealthSnapshot after_health; - const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds{2}; - while (std::chrono::steady_clock::now() < deadline && !client.faulted() && - !was_interrupted(interrupted)) { - after = client.latest_sample(); - after_health = client.health(); - if (after && after->rdt_sequence != before->rdt_sequence && - after_health.delivered_count > before_health.delivered_count) { - break; - } - std::this_thread::sleep_for(std::chrono::milliseconds{2}); - } - const bool signal_received = was_interrupted(interrupted); - client.stop(); - if (signal_received) { - return 130; - } - if (!after || !after_health.sensor_configuration || after->rdt_sequence == before->rdt_sequence || - after_health.delivered_count <= before_health.delivered_count) { - errors << "netft: no post-bias sample received\n"; - return 2; - } - - Summary summary{*after_health.sensor_configuration, - after_health, - *after, - std::chrono::duration(std::chrono::steady_clock::now() - start).count(), - std::nullopt, - true}; - emit(options, output, - options.json ? serialize_json(summary, options.config) - : serialize_human(summary, options.config)); - return result_code(after_health); -} - -} // namespace - -Options parse_options(const std::vector &arguments) { - Options options; - if (arguments.size() == 1 && arguments.front() == "--help") { - options.help = true; - return options; - } - if (arguments.empty()) { - throw UsageError("missing command"); - } - if (arguments.front() == "info") { - options.command = Command::Info; - } else if (arguments.front() == "monitor") { - options.command = Command::Monitor; - } else if (arguments.front() == "bias") { - options.command = Command::Bias; - } else { - throw UsageError("unknown command: " + arguments.front()); - } - - std::optional counts_per_force; - std::optional counts_per_torque; - std::optional force_unit; - std::optional torque_unit; - bool duration_supplied = false; - for (std::size_t index = 1; index < arguments.size(); ++index) { - const auto &argument = arguments[index]; - if (argument == "--help") { - options.help = true; - } else if (argument == "--json") { - options.json = true; - } else if (argument == "--host") { - options.config.sensor_host = take_value(arguments, index); - } else if (argument == "--rdt-port") { - options.config.rdt_port = parse_port("--rdt-port", take_value(arguments, index)); - } else if (argument == "--http-port") { - options.config.http_port = parse_port("--http-port", take_value(arguments, index)); - } else if (argument == "--duration") { - duration_supplied = true; - options.duration = std::chrono::duration{ - parse_positive_double("--duration", take_value(arguments, index))}; - } else if (argument == "--output") { - options.output_path = take_value(arguments, index); - if (options.output_path.empty()) { - throw UsageError("--output must not be empty"); - } - } else if (argument == "--counts-per-force-unit") { - counts_per_force = - parse_positive_double("--counts-per-force-unit", take_value(arguments, index)); - } else if (argument == "--counts-per-torque-unit") { - counts_per_torque = - parse_positive_double("--counts-per-torque-unit", take_value(arguments, index)); - } else if (argument == "--force-unit") { - force_unit = force_unit_from_string(take_value(arguments, index)); - if (!force_unit || *force_unit == ForceUnit::Unknown) { - throw UsageError("--force-unit is not supported"); - } - } else if (argument == "--torque-unit") { - torque_unit = torque_unit_from_string(take_value(arguments, index)); - if (!torque_unit || *torque_unit == TorqueUnit::Unknown) { - throw UsageError("--torque-unit is not supported"); - } - } else { - throw UsageError("unknown option: " + argument); - } - } - - const unsigned override_count = static_cast(counts_per_force.has_value()) + - static_cast(counts_per_torque.has_value()) + - static_cast(force_unit.has_value()) + - static_cast(torque_unit.has_value()); - if (override_count != 0 && override_count != 4) { - throw UsageError("manual calibration requires all four calibration options"); - } - if (override_count == 4) { - if (options.command == Command::Info) { - throw UsageError("manual calibration is not valid for info"); - } - options.config.calibration_override = - Calibration{*counts_per_force, *counts_per_torque, *force_unit, *torque_unit}; - } - if (duration_supplied && options.command != Command::Monitor) { - throw UsageError("--duration is only valid for monitor"); - } - try { - validate(options.config); - } catch (const std::exception &error) { - throw UsageError(error.what()); - } - return options; -} - -std::string usage() { - return "Usage: netft [options]\n" - "\n" - "Commands:\n" - " info Discover and print sensor calibration\n" - " monitor Monitor the latest sample (default: 5 seconds)\n" - " bias Apply software bias after receiving a sample\n" - "\n" - "Options:\n" - " --host HOST Sensor host\n" - " --rdt-port PORT RDT UDP port\n" - " --http-port PORT Configuration HTTP port\n" - " --duration SECONDS Monitor duration\n" - " --json Emit JSON\n" - " --output PATH Atomically write output to PATH\n" - " --counts-per-force-unit VALUE Manual force scale\n" - " --counts-per-torque-unit VALUE Manual torque scale\n" - " --force-unit UNIT Manual force unit\n" - " --torque-unit UNIT Manual torque unit\n" - " --help Show this help\n"; -} - -int run(const Options &options, std::ostream &output, std::ostream &errors, - const volatile std::sig_atomic_t *interrupted) { - try { - switch (options.command) { - case Command::Info: - return run_info(options, output, interrupted); - case Command::Monitor: - return run_monitor(options, output, errors, interrupted); - case Command::Bias: - return run_bias(options, output, errors, interrupted); - } - } catch (const std::exception &error) { - if (was_interrupted(interrupted)) { - return 130; - } - errors << "netft: " << error.what() << '\n'; - return 2; - } - errors << "netft: invalid command\n"; - return 2; -} - -} // namespace netft::cli diff --git a/core/app/cli.hpp b/core/app/cli.hpp deleted file mode 100644 index 483bc88..0000000 --- a/core/app/cli.hpp +++ /dev/null @@ -1,36 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include -#include - -#include "netft/types.hpp" - -namespace netft::cli { - -enum class Command { Info, Monitor, Bias }; - -class UsageError : public std::invalid_argument { -public: - using std::invalid_argument::invalid_argument; -}; - -struct Options { - Command command{Command::Info}; - Config config; - std::chrono::duration duration{5.0}; - bool json{false}; - bool help{false}; - std::string output_path; -}; - -Options parse_options(const std::vector &arguments); -std::string usage(); - -int run(const Options &options, std::ostream &output, std::ostream &errors, - const volatile std::sig_atomic_t *interrupted = nullptr); - -} // namespace netft::cli diff --git a/core/app/main.cpp b/core/app/main.cpp deleted file mode 100644 index 3ae4d36..0000000 --- a/core/app/main.cpp +++ /dev/null @@ -1,39 +0,0 @@ -#include -#include -#include -#include - -#include "cli.hpp" - -namespace { - -volatile std::sig_atomic_t interrupted = 0; - -extern "C" void handle_sigint(int /*unused*/) { interrupted = 1; } - -} // namespace - -int main(int argc, char **argv) { - std::signal(SIGINT, handle_sigint); - - std::vector arguments; - arguments.reserve(static_cast(argc > 0 ? argc - 1 : 0)); - for (int index = 1; index < argc; ++index) { - arguments.emplace_back(argv[index]); - } - - try { - const auto options = netft::cli::parse_options(arguments); - if (options.help) { - std::cout << netft::cli::usage(); - return 0; - } - return netft::cli::run(options, std::cout, std::cerr, &interrupted); - } catch (const netft::cli::UsageError &error) { - std::cerr << "netft: " << error.what() << "\n\n" << netft::cli::usage(); - return 2; - } catch (const std::exception &error) { - std::cerr << "netft: " << error.what() << '\n'; - return 2; - } -} diff --git a/core/src/detail/client_impl.cpp b/core/src/detail/client_impl.cpp index 1314d43..5ebc20c 100644 --- a/core/src/detail/client_impl.cpp +++ b/core/src/detail/client_impl.cpp @@ -594,12 +594,12 @@ Client::Impl::handle_record(const detail::RawRecord &record, outcome = SessionResult::FtBackward; } - const bool drop_reconnect_ft_discontinuity = - config_.recovery_policy == RecoveryPolicy::Reconnect && - (ft.kind == detail::FtSequenceKind::Stall || ft.kind == detail::FtSequenceKind::Backward); - if (drop_reconnect_ft_discontinuity) { + const bool ft_discontinuity = + ft.kind == detail::FtSequenceKind::Stall || ft.kind == detail::FtSequenceKind::Backward; + if (ft_discontinuity) { deliver = false; - if (outcome == SessionResult::FtStall || outcome == SessionResult::FtBackward) { + if (config_.recovery_policy == RecoveryPolicy::Reconnect && + (outcome == SessionResult::FtStall || outcome == SessionResult::FtBackward)) { outcome.reset(); } } diff --git a/tests/integration/test_recovery.py b/tests/integration/test_recovery.py index 1a39c37..efa2adb 100644 --- a/tests/integration/test_recovery.py +++ b/tests/integration/test_recovery.py @@ -153,18 +153,15 @@ def test_ft_sequence_stall_reports_progress_counter_and_fault( rdt_sequence=71, ft_sequence=80, ) - stalled = next(client.samples(timeout=1.0)) with pytest.raises(SensorFaultError) as captured: next(client.samples(timeout=1.0)) assert first.ft_sequence == 80 - assert stalled.ft_sequence == 80 - assert stalled.rdt_sequence == 71 assert captured.value.fault_code is FaultCode.FT_STALL assert captured.value.health.ft_stall_count == 1 assert captured.value.health.last_ft_progress == "stall" assert captured.value.health.received_count == 2 - assert captured.value.health.delivered_count == 2 + assert captured.value.health.delivered_count == 1 assert BIAS not in fake_sensor.commands diff --git a/tests/quality/test_sync_core.py b/tests/quality/test_sync_core.py new file mode 100644 index 0000000..70c11fd --- /dev/null +++ b/tests/quality/test_sync_core.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import subprocess +from pathlib import Path + +from tools import sync_core + + +def git(repository: Path, *arguments: str) -> str: + return subprocess.run( + ["git", *arguments], + cwd=repository, + check=True, + capture_output=True, + text=True, + ).stdout.strip() + + +def test_sync_removes_files_outside_the_selected_snapshot( + tmp_path: Path, +) -> None: + source = tmp_path / "source" + source.mkdir() + git(source, "init") + git(source, "config", "user.name", "Test User") + git(source, "config", "user.email", "test@example.com") + for relative_path in sync_core.SELECTED: + path = source / relative_path + if relative_path in {"CMakeLists.txt", "LICENSE"}: + path.write_text(f"{relative_path}\n", encoding="utf-8") + else: + path.mkdir() + (path / "kept.txt").write_text(f"{relative_path}\n", encoding="utf-8") + git(source, "add", ".") + git(source, "commit", "-m", "fixture") + git(source, "tag", "v-test") + + destination = tmp_path / "core" + destination.mkdir() + stale = destination / "removed-upstream-path.txt" + stale.write_text("stale\n", encoding="utf-8") + + sync_core.sync(source, destination, "v-test") + + assert not stale.exists() + sync_core.verify(destination) diff --git a/tools/sync_core.py b/tools/sync_core.py index f28ed18..17d6adb 100644 --- a/tools/sync_core.py +++ b/tools/sync_core.py @@ -5,9 +5,10 @@ import hashlib import shutil import subprocess +import tempfile from pathlib import Path -SELECTED = ("CMakeLists.txt", "LICENSE", "app", "cmake", "include", "src") +SELECTED = ("CMakeLists.txt", "LICENSE", "cmake", "include", "src") def git(source: Path, *args: str) -> str: @@ -52,33 +53,47 @@ def sync(source: Path, destination: Path, tag: str) -> None: if git(source, "status", "--short"): raise SystemExit(f"{source} is not clean") - destination.mkdir(parents=True, exist_ok=True) - for name in SELECTED: - target = destination / name - if target.is_dir(): - shutil.rmtree(target) - elif target.exists(): - target.unlink() - origin = source / name - if origin.is_dir(): - shutil.copytree(origin, target) - else: - shutil.copy2(origin, target) - - (destination / "UPSTREAM").write_text( - "\n".join( - ( - "repository=https://github.com/netft/netft-cpp", - f"tag={tag}", - f"commit={commit}", - f"paths={','.join(SELECTED)}", + destination.parent.mkdir(parents=True, exist_ok=True) + transaction = Path( + tempfile.mkdtemp(prefix=f".{destination.name}-sync-", dir=destination.parent) + ) + staging = transaction / "snapshot" + previous = transaction / "previous" + staging.mkdir() + try: + for name in SELECTED: + origin = source / name + target = staging / name + if origin.is_dir(): + shutil.copytree(origin, target) + else: + shutil.copy2(origin, target) + + (staging / "UPSTREAM").write_text( + "\n".join( + ( + "repository=https://github.com/netft/netft-cpp", + f"tag={tag}", + f"commit={commit}", + f"paths={','.join(SELECTED)}", + ) ) + + "\n", + encoding="utf-8", ) - + "\n", - encoding="utf-8", - ) - write_manifest(destination) - verify(destination) + write_manifest(staging) + verify(staging) + + if destination.exists(): + destination.rename(previous) + try: + staging.rename(destination) + except BaseException: + if previous.exists(): + previous.rename(destination) + raise + finally: + shutil.rmtree(transaction, ignore_errors=True) def main() -> None: