Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
core/ADAPTATIONS.patch text eol=lf
# Preserve the byte inventory when Windows enables core.autocrlf.
/core/netft/** -text
23 changes: 11 additions & 12 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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-')
Expand Down
3 changes: 1 addition & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 3 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path-to-netft-cpp> --tag v0.3.3
python tools/sync_core.py sync --source <path-to-netft-cpp> --commit 91f012c5d6f9b63902765ccbec3437cb286c15e1
python tools/sync_core.py verify
```

Expand All @@ -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

Expand Down
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<output>.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.
10 changes: 5 additions & 5 deletions core/ADAPTATIONS.patch
Original file line number Diff line number Diff line change
@@ -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();
Expand Down
13 changes: 7 additions & 6 deletions core/MANIFEST.sha256
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,22 @@ 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
f5953a7fc4fd5eee56f7aa3568fe7417010e3e767cfbb0ee5922ee5f8aa2a9aa src/detail/protocol.cpp
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
6 changes: 3 additions & 3 deletions core/UPSTREAM
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion core/netft/include/netft/types.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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> calibration_override;
StartupMode startup_mode{StartupMode::Stream};
bool retain_bound_socket_until_destruction{false};
std::optional<Calibration> calibration_override;
};

struct Sample {
Expand Down
18 changes: 14 additions & 4 deletions core/netft/src/detail/client_impl.cpp
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
#include "detail/client_impl.hpp"
#include "detail/time.hpp"

#include <algorithm>
#include <array>
#include <cmath>
#include <exception>
#include <stdexcept>
#include <utility>
Expand Down Expand Up @@ -301,12 +303,13 @@ void Client::Impl::bias() {
}

bool Client::Impl::wait_for_first_sample(const std::chrono::duration<double> timeout) {
const auto deadline = detail::checked_deadline(std::chrono::steady_clock::now(), timeout);
std::unique_lock<std::mutex> 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();
});
Expand Down Expand Up @@ -406,7 +409,9 @@ void Client::Impl::run() noexcept {
}

std::unique_lock<std::mutex> 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);
}
Expand Down Expand Up @@ -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<std::uint8_t, 65'536> buffer{};
while (!stopping_) {
const auto now = std::chrono::steady_clock::now();
Expand Down Expand Up @@ -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};
}
Expand Down Expand Up @@ -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;
Expand Down
29 changes: 29 additions & 0 deletions core/netft/src/detail/time.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
#pragma once

#include <chrono>
#include <cmath>
#include <limits>
#include <stdexcept>

namespace netft::detail {
using SteadyClock = std::chrono::steady_clock;

inline SteadyClock::duration checked_duration(std::chrono::duration<double> duration) {
const auto ticks = static_cast<long double>(duration.count()) * SteadyClock::period::den /
SteadyClock::period::num;
if (!std::isfinite(ticks) || ticks < 0 ||
ticks >= static_cast<long double>(SteadyClock::duration::max().count())) {
throw std::invalid_argument("duration is outside the supported steady-clock range");
}
return SteadyClock::duration{static_cast<SteadyClock::rep>(ticks)};
}

inline SteadyClock::time_point checked_deadline(SteadyClock::time_point origin,
std::chrono::duration<double> 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
Loading