diff --git a/.gitattributes b/.gitattributes index 86fb1a8..9c911b4 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,3 @@ core/ADAPTATIONS.patch text eol=lf +# Preserve the byte inventory when Windows enables core.autocrlf. +/core/netft/** -text diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8adcde3..a9e0f31 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,12 @@ on: push: branches: [main] pull_request: + workflow_dispatch: + inputs: + windows_only: + description: Verify only the Windows native target after a platform-specific fix + type: boolean + default: false permissions: contents: read @@ -17,23 +23,17 @@ jobs: strategy: fail-fast: false matrix: - include: - - target: linux-x86_64 - runner: ubuntu-24.04 - - target: linux-arm64 - runner: ubuntu-24.04-arm - - target: macos-x86_64 - runner: macos-15-intel - - target: macos-arm64 - runner: macos-15 - - target: windows-x86_64 - runner: windows-2025 + include: ${{ fromJSON(inputs.windows_only && '[{"target":"windows-x86_64","runner":"windows-2025"}]' || '[{"target":"linux-x86_64","runner":"ubuntu-24.04"},{"target":"linux-arm64","runner":"ubuntu-24.04-arm"},{"target":"macos-x86_64","runner":"macos-15-intel"},{"target":"macos-arm64","runner":"macos-15"},{"target":"windows-x86_64","runner":"windows-2025"}]') }} runs-on: ${{ matrix.runner }} + timeout-minutes: 30 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: persist-credentials: false + - name: Verify controlled core snapshot + run: python tools/sync_core.py verify + - uses: prefix-dev/setup-pixi@a09b6247153796b190642a2b53fac4241043cf6f if: startsWith(matrix.target, 'linux-') with: @@ -44,7 +44,6 @@ jobs: - if: startsWith(matrix.target, 'linux-') run: | - pixi run ctest --test-dir build --output-on-failure pixi run ./build/netft --version - if: startsWith(matrix.target, 'macos-') diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1efdffd..d037b0d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,12 +28,11 @@ jobs: shell: bash run: | set -euo pipefail - if [[ ! "${GITHUB_REF_NAME}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + if [[ ! "${GITHUB_REF_NAME}" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then echo "release tags must use vMAJOR.MINOR.PATCH" >&2 exit 1 fi version="${GITHUB_REF_NAME#v}" - test "${version}" = "0.2.1" test "$(git cat-file -t "${GITHUB_REF_NAME}")" = tag git fetch --no-tags origin \ diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f322b3..af8ad85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ All notable changes to this project are documented in this file. ## Unreleased +### Added + +- Provide a typed schema-version-1 `--schema` interface with configured source identity. +- Write a shared versioned summary beside completed recordings and include sample calibration revisions (CSV appended column, NDJSON additive field). + +### Fixed + +- Integrate the pinned unpublished SDK candidate's calibration and checked-time protections. +- Create recording partial files exclusively and publish without replacing an existing destination or link. +- Fail recordings with exit 8 on zero samples, terminal client faults, or no accepted samples within `--timeout`; retain `.partial`. Recoveries must resume data within that timeout. Ctrl-C with accepted data still drains and finalizes, returning 130. +- Derive release versions from project metadata and reject noncanonical stable version identifiers. Pin build actions to reviewed commits. + ## 0.2.1 - 2026-08-14 ### Fixed diff --git a/CMakeLists.txt b/CMakeLists.txt index 27477f4..dbb62d0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -142,12 +142,36 @@ target_link_libraries(netft_cli_commands PUBLIC netft_enable_warnings(netft_cli_commands) netft_enable_sanitizers(netft_cli_commands) +# Bind machine interface provenance to the configured checkout. Archives without +# Git remain buildable but advertise unknown provenance and cannot regenerate docs. +set(NETFT_CLI_SOURCE_COMMIT "unknown") +set(NETFT_CLI_SOURCE_DIRTY 1) +find_package(Git QUIET) +if(Git_FOUND) + execute_process(COMMAND "${GIT_EXECUTABLE}" rev-parse HEAD + WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}" + RESULT_VARIABLE _identity_result OUTPUT_VARIABLE _identity OUTPUT_STRIP_TRAILING_WHITESPACE ERROR_QUIET) + execute_process(COMMAND "${GIT_EXECUTABLE}" status --porcelain + WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}" + RESULT_VARIABLE _status_result OUTPUT_VARIABLE _status OUTPUT_STRIP_TRAILING_WHITESPACE ERROR_QUIET) + if(_identity_result EQUAL 0 AND _status_result EQUAL 0 AND _identity MATCHES "^[0-9a-f]+$") + set(NETFT_CLI_SOURCE_COMMIT "${_identity}") + if(_status STREQUAL "") + set(NETFT_CLI_SOURCE_DIRTY 0) + endif() + endif() + set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS + "${CMAKE_CURRENT_SOURCE_DIR}/.git/HEAD" "${CMAKE_CURRENT_SOURCE_DIR}/.git/index") +endif() + add_library(netft_cli_application src/application.cpp) target_compile_features(netft_cli_application PUBLIC cxx_std_17) target_include_directories(netft_cli_application PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/src") target_link_libraries(netft_cli_application PUBLIC netft_cli_commands) target_compile_definitions(netft_cli_application PRIVATE NETFT_CLI_VERSION="${PROJECT_VERSION}" + NETFT_CLI_SOURCE_COMMIT="${NETFT_CLI_SOURCE_COMMIT}" + NETFT_CLI_SOURCE_DIRTY=${NETFT_CLI_SOURCE_DIRTY} ) netft_enable_warnings(netft_cli_application) netft_enable_sanitizers(netft_cli_application) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2f99abb..72bcc1b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -68,12 +68,12 @@ Never commit a laboratory sensor address, credentials, recordings, or private ne ## Core synchronization -The private core snapshot comes from [netft-cpp](https://github.com/netft/netft-cpp). The current synchronization command accepts only `netft-cpp` tag `v0.3.3` at commit `3259b8576b16fb150b51e9ef9090366c5e6efcba`; it applies the checksum-pinned `core/ADAPTATIONS.patch` to reproduce the existing adapted snapshot. +The private core snapshot comes from [netft-cpp](https://github.com/netft/netft-cpp). The current synchronization command accepts the unpublished `netft-cpp` candidate commit `91f012c5d6f9b63902765ccbec3437cb286c15e1`; it applies the checksum-pinned `core/ADAPTATIONS.patch` to reproduce the existing adapted snapshot. To reproduce the current snapshot from a clean checkout at that exact tag: ```bash -python tools/sync_core.py sync --source --tag v0.3.3 +python tools/sync_core.py sync --source --commit 91f012c5d6f9b63902765ccbec3437cb286c15e1 python tools/sync_core.py verify ``` @@ -87,7 +87,7 @@ can send one bias command before starting that second fail-stop stream. It does not change protocol parsing, calibration, sample conversion, health, or recovery semantics. -A core upgrade is a maintainer-controlled change. The maintainer updates the pinned tag and commit in `tools/sync_core.py` and the corresponding tests, rebuilds and audits `core/ADAPTATIONS.patch` against that exact upstream base, and updates its pinned SHA-256. The synchronization command then regenerates `core/netft`, `core/UPSTREAM`, and `core/MANIFEST.sha256`; notices and snapshot tests are updated and the complete snapshot verification is run before the files are committed together. +A core upgrade is a maintainer-controlled change. The maintainer updates the pinned publication marker and commit in `tools/sync_core.py` and the corresponding tests, rebuilds and audits `core/ADAPTATIONS.patch` against that exact upstream base, and updates its pinned SHA-256. The synchronization command then regenerates `core/netft`, `core/UPSTREAM`, and `core/MANIFEST.sha256`; notices and snapshot tests are updated and the complete snapshot verification is run before the files are committed together. ## Pull requests diff --git a/README.md b/README.md index 1f31b1d..463031a 100644 --- a/README.md +++ b/README.md @@ -88,3 +88,31 @@ through [SECURITY.md](SECURITY.md). This project is licensed under the [Apache License 2.0](LICENSE). Release archives include the notices for the synchronized `netft-cpp` core and libcurl. + +### Recording integrity (unreleased candidate) + +`record` never replaces existing destination or partial paths, including dangling links. The first sample and subsequent accepted samples must arrive within `--timeout`. A zero-sample session, terminal fault, queue overflow, writer failure or data timeout returns recording error 8 and retains `.partial`; inspect partial data before using it. Recoverable interruptions can resume within the timeout. `--count` can wait for the selected sample count while data keeps arriving; combine it with `--duration` for a total time limit. Ctrl-C drains nonempty recordings, finalizes them and returns 130. Publication protects file names but does not promise power-loss durability. + +## Recording metadata (unreleased candidate) + +After successful data finalization, the candidate writes `.metadata.json` using schema version 1 and kind `netft-recording`. CLI and Viewer share these meanings: + +| Field | Meaning | +| --- | --- | +| `producer`, `result` | `netft-cli` or `netft-viewer`; `complete` or CLI `interrupted` after a successful nonempty drain. | +| `accepted_samples`, `written_samples` | Samples accepted into the recording queue and rows successfully written. These are not power-loss durability guarantees. | +| `sample_span_seconds` | Nonnegative host receive-time span between first and last written sample; zero for fewer than two samples. | +| `configuration_revisions` | Unique revision identifiers in written rows, scoped to the producer session. They do not contain full calibration snapshots. | +| `force_units`, `torque_units` | Unique native unit symbols observed in written rows. | +| `recorded_rdt_gaps` | Sum of forward sequence gaps between written rows, using unsigned wraparound and ignoring backward jumps. Pauses, rate filtering and reconnects can contribute; this is not the SDK packet-loss counter. | +| `pause_count` | Accepted Viewer pauses; CLI has no pause operation and reports zero. | +| `reconnect_count` | CLI SDK reconnect counter for the run; `null` for Viewer because its recorder does not own connection health. | +| `error` | `null` on successful finalization. Failure details remain in the CLI error or Viewer recorder state. | + +Partial or failed captures do not receive a completed summary. Metadata creation and promotion follow the data file's overwrite policy. The two files are finalized separately: a metadata failure reports an error and preserves the completed data file, and may leave a metadata partial for recovery. Keep both files when archiving a successful capture. + +The CLI candidate appends `configuration_revision` to CSV without moving existing columns and adds it inside the NDJSON sample object. Viewer already records revisions in each CSV row. Existing published releases retain their documented formats until these changes are released. + +## Machine interface (unreleased candidate) + +`netft --schema` prints schema-version-1 JSON without opening a sensor connection. Commands refer to option identifiers, typed positionals and exit statuses from the same command definitions used by parsing and help. `version`, `sourceCommit` and `sourceDirty` identify the configured build. Archives with no Git metadata report unknown provenance; documentation generation requires a clean checkout and matching executable. Consumers must reject unsupported schema versions and should tolerate additive fields. diff --git a/core/ADAPTATIONS.patch b/core/ADAPTATIONS.patch index 61f319f..92d6ea5 100644 --- a/core/ADAPTATIONS.patch +++ b/core/ADAPTATIONS.patch @@ -1,21 +1,21 @@ diff --git a/include/netft/types.hpp b/include/netft/types.hpp -index feb6365..d26123c 100644 +index feb6365..7eb2cbb 100755 --- a/include/netft/types.hpp +++ b/include/netft/types.hpp @@ -25,0 +26 @@ enum class RecoveryPolicy { Reconnect, FailStop }; +enum class StartupMode { Stream, BiasAndStream }; -@@ -64,0 +66,2 @@ struct Config { +@@ -65,0 +67,2 @@ struct Config { + StartupMode startup_mode{StartupMode::Stream}; + bool retain_bound_socket_until_destruction{false}; diff --git a/src/detail/client_impl.cpp b/src/detail/client_impl.cpp -index 5ebc20c..63b03d4 100644 +index c8f7167..60d29d3 100755 --- a/src/detail/client_impl.cpp +++ b/src/detail/client_impl.cpp -@@ -450,0 +451,3 @@ Client::Impl::SessionOutcome Client::Impl::receive_session() { +@@ -455,0 +456,3 @@ Client::Impl::SessionOutcome Client::Impl::receive_session() { + if (config_.startup_mode == StartupMode::BiasAndStream) { + transport_.send(detail::encode_request(detail::Command::SetSoftwareBias)); + } -@@ -710 +713,3 @@ void Client::Impl::close_session() noexcept { +@@ -720 +723,3 @@ void Client::Impl::close_session() noexcept { - transport_.close(); + if (!config_.retain_bound_socket_until_destruction) { + transport_.close(); diff --git a/core/MANIFEST.sha256 b/core/MANIFEST.sha256 index 25471b5..160febc 100644 --- a/core/MANIFEST.sha256 +++ b/core/MANIFEST.sha256 @@ -3,9 +3,9 @@ e4a52fe7c7e13de95a29bdbd688d295a4a8ddc1805276274b0c14d4092f7253d include/netft/ 4f7fb383da5456fd8477e1981f4ff3eba9b82ec3a98f3c395e7d482faf75c112 include/netft/discovery.hpp 1beed1a22ad16aa801e0cceb0950548a32d263e01f260a86e72ec58d8f8d1a74 include/netft/export.hpp 912892bcb8bb5f95392c9c74983d91aab103b7adc5735505b0ac97e1012893b1 include/netft/status.hpp -6b50fd5f8955f72cdbbd8f8dc5edd12fd4595d56c5703a8ebb93a8d7ceeb2b9e include/netft/types.hpp +c0f0c2eee62556c366a82e2252e938f04ab8ec72268069c36ee157d97a212eb3 include/netft/types.hpp 7199f02fa0fd88059323f507edbf073b2a4827c692d1fd824893cbad8ea9215b src/client.cpp -08926a58b976b17fdb88b7a2140480bc77fc78aa4c78fe8719c7232687d9f22c src/detail/client_impl.cpp +a6e53b4347819bd5055ae0da80e02d21a6e6470b0a911b2c71db93330dee27a1 src/detail/client_impl.cpp b202cc6e250866e681d16b480911f77d865d9187300111f8482bc4a8b0424b34 src/detail/client_impl.hpp ad8744385642693bb4666c428a5dbd0d6eff350b0079a6c9ff720512e89b6a53 src/detail/fault_latch.cpp be488c557e17044948cd62e6de07bf54862d389a584b40b78336479cecdaf23c src/detail/fault_latch.hpp @@ -13,11 +13,12 @@ f5953a7fc4fd5eee56f7aa3568fe7417010e3e767cfbb0ee5922ee5f8aa2a9aa src/detail/pro 8ef13600d67b2bb77181c5403e9b51b7f48b6f8ef06381fb1f07c5b9b2a90d8a src/detail/protocol.hpp fe9034a4a32138c3469005a44a1fbd61cfe41df25207723635a22e8ed376dbf5 src/detail/sequence.cpp 72daab01251425dd7fd5d26334760f4937f94e674a16df0fb38301b0ee78b9c1 src/detail/sequence.hpp +8fdc8f3373008f41ea032478ac44ef5274013eeb4ea56f5fdd265895672ee0ae src/detail/time.hpp 78098e0c98c84103653b7aa9e2baf689cde656b52e1e13873f7491aed7f35ff5 src/detail/udp_transport.hpp -6c4fb6a8b6bdfc9f1364ac0d76423e9972e1eb30bb987aa6865e191e7d93f9ea src/detail/udp_transport_posix.cpp -9a9e34da6e9d66e0918e85da61ebc60f9ae3806a0471c327f818e8ccd210426e src/detail/udp_transport_windows.cpp -3e908eac916b3cf457387fd878dd77fb46e3b5a9eee37dfc60be788649476475 src/detail/xml_config.cpp +f6e7e14de70a366e3d25b139dee8f061cdfff4c5173c78b831e564125b81d4b2 src/detail/udp_transport_posix.cpp +d48e67880a9c0db5f2c9b4045cc7caddb7fdfbb934cb676d25914c9cf2d5cfe5 src/detail/udp_transport_windows.cpp +95a05fe378b735b433ea10f438e9f5338120830e054b30ed11cd8db12d760cea src/detail/xml_config.cpp f10ff61cd2cc645abca23a7abce37439273569c6fe139a4fb07041532edb908d src/detail/xml_config.hpp 5a25276d0307c6a341aaee525cc9a66f00cda06e30bd345716ff5ee17a57e3e7 src/discovery.cpp 2598c182a2322925c2d99e1d0bba3b57215e451ba1ecc8df6b108b885f089c1f src/status.cpp -6017859e709c6b80fd5dc444554e497410e1c7d19a12ae05502f0c9922c58762 src/types.cpp +2b59ff7f0c88207e47d295168eaf4383fbecc008b5e4f1346fb63fddfe02b72b src/types.cpp diff --git a/core/UPSTREAM b/core/UPSTREAM index 17fb02a..70e715f 100644 --- a/core/UPSTREAM +++ b/core/UPSTREAM @@ -1,7 +1,7 @@ repository=https://github.com/netft/netft-cpp.git -tag=v0.3.3 -commit=3259b8576b16fb150b51e9ef9090366c5e6efcba +tag=unreleased +commit=91f012c5d6f9b63902765ccbec3437cb286c15e1 paths=LICENSE,include,src adaptation_path=ADAPTATIONS.patch adaptation_format=git-diff-unified-zero -adaptation_sha256=07c596386c68ccd799056247d28871b41348aa56565a9504e07945cf3d7bfade +adaptation_sha256=4b2030500ea052b8958f3713e04079c8571cf44d8027e3e73f3b52b1e031cf44 diff --git a/core/netft/include/netft/types.hpp b/core/netft/include/netft/types.hpp index d26123c..7eb2cbb 100644 --- a/core/netft/include/netft/types.hpp +++ b/core/netft/include/netft/types.hpp @@ -63,9 +63,9 @@ struct Config { double sample_rate_limit_hz{0.0}; bool deliver_samples_with_error_status{false}; RecoveryPolicy recovery_policy{RecoveryPolicy::Reconnect}; + std::optional calibration_override; StartupMode startup_mode{StartupMode::Stream}; bool retain_bound_socket_until_destruction{false}; - std::optional calibration_override; }; struct Sample { diff --git a/core/netft/src/detail/client_impl.cpp b/core/netft/src/detail/client_impl.cpp index 63b03d4..60d29d3 100644 --- a/core/netft/src/detail/client_impl.cpp +++ b/core/netft/src/detail/client_impl.cpp @@ -1,7 +1,9 @@ #include "detail/client_impl.hpp" +#include "detail/time.hpp" #include #include +#include #include #include #include @@ -301,12 +303,13 @@ void Client::Impl::bias() { } bool Client::Impl::wait_for_first_sample(const std::chrono::duration timeout) { + const auto deadline = detail::checked_deadline(std::chrono::steady_clock::now(), timeout); std::unique_lock data_lock(data_mutex_); const auto captured_generation = generation_; if (captured_generation == 0) { return false; } - first_sample_cv_.wait_for(data_lock, timeout, [this, captured_generation] { + first_sample_cv_.wait_until(data_lock, deadline, [this, captured_generation] { return generation_ != captured_generation || delivered_generation_ == captured_generation || stopping_ || faulted(); }); @@ -406,7 +409,9 @@ void Client::Impl::run() noexcept { } std::unique_lock data_lock(data_mutex_); - first_sample_cv_.wait_for(data_lock, backoff, [this] { return stopping_.load(); }); + first_sample_cv_.wait_until(data_lock, + detail::checked_deadline(detail::SteadyClock::now(), backoff), + [this] { return stopping_.load(); }); data_lock.unlock(); backoff = std::min(backoff * 2.0, config_.reconnect_max_delay); } @@ -464,7 +469,7 @@ Client::Impl::SessionOutcome Client::Impl::receive_session() { } const auto timeout = config_.receive_timeout; - auto deadline = std::chrono::steady_clock::now() + timeout; + auto deadline = detail::checked_deadline(std::chrono::steady_clock::now(), timeout); std::array buffer{}; while (!stopping_) { const auto now = std::chrono::steady_clock::now(); @@ -514,7 +519,7 @@ Client::Impl::SessionOutcome Client::Impl::receive_session() { consecutive_malformed_ = 0; } received_valid_record = true; - deadline = received_at + timeout; + deadline = detail::checked_deadline(received_at, timeout); if (const auto outcome = handle_record(record, received_at)) { return {*outcome, {}, received_valid_record}; } @@ -634,6 +639,11 @@ Client::Impl::handle_record(const detail::RawRecord &record, sample.torque = {record.tx / calibration.counts_per_torque_unit, record.ty / calibration.counts_per_torque_unit, record.tz / calibration.counts_per_torque_unit}; + const auto finite = [](double value) { return std::isfinite(value); }; + if (!std::all_of(sample.force.begin(), sample.force.end(), finite) || + !std::all_of(sample.torque.begin(), sample.torque.end(), finite)) { + return SessionResult::SensorConfiguration; + } sample.force_unit = calibration.force_unit; sample.torque_unit = calibration.torque_unit; sample.configuration_revision = configuration.revision; diff --git a/core/netft/src/detail/time.hpp b/core/netft/src/detail/time.hpp new file mode 100644 index 0000000..b5c1aae --- /dev/null +++ b/core/netft/src/detail/time.hpp @@ -0,0 +1,29 @@ +#pragma once + +#include +#include +#include +#include + +namespace netft::detail { +using SteadyClock = std::chrono::steady_clock; + +inline SteadyClock::duration checked_duration(std::chrono::duration duration) { + const auto ticks = static_cast(duration.count()) * SteadyClock::period::den / + SteadyClock::period::num; + if (!std::isfinite(ticks) || ticks < 0 || + ticks >= static_cast(SteadyClock::duration::max().count())) { + throw std::invalid_argument("duration is outside the supported steady-clock range"); + } + return SteadyClock::duration{static_cast(ticks)}; +} + +inline SteadyClock::time_point checked_deadline(SteadyClock::time_point origin, + std::chrono::duration duration) { + const auto ticks = checked_duration(duration); + if (origin > SteadyClock::time_point::max() - ticks) { + throw std::invalid_argument("deadline is outside the supported steady-clock range"); + } + return origin + ticks; +} +} // namespace netft::detail diff --git a/core/netft/src/detail/udp_transport_posix.cpp b/core/netft/src/detail/udp_transport_posix.cpp index 5af2b7f..923f7f8 100644 --- a/core/netft/src/detail/udp_transport_posix.cpp +++ b/core/netft/src/detail/udp_transport_posix.cpp @@ -1,3 +1,4 @@ +#include "detail/time.hpp" #include "detail/udp_transport.hpp" #include @@ -106,7 +107,7 @@ std::size_t UdpTransport::receive(std::uint8_t *data, const std::size_t capacity } pollfd descriptor{socket, POLLIN, 0}; - const auto deadline = std::chrono::steady_clock::now() + timeout; + const auto deadline = checked_deadline(std::chrono::steady_clock::now(), timeout); auto remaining = timeout; int poll_result{}; if (wait_started_hook != nullptr) { diff --git a/core/netft/src/detail/udp_transport_windows.cpp b/core/netft/src/detail/udp_transport_windows.cpp index 1330f54..7209db2 100644 --- a/core/netft/src/detail/udp_transport_windows.cpp +++ b/core/netft/src/detail/udp_transport_windows.cpp @@ -1,3 +1,4 @@ +#include "detail/time.hpp" #include "detail/udp_transport.hpp" #ifndef NOMINMAX @@ -145,8 +146,9 @@ std::size_t UdpTransport::receive(std::uint8_t *data, const std::size_t capacity if (wait_started_hook != nullptr) { wait_started_hook(wait_started_context); } - const auto bounded_timeout = std::max(timeout, std::chrono::duration::zero()); - const auto deadline = std::chrono::steady_clock::now() + bounded_timeout; + static_cast(checked_duration(timeout)); + const auto bounded_timeout = timeout; + const auto deadline = checked_deadline(std::chrono::steady_clock::now(), bounded_timeout); constexpr int kShutdownCheckIntervalMilliseconds = 50; constexpr auto kShutdownCheckInterval = std::chrono::milliseconds{kShutdownCheckIntervalMilliseconds}; diff --git a/core/netft/src/detail/xml_config.cpp b/core/netft/src/detail/xml_config.cpp index 3e6bb03..dd40fef 100644 --- a/core/netft/src/detail/xml_config.cpp +++ b/core/netft/src/detail/xml_config.cpp @@ -278,6 +278,11 @@ SensorConfiguration parse_sensor_configuration(std::string_view xml) { configuration.calibration.counts_per_torque_unit = parse_positive_count(fields[2], "cfgcpt"); configuration.calibration.force_unit = parse_force_unit(fields[3]); configuration.calibration.torque_unit = parse_torque_unit(fields[4]); + try { + validate(configuration.calibration); + } catch (const std::invalid_argument &error) { + throw DiscoveryError(error.what()); + } configuration.source = CalibrationSource::Sensor; configuration.revision = 1; return configuration; diff --git a/core/netft/src/types.cpp b/core/netft/src/types.cpp index 3f2824b..5698a53 100644 --- a/core/netft/src/types.cpp +++ b/core/netft/src/types.cpp @@ -1,8 +1,10 @@ #include "netft/types.hpp" +#include "detail/time.hpp" #include #include #include +#include #include namespace netft { @@ -20,17 +22,43 @@ void require_positive_finite(std::string_view name, double value) { } } +void require_safe_timeout(std::string_view name, double value, bool http = false) { + require_positive_finite(name, value); + if (detail::checked_duration(std::chrono::duration{value}) <= + detail::SteadyClock::duration::zero()) { + throw std::invalid_argument(std::string{name} + " is below the steady-clock resolution"); + } + static_cast( + detail::checked_deadline(detail::SteadyClock::now(), std::chrono::duration{value})); + if (http && std::ceil(static_cast(value) * 1000.0L) >= + static_cast(std::numeric_limits::max())) { + throw std::invalid_argument(std::string{name} + " is outside the HTTP timer range"); + } +} + void require_port(std::string_view name, int value) { if (value < 1 || value > 65535) { throw std::invalid_argument(std::string{name} + " must be in the range 1..65535"); } } +void require_safe_count(std::string_view name, double value) { + require_positive_finite(name, value); + // INT32_MIN has the largest magnitude. Round the minimum divisor up so + // every raw count remains representable, including under directed rounding. + constexpr double magnitude = -static_cast(std::numeric_limits::min()); + constexpr double minimum = (magnitude / std::numeric_limits::max()) * + (1.0 + std::numeric_limits::epsilon()); + if (value < minimum) { + throw std::invalid_argument(std::string{name} + " cannot represent the full raw count range"); + } +} + } // namespace void validate(const Calibration &calibration) { - require_positive_finite("counts_per_force_unit", calibration.counts_per_force_unit); - require_positive_finite("counts_per_torque_unit", calibration.counts_per_torque_unit); + require_safe_count("counts_per_force_unit", calibration.counts_per_force_unit); + require_safe_count("counts_per_torque_unit", calibration.counts_per_torque_unit); if (calibration.force_unit == ForceUnit::Unknown || calibration.torque_unit == TorqueUnit::Unknown) { throw std::invalid_argument("calibration units must be known"); @@ -43,12 +71,12 @@ void validate(const Config &config) { } require_port("rdt_port", config.rdt_port); require_port("http_port", config.http_port); - require_positive_finite("receive_timeout", config.receive_timeout.count()); - require_positive_finite("configuration_connect_timeout", - config.configuration_connect_timeout.count()); - require_positive_finite("configuration_timeout", config.configuration_timeout.count()); - require_positive_finite("reconnect_initial_delay", config.reconnect_initial_delay.count()); - require_positive_finite("reconnect_max_delay", config.reconnect_max_delay.count()); + require_safe_timeout("receive_timeout", config.receive_timeout.count()); + require_safe_timeout("configuration_connect_timeout", + config.configuration_connect_timeout.count(), true); + require_safe_timeout("configuration_timeout", config.configuration_timeout.count(), true); + require_safe_timeout("reconnect_initial_delay", config.reconnect_initial_delay.count()); + require_safe_timeout("reconnect_max_delay", config.reconnect_max_delay.count()); if (config.reconnect_max_delay < config.reconnect_initial_delay) { throw std::invalid_argument("reconnect_max_delay must not be below reconnect_initial_delay"); } diff --git a/src/application.cpp b/src/application.cpp index 8efe10a..b093564 100644 --- a/src/application.cpp +++ b/src/application.cpp @@ -4,12 +4,14 @@ #include "cli/completion.hpp" #include "cli/help.hpp" #include "cli/parser.hpp" +#include "cli/schema.hpp" #include "commands/bias.hpp" #include "commands/check.hpp" #include "commands/info.hpp" #include "commands/monitor.hpp" #include "commands/record.hpp" #include "config/resolver.hpp" +#include "output/json.hpp" #include "platform/line_reader.hpp" #include "sensor/netft_backend.hpp" @@ -93,7 +95,12 @@ Filesystem &NativeEnvironment::filesystem() { return implementation_->filesystem EnvironmentMap NativeEnvironment::environment() const { return read_process_environment(); } int NativeEnvironment::show_help(const ShowHelp &help) { - implementation_->output.standard_output << render_help(help.topic); + if (help.topic == "schema") { + write_command_schema(implementation_->output.standard_output, command_schema(), + NETFT_CLI_VERSION, NETFT_CLI_SOURCE_COMMIT, NETFT_CLI_SOURCE_DIRTY); + } else { + implementation_->output.standard_output << render_help(help.topic); + } implementation_->output.standard_output.flush(); if (!implementation_->output.standard_output) { throw AppError{ExitCode::Io, "failed to write help output"}; diff --git a/src/cli/help.cpp b/src/cli/help.cpp index e30e265..69de542 100644 --- a/src/cli/help.cpp +++ b/src/cli/help.cpp @@ -66,7 +66,8 @@ std::string general_help() { " -q, --quiet Suppress progress output.\n" " --color MODE Control color in human-readable output.\n" " --help Show help.\n" - " --version Show version.\n"; + " --version Show version.\n" + " --schema Emit the versioned machine interface as JSON.\n"; return stream.str(); } diff --git a/src/cli/parser.cpp b/src/cli/parser.cpp index 7072e44..a10657f 100644 --- a/src/cli/parser.cpp +++ b/src/cli/parser.cpp @@ -373,6 +373,12 @@ Action parse_arguments(const std::vector &arguments) { } return ShowHelp{"general"}; } + if (normalized.front() == "--schema") { + if (normalized.size() != 1) { + usage_error("Schema does not take arguments"); + } + return ShowHelp{"schema"}; + } if (normalized.front() == "--version") { if (normalized.size() != 1) { usage_error("Version does not take arguments"); diff --git a/src/commands/record.cpp b/src/commands/record.cpp index 5369742..8d32a5b 100644 --- a/src/commands/record.cpp +++ b/src/commands/record.cpp @@ -1,6 +1,7 @@ #include "commands/record.hpp" #include "app/error.hpp" +#include "output/json.hpp" #include "recording/csv_writer.hpp" #include "recording/ndjson_writer.hpp" #include "recording/output_file.hpp" @@ -71,6 +72,10 @@ int run_record(const RecordOptions &options, SensorBackend &backend, OutputConte const RecorderLimits limits{options.duration, options.count, queue_capacity}; const auto result = recorder.run(limits, [&] { file.finalize(); }); + OutputFile metadata(options.output.string() + ".metadata.json", filesystem); + write_recording_metadata(metadata.stream(), result); + metadata.finalize(); + write_progress(output, options.terminal, "Recording saved: " + std::to_string(result.written_count) + " samples"); return result.interrupted ? static_cast(ExitCode::Interrupted) : 0; diff --git a/src/output/json.cpp b/src/output/json.cpp index faa75cf..83c88a5 100644 --- a/src/output/json.cpp +++ b/src/output/json.cpp @@ -1,6 +1,9 @@ #include "output/json.hpp" #include "app/error.hpp" +#include "cli/schema.hpp" +#include "recording/recorder.hpp" +#include #include #include @@ -339,6 +342,170 @@ void write_serialized(std::ostream &stream, const Record &record, bool newline) } // namespace +void write_recording_metadata(std::ostream &stream, const RecorderResult &result) { + std::string output{ + R"({"schema_version":1,"kind":"netft-recording","producer":"netft-cli","result":)"}; + append_string(output, result.interrupted ? "interrupted" : "complete"); + output += ",\"accepted_samples\":"; + append_integer(output, result.accepted_count); + output += ",\"written_samples\":"; + append_integer(output, result.written_count); + output += ",\"sample_span_seconds\":"; + append_double(output, result.sample_span_seconds); + output += ",\"recorded_rdt_gaps\":"; + append_integer(output, result.recorded_rdt_gaps); + output += ",\"reconnect_count\":"; + append_integer(output, result.reconnect_count); + output += R"(,"error":null,"pause_count":0,"configuration_revisions":[)"; + bool first = true; + for (const auto revision : result.configuration_revisions) { + if (!first) { + output.push_back(','); + } + first = false; + append_integer(output, revision); + } + output += "],\"force_units\":["; + first = true; + for (const auto &unit : result.force_units) { + if (!first) { + output.push_back(','); + } + first = false; + append_string(output, unit); + } + output += "],\"torque_units\":["; + first = true; + for (const auto &unit : result.torque_units) { + if (!first) { + output.push_back(','); + } + first = false; + append_string(output, unit); + } + output += "]}\n"; + stream.write(output.data(), static_cast(output.size())); + if (!stream) { + throw AppError{ExitCode::Io, "failed to write recording metadata"}; + } +} + +void write_command_schema(std::ostream &stream, const CommandSchema &schema, + std::string_view version, std::string_view source_commit, + bool source_dirty) { + constexpr std::array types{"flag", + "host", + "port", + "positive-number", + "nonnegative-number", + "positive-integer", + "nonnegative-integer", + "duration", + "path", + "choice"}; + std::string output{R"({"schemaVersion":1,"kind":"cli","component":"netft-cli")"}; + auto string_field = [&](std::string_view key, std::string_view value) { + output.push_back(','); + append_string(output, key); + output.push_back(':'); + append_string(output, value); + }; + auto array = [&](const auto &values, auto append) { + output.push_back('['); + bool first = true; + for (const auto &value : values) { + if (!first) { + output.push_back(','); + } + first = false; + append(value); + } + output.push_back(']'); + }; + string_field("version", version); + string_field("sourceCommit", source_commit); + output += source_dirty ? ",\"sourceDirty\":true" : ",\"sourceDirty\":false"; + output += ",\"options\":"; + array(schema.options, [&](const OptionSpec &option) { + output += "{\"id\":"; + append_string(output, option.long_name); + string_field("longName", option.long_name); + if (option.short_name) { + string_field("shortName", std::string(1, option.short_name)); + } + string_field("valueType", types.at(static_cast(option.value_type))); + string_field("description", option.description); + output += option.repeatable ? ",\"repeatable\":true" : ",\"repeatable\":false"; + output += ",\"values\":"; + array(option.values, [&](auto value) { append_string(output, value); }); + output.push_back('}'); + }); + output += ",\"commands\":"; + std::set exits; + array(schema.commands, [&](const CommandSpec &command) { + output += "{\"id\":"; + append_string(output, command.name); + string_field("name", command.name); + string_field("synopsis", command.usage); + string_field("description", command.description); + output += ",\"optionIds\":"; + array(command.options, [&](auto id) { append_string(output, schema.option(id).long_name); }); + output += ",\"positionals\":"; + array(command.positionals, [&](auto id) { + const auto &position = schema.positional(id); + output += "{\"name\":"; + append_string(output, position.name); + string_field("valueType", types.at(static_cast(position.value_type))); + output += position.required ? ",\"required\":true" : ",\"required\":false"; + output += ",\"values\":"; + array(position.values, [&](auto value) { append_string(output, value); }); + output.push_back('}'); + }); + output += ",\"examples\":"; + array(command.examples, [&](auto value) { append_string(output, value); }); + output += ",\"exitStatuses\":"; + array(command.exit_statuses, [&](int value) { + exits.insert(value); + append_integer(output, value); + }); + output.push_back('}'); + }); + output += ",\"exitStatuses\":"; + array(exits, [&](int code) { + output += "{\"code\":"; + append_integer(output, code); + const auto meaning = [](int exit_code) -> std::string_view { + switch (exit_code) { + case 0: + return "Completed successfully"; + case 2: + return "Invalid command line or configuration"; + case 3: + return "Sensor discovery failed"; + case 4: + return "Stream connection or acquisition failed"; + case 5: + return "Sensor reported a fault"; + case 6: + return "Input/output operation failed"; + case 7: + return "A check acceptance criterion failed"; + case 8: + return "Recording integrity could not be guaranteed"; + default: + return "Interrupted"; + } + }(code); + string_field("meaning", meaning); + output.push_back('}'); + }); + output += "}\n"; + stream.write(output.data(), static_cast(output.size())); + if (!stream) { + throw AppError{ExitCode::Io, "failed to write interface schema"}; + } +} + void write_json(std::ostream &stream, const ConfigurationRecord &record) { write_serialized(stream, record, false); } diff --git a/src/output/json.hpp b/src/output/json.hpp index cc1083d..ceeeda5 100644 --- a/src/output/json.hpp +++ b/src/output/json.hpp @@ -6,6 +6,12 @@ #include namespace netft_cli { +struct CommandSchema; +struct RecorderResult; +void write_recording_metadata(std::ostream &stream, const RecorderResult &result); +void write_command_schema(std::ostream &stream, const CommandSchema &schema, + std::string_view version, std::string_view source_commit, + bool source_dirty); void write_json(std::ostream &stream, const ConfigurationRecord &record); void write_json(std::ostream &stream, const SampleRecord &record); diff --git a/src/platform/filesystem.cpp b/src/platform/filesystem.cpp index 2bd8c65..db65204 100644 --- a/src/platform/filesystem.cpp +++ b/src/platform/filesystem.cpp @@ -1,18 +1,153 @@ #include "platform/filesystem.hpp" +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#include +#include +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#else +#include +#endif + namespace netft_cli { +namespace { + +class FileBuffer final : public std::streambuf { +public: + explicit FileBuffer(const std::filesystem::path &path) { +#ifdef _WIN32 + const auto descriptor = + _wopen(path.c_str(), _O_CREAT | _O_EXCL | _O_WRONLY | _O_BINARY, _S_IREAD | _S_IWRITE); +#else + const auto descriptor = ::open(path.c_str(), O_CREAT | O_EXCL | O_WRONLY | O_CLOEXEC, 0600); +#endif + if (descriptor < 0) { + throw std::system_error(errno, std::generic_category(), "create recording"); + } +#ifdef _WIN32 + file_ = _wfdopen(descriptor, L"wb"); +#else + file_ = ::fdopen(descriptor, "wb"); +#endif + if (file_ == nullptr) { + const auto error = errno; +#ifdef _WIN32 + _close(descriptor); +#else + ::close(descriptor); +#endif + throw std::system_error(error, std::generic_category(), "open recording stream"); + } + setp(buffer_.data(), buffer_.data() + buffer_.size()); + } + ~FileBuffer() override { static_cast(close()); } + bool close() noexcept { + if (file_ == nullptr) { + return true; + } + const bool flushed = sync() == 0; + const bool closed = std::fclose(file_) == 0; + file_ = nullptr; + setp(nullptr, nullptr); + return flushed && closed; + } + +protected: + int sync() override { + if (file_ == nullptr) { + return -1; + } + const auto count = static_cast(pptr() - pbase()); + if (count != 0 && std::fwrite(pbase(), 1, count, file_) != count) { + return -1; + } + setp(buffer_.data(), buffer_.data() + buffer_.size()); + return std::fflush(file_); + } + int_type overflow(int_type value) override { + if (sync() != 0) { + return traits_type::eof(); + } + if (!traits_type::eq_int_type(value, traits_type::eof())) { + *pptr() = traits_type::to_char_type(value); + pbump(1); + } + return traits_type::not_eof(value); + } + std::streamsize xsputn(const char *source, std::streamsize count) override { + std::streamsize written{}; + while (written < count && (file_ != nullptr)) { + if (pptr() == epptr() && sync() != 0) { + break; + } + const auto chunk = std::min(count - written, static_cast(epptr() - pptr())); + std::memcpy(pptr(), source + written, static_cast(chunk)); + pbump(static_cast(chunk)); + written += chunk; + } + return written; + } + +private: + std::FILE *file_{}; + std::array buffer_{}; +}; + +} // namespace + +struct ExclusiveOutputFile::Impl { + explicit Impl(const std::filesystem::path &path) : buffer(path), stream(&buffer) {} + FileBuffer buffer; + std::ostream stream; +}; +ExclusiveOutputFile::ExclusiveOutputFile(const std::filesystem::path &path) + : impl_(std::make_unique(path)) {} +ExclusiveOutputFile::~ExclusiveOutputFile() = default; +std::ostream &ExclusiveOutputFile::stream() noexcept { return impl_->stream; } +void ExclusiveOutputFile::close() { + impl_->stream.flush(); + const bool closed = impl_->buffer.close(); + if (!impl_->stream || !closed) { + throw std::ios_base::failure("recording close failed"); + } +} bool NativeFilesystem::exists(const std::filesystem::path &path) const { - return std::filesystem::exists(path); + // symlink_status also observes a dangling link; exists(path) follows it. + return std::filesystem::exists(std::filesystem::symlink_status(path)); } void NativeFilesystem::rename(const std::filesystem::path &source, const std::filesystem::path &destination) { - if (std::filesystem::exists(destination)) { - throw std::filesystem::filesystem_error("recording destination already exists", destination, - std::make_error_code(std::errc::file_exists)); +#ifdef _WIN32 + if (!MoveFileW(source.c_str(), destination.c_str())) { + throw std::filesystem::filesystem_error( + "finalize recording", source, destination, + std::error_code(static_cast(GetLastError()), std::system_category())); + } +#else + // Same-directory hard-link publication atomically refuses every existing + // destination, including dangling links. No check-then-rename race. + if (::link(source.c_str(), destination.c_str()) != 0) { + throw std::filesystem::filesystem_error("publish recording", source, destination, + std::error_code(errno, std::generic_category())); + } + if (::unlink(source.c_str()) != 0) { + throw std::filesystem::filesystem_error("remove recording partial", source, + std::error_code(errno, std::generic_category())); } - std::filesystem::rename(source, destination); +#endif } } // namespace netft_cli diff --git a/src/platform/filesystem.hpp b/src/platform/filesystem.hpp index 1744804..3f784e8 100644 --- a/src/platform/filesystem.hpp +++ b/src/platform/filesystem.hpp @@ -1,9 +1,26 @@ #pragma once #include +#include +#include namespace netft_cli { +// Owns the exclusively created file for its complete write lifetime. +class ExclusiveOutputFile { +public: + explicit ExclusiveOutputFile(const std::filesystem::path &path); + ~ExclusiveOutputFile(); + ExclusiveOutputFile(const ExclusiveOutputFile &) = delete; + ExclusiveOutputFile &operator=(const ExclusiveOutputFile &) = delete; + [[nodiscard]] std::ostream &stream() noexcept; + void close(); + +private: + struct Impl; + std::unique_ptr impl_; +}; + class Filesystem { public: virtual ~Filesystem() = default; diff --git a/src/recording/csv_writer.cpp b/src/recording/csv_writer.cpp index fde8fdb..ed2a4f0 100644 --- a/src/recording/csv_writer.cpp +++ b/src/recording/csv_writer.cpp @@ -19,7 +19,7 @@ namespace { constexpr std::string_view header = "schema_version,timestamp_utc,elapsed_seconds,rdt_sequence,ft_sequence,status," "raw_fx,raw_fy,raw_fz,raw_tx,raw_ty,raw_tz," - "fx,fy,fz,tx,ty,tz,force_unit,torque_unit\r\n"; + "fx,fy,fz,tx,ty,tz,force_unit,torque_unit,configuration_revision\r\n"; void require_finite(double value) { if (!std::isfinite(value)) { @@ -80,6 +80,7 @@ std::string serialize(const RecordingRecord &record) { append_field(output, record.force_unit); output.push_back(','); append_field(output, record.torque_unit); + append_value(output, record.configuration_revision); output += "\r\n"; return output; } diff --git a/src/recording/ndjson_writer.cpp b/src/recording/ndjson_writer.cpp index 9d6bc07..853134b 100644 --- a/src/recording/ndjson_writer.cpp +++ b/src/recording/ndjson_writer.cpp @@ -57,6 +57,7 @@ std::string serialize(const RecordingRecord &record) { append_number(output, record.rdt_sequence); output += ",\"ft_sequence\":"; append_number(output, record.ft_sequence); + output += ",\"configuration_revision\":" + std::to_string(record.configuration_revision); output += ",\"status\":"; append_number(output, record.status); output += ",\"raw\":"; diff --git a/src/recording/output_file.cpp b/src/recording/output_file.cpp index bc3ed3c..4ba7192 100644 --- a/src/recording/output_file.cpp +++ b/src/recording/output_file.cpp @@ -17,10 +17,7 @@ OutputFile::OutputFile(std::filesystem::path destination, Filesystem &filesystem if (filesystem_.exists(partial_)) { throw AppError{ExitCode::Io, "recording partial file already exists"}; } - stream_.open(partial_, std::ios::binary | std::ios::out); - if (!stream_) { - throw AppError{ExitCode::Io, "recording partial file could not be opened"}; - } + stream_ = std::make_unique(partial_); } catch (const AppError &) { throw; } catch (const std::exception &) { @@ -28,25 +25,14 @@ OutputFile::OutputFile(std::filesystem::path destination, Filesystem &filesystem } } -OutputFile::~OutputFile() { - if (stream_.is_open()) { - stream_.close(); - } -} +OutputFile::~OutputFile() = default; void OutputFile::finalize() { if (finalized_) { return; } - stream_.flush(); - if (!stream_) { - throw AppError{ExitCode::Io, "recording output could not be flushed"}; - } - stream_.close(); - if (stream_.fail()) { - throw AppError{ExitCode::Io, "recording output could not be closed"}; - } try { + stream_->close(); filesystem_.rename(partial_, destination_); } catch (const std::exception &) { throw AppError{ExitCode::Io, "recording output could not be finalized"}; diff --git a/src/recording/output_file.hpp b/src/recording/output_file.hpp index c34bb70..d4d8b7d 100644 --- a/src/recording/output_file.hpp +++ b/src/recording/output_file.hpp @@ -3,7 +3,7 @@ #include "platform/filesystem.hpp" #include -#include +#include namespace netft_cli { @@ -17,7 +17,7 @@ class OutputFile { OutputFile(OutputFile &&) = delete; OutputFile &operator=(OutputFile &&) = delete; - [[nodiscard]] std::ostream &stream() noexcept { return stream_; } + [[nodiscard]] std::ostream &stream() noexcept { return stream_->stream(); } void finalize(); [[nodiscard]] bool finalized() const noexcept { return finalized_; } [[nodiscard]] const std::filesystem::path &partial_path() const noexcept { return partial_; } @@ -26,7 +26,7 @@ class OutputFile { std::filesystem::path destination_; std::filesystem::path partial_; Filesystem &filesystem_; - std::ofstream stream_; + std::unique_ptr stream_; bool finalized_{}; }; diff --git a/src/recording/recorder.cpp b/src/recording/recorder.cpp index 7944f66..9eba3ce 100644 --- a/src/recording/recorder.cpp +++ b/src/recording/recorder.cpp @@ -7,11 +7,31 @@ #include #include #include +#include #include +#include #include #include namespace netft_cli { +namespace { +Clock::TimePoint deadline_after(Clock::TimePoint origin, std::chrono::duration duration) { + const auto ticks = static_cast(duration.count()) * Clock::Duration::period::den / + Clock::Duration::period::num; + if (!std::isfinite(ticks) || ticks < 1 || + ticks > static_cast(Clock::Duration::max().count())) { + throw AppError{ExitCode::Usage, "duration is outside the supported clock range"}; + } + const auto converted = std::chrono::duration_cast(duration); + if (origin > Clock::TimePoint::max() - converted) { + throw AppError{ExitCode::Usage, "duration is outside the supported clock range"}; + } + return origin + converted; +} +Clock::TimePoint deadline_at_or_max(Clock::TimePoint origin, Clock::Duration interval) { + return origin > Clock::TimePoint::max() - interval ? Clock::TimePoint::max() : origin + interval; +} +} // namespace Recorder::Recorder(SensorBackend &backend, ConnectionOptions connection, RecordingWriter &writer, Clock &clock, WallClock &wall_clock, InterruptFlag &interrupt) @@ -26,8 +46,19 @@ RecorderResult Recorder::run(const RecorderLimits &limits, const std::function accepted_count{}; std::uint64_t written_count{}; std::exception_ptr writer_error; + RecorderResult summary; + std::optional first_sample_at; + std::optional last_sample_at; + std::optional last_recorded_rdt; const auto monotonic_origin = clock_.now(); const auto wall_origin = wall_clock_.now(); + const auto initial_data_deadline = deadline_after(monotonic_origin, connection_.timeout); + const auto idle_interval = initial_data_deadline - monotonic_origin; + std::optional deadline; + if (limits.duration) { + deadline = deadline_after(monotonic_origin, *limits.duration); + } + auto acquisition = Acquisition::open(backend_, connection_); std::thread writer_thread([&] { @@ -42,6 +73,21 @@ RecorderResult Recorder::run(const RecorderLimits &limits, const std::functionreceived_at - monotonic_origin); writer_.write(make_recording_record(*item.sample, monotonic_origin, timestamp)); ++written_count; + if (!first_sample_at) { + first_sample_at = item.sample->received_at; + } + last_sample_at = item.sample->received_at; + summary.configuration_revisions.insert(item.sample->configuration_revision); + summary.force_units.emplace(netft::to_string(item.sample->force_unit)); + summary.torque_units.emplace(netft::to_string(item.sample->torque_unit)); + if (last_recorded_rdt) { + const auto delta = + static_cast(item.sample->rdt_sequence - *last_recorded_rdt); + if (delta > 0 && delta < 0x80000000U) { + summary.recorded_rdt_gaps += delta - 1U; + } + } + last_recorded_rdt = item.sample->rdt_sequence; } } catch (...) { writer_error = std::current_exception(); @@ -80,39 +126,51 @@ RecorderResult Recorder::run(const RecorderLimits &limits, const std::function deadline; - if (limits.duration) { - const auto ticks = std::chrono::duration_cast(*limits.duration); - if (ticks <= Clock::Duration::zero() || monotonic_origin > Clock::TimePoint::max() - ticks) { - acquisition.stop(); - queue.cancel(); - writer_thread.join(); - throw AppError{ExitCode::Usage, "duration is outside the supported clock range"}; - } - deadline = monotonic_origin + ticks; - } - bool interrupted{}; + bool data_failed{}; + std::uint64_t last_progress_count{}; + auto data_deadline = initial_data_deadline; constexpr auto poll_interval = std::chrono::milliseconds{10}; - while (!stop_requested.load(std::memory_order_acquire)) { - if (interrupt_.requested()) { - interrupted = true; - break; - } - const auto now = clock_.now(); - if (deadline && now >= *deadline) { - break; - } - const auto interval = std::chrono::duration_cast(poll_interval); - const auto next_poll = - now > Clock::TimePoint::max() - interval ? Clock::TimePoint::max() : now + interval; - const auto next = deadline ? std::min(*deadline, next_poll) : next_poll; - if (!clock_.wait_until(next, interrupt_)) { - interrupted = true; - break; + try { + while (!stop_requested.load(std::memory_order_acquire)) { + if (interrupt_.requested()) { + interrupted = true; + break; + } + const auto now = clock_.now(); + const auto health = acquisition.health(); + if (health.state == netft::ClientState::Faulted) { + data_failed = true; + break; + } + const auto count = accepted_count.load(std::memory_order_acquire); + if (count != last_progress_count) { + last_progress_count = count; + data_deadline = deadline_at_or_max(now, idle_interval); + } + if (now >= data_deadline) { + data_failed = true; + break; + } + if (deadline && now >= *deadline) { + break; + } + const auto interval = std::chrono::duration_cast(poll_interval); + const auto next_poll = deadline_at_or_max(now, interval); + const auto next = + std::min(data_deadline, deadline ? std::min(*deadline, next_poll) : next_poll); + if (!clock_.wait_until(next, interrupt_)) { + interrupted = true; + break; + } } - } + const auto final_health = acquisition.health(); + summary.reconnect_count = final_health.reconnect_count; + data_failed = data_failed || final_health.state == netft::ClientState::Faulted; + } catch (...) { + data_failed = true; + } acquisition.stop(); queue.close(); writer_thread.join(); @@ -124,8 +182,19 @@ RecorderResult Recorder::run(const RecorderLimits &limits, const std::function{*last_sample_at - *first_sample_at}.count()); + } + return summary; } } // namespace netft_cli diff --git a/src/recording/recorder.hpp b/src/recording/recorder.hpp index 2235912..442d43c 100644 --- a/src/recording/recorder.hpp +++ b/src/recording/recorder.hpp @@ -10,6 +10,8 @@ #include #include #include +#include +#include namespace netft_cli { @@ -22,6 +24,12 @@ struct RecorderLimits { struct RecorderResult { std::uint64_t written_count{}; bool interrupted{}; + std::uint64_t accepted_count{}; + double sample_span_seconds{}; + std::set configuration_revisions; + std::set force_units, torque_units; + std::uint64_t recorded_rdt_gaps{}; + std::uint64_t reconnect_count{}; }; class Recorder { diff --git a/src/recording/writer.cpp b/src/recording/writer.cpp index 546f375..fba3ebe 100644 --- a/src/recording/writer.cpp +++ b/src/recording/writer.cpp @@ -52,6 +52,7 @@ RecordingRecord make_recording_record(const netft::Sample &sample, sample.torque[2]}, std::string{netft::to_string(sample.force_unit)}, std::string{netft::to_string(sample.torque_unit)}, + sample.configuration_revision, }; } diff --git a/src/recording/writer.hpp b/src/recording/writer.hpp index 1452e64..5e9c6cd 100644 --- a/src/recording/writer.hpp +++ b/src/recording/writer.hpp @@ -19,6 +19,7 @@ struct RecordingRecord { std::array scaled{}; std::string force_unit; std::string torque_unit; + std::uint64_t configuration_revision{}; }; [[nodiscard]] RecordingRecord make_recording_record(const netft::Sample &sample, diff --git a/test/integration/process_test.py b/test/integration/process_test.py index 7f4c2da..1277e1c 100644 --- a/test/integration/process_test.py +++ b/test/integration/process_test.py @@ -302,6 +302,12 @@ def test_record_finalizes_parseable_machine_files(tmp_path: Path, suffix: str) - assert not result.stdout assert not result.stderr assert destination.is_file() + metadata = json.loads(Path(f"{destination}.metadata.json").read_text()) + assert metadata["kind"] == "netft-recording" + assert metadata["accepted_samples"] == metadata["written_samples"] == 5 + assert metadata["configuration_revisions"] == [1] + assert metadata["force_units"] == ["N"] + assert metadata["pause_count"] == 0 assert not Path(f"{destination}.partial").exists() if suffix == ".csv": with destination.open(newline="", encoding="utf-8") as stream: @@ -346,3 +352,23 @@ def test_sigint_returns_130_and_stops_streaming() -> None: assert not stderr for line in stdout.splitlines(): json.loads(line) + + +def test_schema_exposes_typed_options_and_command_exit_contract_without_network() -> None: + result = run_process("--schema") + assert result.returncode == 0 + assert not result.stderr + manifest = json.loads(result.stdout) + assert manifest["schemaVersion"] == 1 + assert manifest["component"] == "netft-cli" + assert manifest["version"] == run_process("--version").stdout.strip().split()[-1] + options = {item["id"]: item for item in manifest["options"]} + commands = {item["id"]: item for item in manifest["commands"]} + assert options["timeout"]["valueType"] == "duration" + assert options["yes"]["valueType"] == "flag" + assert "yes" in commands["bias"]["optionIds"] + assert "yes" not in commands["record"]["optionIds"] + assert 8 in commands["record"]["exitStatuses"] + assert commands["completion"]["positionals"][0]["required"] is True + assert commands["completion"]["positionals"][0]["values"] == ["bash", "zsh", "fish", "powershell"] + assert run_process("--schema", "unexpected").returncode == 2 diff --git a/test/tools/test_release_tools.py b/test/tools/test_release_tools.py index dbf32a5..3401a2f 100644 --- a/test/tools/test_release_tools.py +++ b/test/tools/test_release_tools.py @@ -209,3 +209,15 @@ def test_release_notes_do_not_insert_fixed_width_breaks() -> None: bullet_lines = [line for line in notes.splitlines() if line.startswith("- ")] assert len(bullet_lines) == 1 assert len(bullet_lines[0]) > 120 + + +def test_stable_version_rules_match_installer_contract() -> None: + from tools.package_release import VERSION_PATTERN as package_pattern + from tools.release_inventory import expected_asset_names, ReleaseInventoryError + for version in ("0.0.0", "0.2.2", "10.20.30"): + assert package_pattern.fullmatch(version) + assert len(expected_asset_names(version)) == 6 + for version in ("00.2.1", "0.02.1", "0.2.01", "0.2.1-rc.1", "v0.2.1", "0.2.1+build"): + assert not package_pattern.fullmatch(version) + with pytest.raises(ReleaseInventoryError): + expected_asset_names(version) diff --git a/test/tools/test_sync_core.py b/test/tools/test_sync_core.py index d6c1b59..d7316a5 100644 --- a/test/tools/test_sync_core.py +++ b/test/tools/test_sync_core.py @@ -50,6 +50,8 @@ def make_exact_base_fixture(path: Path) -> Path: run("git", "init", cwd=path) run( "git", + "-c", + "core.autocrlf=false", "apply", "--reverse", "--unidiff-zero", diff --git a/test/unit/output_file_test.cpp b/test/unit/output_file_test.cpp index 02fd6c3..e38ddfc 100644 --- a/test/unit/output_file_test.cpp +++ b/test/unit/output_file_test.cpp @@ -24,6 +24,15 @@ class RenameFailureFilesystem final : public Filesystem { } }; +class BlindFilesystem final : public Filesystem { +public: + bool exists(const std::filesystem::path &) const override { return false; } + void rename(const std::filesystem::path &source, + const std::filesystem::path &destination) override { + NativeFilesystem{}.rename(source, destination); + } +}; + std::filesystem::path output_path(std::string_view name) { const auto directory = std::filesystem::path(testing::TempDir()) / "netft-output-file"; std::filesystem::create_directories(directory); @@ -95,5 +104,51 @@ TEST(OutputFile, RetainsPartialFileWhenStreamOrRenameFails) { remove_output(rename_path); } +TEST(OutputFile, ExclusiveCreatePreservesPartialEvenWhenPrecheckMissesIt) { + const auto path = output_path("exclusive-create.csv"); + remove_output(path); + const auto partial = path.string() + ".partial"; + std::ofstream(partial) << "other writer"; + BlindFilesystem filesystem; + test::expect_app_error(ExitCode::Io, [&] { OutputFile output(path, filesystem); }); + std::ifstream input(partial); + const std::string content{std::istreambuf_iterator{input}, + std::istreambuf_iterator{}}; + EXPECT_EQ(content, "other writer"); + remove_output(path); +} + +#ifndef _WIN32 +TEST(OutputFile, RejectsDanglingPartialWithoutCreatingItsTarget) { + const auto path = output_path("dangling-partial.csv"); + const auto target = output_path("must-not-create.csv"); + remove_output(path); + remove_output(target); + std::filesystem::create_symlink(target, path.string() + ".partial"); + NativeFilesystem filesystem; + test::expect_app_error(ExitCode::Io, [&] { OutputFile output(path, filesystem); }); + EXPECT_FALSE(std::filesystem::exists(target)); + remove_output(path); + remove_output(target); +} + +TEST(OutputFile, PreservesDanglingDestinationAppearingBeforeFinalize) { + const auto path = output_path("destination-race.csv"); + const auto target = output_path("destination-link-target.csv"); + remove_output(path); + remove_output(target); + NativeFilesystem filesystem; + { + OutputFile output(path, filesystem); + output.stream() << "captured"; + std::filesystem::create_symlink(target, path); + test::expect_app_error(ExitCode::Io, [&] { output.finalize(); }); + EXPECT_TRUE(std::filesystem::is_symlink(path)); + EXPECT_TRUE(std::filesystem::exists(output.partial_path())); + } + remove_output(path); +} +#endif + } // namespace } // namespace netft_cli diff --git a/test/unit/record_command_test.cpp b/test/unit/record_command_test.cpp index 16f2a41..cd2b577 100644 --- a/test/unit/record_command_test.cpp +++ b/test/unit/record_command_test.cpp @@ -43,6 +43,8 @@ void remove_recording(const std::filesystem::path &path) { std::error_code error; std::filesystem::remove(path, error); std::filesystem::remove(path.string() + ".partial", error); + std::filesystem::remove(path.string() + ".metadata.json", error); + std::filesystem::remove(path.string() + ".metadata.json.partial", error); } TEST(RecordCommand, SelectsCsvFromExtensionAndHonorsQuietMode) { diff --git a/test/unit/recorder_test.cpp b/test/unit/recorder_test.cpp index 990390d..07217b9 100644 --- a/test/unit/recorder_test.cpp +++ b/test/unit/recorder_test.cpp @@ -164,5 +164,46 @@ TEST(Recorder, QueueOverflowIsExplicitAndRetainsUnfinalizedData) { EXPECT_EQ(backend.session().stop_calls(), 1U); } +TEST(Recorder, NoFirstSampleFailsCountModeWithinConnectionTimeout) { + test::FakeBackend backend; + CollectingWriter writer; + test::FakeClock clock; + FixedWallClock wall_clock; + InterruptFlag interrupt; + Recorder recorder(backend, test::connection_options(), writer, clock, wall_clock, interrupt); + bool finalized{}; + // An independent interrupt keeps the regression bounded on the old code. + clock.set_sleep_hook([&](std::size_t n) { + if (n == 200) + interrupt.request(); + }); + test::expect_app_error(ExitCode::Recording, [&] { + static_cast(recorder.run({std::nullopt, 1, 8}, [&] { finalized = true; })); + }); + EXPECT_FALSE(finalized); + EXPECT_LE(clock.now().time_since_epoch(), 2s); + EXPECT_EQ(backend.session().stop_calls(), 1U); +} + +TEST(Recorder, TerminalFaultRetainsPartialEvenAfterReceivingSamples) { + test::FakeBackend backend; + backend.session().set_samples({test::sample(10)}); + netft::HealthSnapshot health; + health.state = netft::ClientState::Faulted; + health.fault_code = netft::FaultCode::SeriousStatus; + backend.session().set_health(health); + CollectingWriter writer; + test::FakeClock clock; + FixedWallClock wall_clock; + InterruptFlag interrupt; + Recorder recorder(backend, test::connection_options(), writer, clock, wall_clock, interrupt); + bool finalized{}; + test::expect_app_error(ExitCode::Recording, [&] { + static_cast(recorder.run({1s, std::nullopt, 8}, [&] { finalized = true; })); + }); + EXPECT_FALSE(finalized); + EXPECT_EQ(writer.records.size(), 1U); +} + } // namespace } // namespace netft_cli diff --git a/tools/package_release.py b/tools/package_release.py index c92413a..bd502cd 100755 --- a/tools/package_release.py +++ b/tools/package_release.py @@ -23,7 +23,7 @@ "macos-arm64": ".tar.gz", "windows-x86_64": ".zip", } -VERSION_PATTERN: Final = re.compile(r"[0-9]+\.[0-9]+\.[0-9]+") +VERSION_PATTERN: Final = re.compile(r"(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)") def _source_date_epoch() -> int: diff --git a/tools/release_inventory.py b/tools/release_inventory.py index c95ce33..18e5657 100644 --- a/tools/release_inventory.py +++ b/tools/release_inventory.py @@ -17,7 +17,7 @@ "macos-arm64": ".tar.gz", "windows-x86_64": ".zip", } -VERSION_PATTERN = re.compile(r"[0-9]+\.[0-9]+\.[0-9]+") +VERSION_PATTERN = re.compile(r"(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)") TEMPORARY_TAG_PATTERN = re.compile(r"untagged-[0-9a-z]+") diff --git a/tools/sync_core.py b/tools/sync_core.py index 71dc520..f69b335 100644 --- a/tools/sync_core.py +++ b/tools/sync_core.py @@ -10,12 +10,12 @@ from typing import Sequence REQUIRED_REPOSITORY = "https://github.com/netft/netft-cpp.git" -REQUIRED_TAG = "v0.3.3" -REQUIRED_COMMIT = "3259b8576b16fb150b51e9ef9090366c5e6efcba" +REQUIRED_TAG = "unreleased" +REQUIRED_COMMIT = "91f012c5d6f9b63902765ccbec3437cb286c15e1" SELECTED = ("LICENSE", "include", "src") ADAPTATION_NAME = "ADAPTATIONS.patch" ADAPTATION_FORMAT = "git-diff-unified-zero" -REQUIRED_ADAPTATION_SHA256 = "07c596386c68ccd799056247d28871b41348aa56565a9504e07945cf3d7bfade" +REQUIRED_ADAPTATION_SHA256 = "4b2030500ea052b8958f3713e04079c8571cf44d8027e3e73f3b52b1e031cf44" def canonical_adaptation() -> Path: @@ -131,6 +131,8 @@ def apply_adaptation(destination: Path, patch: Path) -> None: subprocess.run( [ "git", + "-c", + "core.autocrlf=false", "apply", "--unidiff-zero", f"--directory={patch_directory.as_posix()}", @@ -195,7 +197,10 @@ def sync(source: Path, destination: Path, tag: str) -> None: raise SystemExit("unsupported upstream tag") if git(source, "remote", "get-url", "origin") != REQUIRED_REPOSITORY: raise SystemExit("source repository does not match the required repository") - commit = git(source, "rev-parse", f"{tag}^{{commit}}") + try: + commit = git(source, "rev-parse", f"{REQUIRED_COMMIT if tag == 'unreleased' else tag}^{{commit}}") + except SystemExit as error: + raise SystemExit("source tag does not match the required commit") from error if commit != REQUIRED_COMMIT: raise SystemExit("source tag does not match the required commit") if git(source, "rev-parse", "HEAD") != commit: @@ -222,7 +227,9 @@ def parse_arguments() -> argparse.Namespace: sync_parser = subcommands.add_parser("sync", help="copy a tagged upstream snapshot") sync_parser.add_argument("--source", type=Path, required=True) - sync_parser.add_argument("--tag", required=True) + identity = sync_parser.add_mutually_exclusive_group(required=True) + identity.add_argument("--tag") + identity.add_argument("--commit", help="exact pinned unpublished candidate commit") sync_parser.add_argument("--destination", type=Path, default=Path("core")) verify_parser = subcommands.add_parser("verify", help="verify the local snapshot manifest") @@ -233,7 +240,9 @@ def parse_arguments() -> argparse.Namespace: def main() -> None: arguments = parse_arguments() if arguments.command == "sync": - sync(arguments.source, arguments.destination, arguments.tag) + if arguments.commit is not None and arguments.commit != REQUIRED_COMMIT: + raise SystemExit("unsupported upstream commit") + sync(arguments.source, arguments.destination, arguments.tag or "unreleased") else: verify(arguments.destination)