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
28 changes: 14 additions & 14 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ jobs:
name: Format and static analysis
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v7
- uses: prefix-dev/setup-pixi@v0.10.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: prefix-dev/setup-pixi@a09b6247153796b190642a2b53fac4241043cf6f # v0.10.0
with:
cache: true
- name: Check formatting
Expand Down Expand Up @@ -49,8 +49,8 @@ jobs:
- library: static
build_shared: "OFF"
steps:
- uses: actions/checkout@v7
- uses: prefix-dev/setup-pixi@v0.10.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: prefix-dev/setup-pixi@a09b6247153796b190642a2b53fac4241043cf6f # v0.10.0
with:
cache: true
- name: Configure
Expand All @@ -73,10 +73,10 @@ jobs:
name: CMake 3.16 compatibility
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- uses: prefix-dev/setup-pixi@v0.10.0
- uses: prefix-dev/setup-pixi@a09b6247153796b190642a2b53fac4241043cf6f # v0.10.0
with:
cache: true
- name: Verify pinned CMake version
Expand All @@ -88,19 +88,19 @@ jobs:
name: GCC 10 compatibility
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- uses: prefix-dev/setup-pixi@v0.10.0
- uses: prefix-dev/setup-pixi@a09b6247153796b190642a2b53fac4241043cf6f # v0.10.0
- name: Build and test with GCC 10
run: bash test/gcc10_compatibility_test.sh

sanitizers:
name: ASan and UBSan
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v7
- uses: prefix-dev/setup-pixi@v0.10.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: prefix-dev/setup-pixi@a09b6247153796b190642a2b53fac4241043cf6f # v0.10.0
with:
cache: true
- name: Configure
Expand All @@ -123,8 +123,8 @@ jobs:
name: ARM64 Release shared
runs-on: ubuntu-24.04-arm
steps:
- uses: actions/checkout@v7
- uses: prefix-dev/setup-pixi@v0.10.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: prefix-dev/setup-pixi@a09b6247153796b190642a2b53fac4241043cf6f # v0.10.0
with:
cache: true
- name: Configure
Expand All @@ -144,7 +144,7 @@ jobs:
name: macOS Release static
runs-on: macos-15
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: Install dependencies
run: brew install curl googletest ninja
- name: Configure
Expand Down Expand Up @@ -172,7 +172,7 @@ jobs:
name: Windows Release static
runs-on: windows-2025
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: Install dependencies
shell: pwsh
run: >-
Expand Down
8 changes: 4 additions & 4 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,12 @@ jobs:
analyze:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v7
- uses: prefix-dev/setup-pixi@v0.10.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: prefix-dev/setup-pixi@a09b6247153796b190642a2b53fac4241043cf6f # v0.10.0
with:
cache: true
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4
with:
languages: c-cpp
build-mode: manual
Expand All @@ -38,4 +38,4 @@ jobs:
- name: Build
run: pixi run cmake --build build/codeql
- name: Analyze
uses: github/codeql-action/analyze@v4
uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4
6 changes: 3 additions & 3 deletions .github/workflows/coverage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,14 @@ jobs:
coverage:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v7
- uses: prefix-dev/setup-pixi@v0.10.0
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: prefix-dev/setup-pixi@a09b6247153796b190642a2b53fac4241043cf6f # v0.10.0
with:
cache: true
- name: Generate coverage
run: pixi run coverage
- name: Upload coverage
uses: codecov/codecov-action@v7
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7
with:
files: coverage.xml
disable_search: true
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ jobs:
release:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
- uses: prefix-dev/setup-pixi@v0.10.0
- uses: prefix-dev/setup-pixi@a09b6247153796b190642a2b53fac4241043cf6f # v0.10.0
with:
cache: true
- name: Verify release version
Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@

All notable changes to this project are documented in this file.

## Unreleased

- Reject durations and deadlines outside the steady-clock/HTTP timer range before conversion; document synchronous resolver shutdown limits.

### Fixed

- Reject calibration divisors that cannot represent the full signed raw-count range,
consistently for HTTP discovery and manual overrides. Never deliver non-finite axes
as successful samples; numeric conversion failures use the sensor-configuration fault.

## 0.3.3 - 2026-08-01

### Fixed
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,3 +105,9 @@ security issues through [SECURITY.md](SECURITY.md).
## License

This project is licensed under the [Apache License 2.0](LICENSE).

### Timing limits (unreleased candidate)

Configuration durations must be finite, positive and representable by the steady clock; HTTP timers must also fit libcurl milliseconds. `wait_for_first_sample` accepts a zero-duration poll and rejects negative, non-finite or nonrepresentable waits. Deadlines are checked before integer conversion or addition.

UDP hostname resolution uses synchronous system `getaddrinfo`. Its DNS/NSS latency cannot be cancelled by `stop()`, and libcurl discovery cancellation also depends on the resolver backend. Receive/configuration timeouts do not provide a hard bound for every hostname resolution or callback. Use a numeric device address on the isolated sensor network when a predictable shutdown bound is required; keep callbacks bounded. The SDK does not create detached resolver threads or promise a hard realtime stop.
18 changes: 14 additions & 4 deletions 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 @@ -461,7 +466,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 @@ -511,7 +516,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 @@ -631,6 +636,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 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
3 changes: 2 additions & 1 deletion src/detail/udp_transport_posix.cpp
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
#include "detail/time.hpp"
#include "detail/udp_transport.hpp"

#include <netdb.h>
Expand Down Expand Up @@ -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) {
Expand Down
6 changes: 4 additions & 2 deletions src/detail/udp_transport_windows.cpp
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
#include "detail/time.hpp"
#include "detail/udp_transport.hpp"

#ifndef NOMINMAX
Expand Down Expand Up @@ -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<double>::zero());
const auto deadline = std::chrono::steady_clock::now() + bounded_timeout;
static_cast<void>(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};
Expand Down
5 changes: 5 additions & 0 deletions src/detail/xml_config.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
44 changes: 36 additions & 8 deletions src/types.cpp
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
#include "netft/types.hpp"
#include "detail/time.hpp"

#include <algorithm>
#include <cctype>
#include <cmath>
#include <limits>
#include <stdexcept>

namespace netft {
Expand All @@ -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<double>{value}) <=
detail::SteadyClock::duration::zero()) {
throw std::invalid_argument(std::string{name} + " is below the steady-clock resolution");
}
static_cast<void>(
detail::checked_deadline(detail::SteadyClock::now(), std::chrono::duration<double>{value}));
if (http && std::ceil(static_cast<long double>(value) * 1000.0L) >=
static_cast<long double>(std::numeric_limits<long>::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<double>(std::numeric_limits<std::int32_t>::min());
constexpr double minimum = (magnitude / std::numeric_limits<double>::max()) *
(1.0 + std::numeric_limits<double>::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");
Expand All @@ -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");
}
Expand Down
1 change: 1 addition & 0 deletions test/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ if(CMAKE_SYSTEM_NAME STREQUAL "Linux")
${CMAKE_CURRENT_SOURCE_DIR}/../src
)
target_link_options(netft_posix_transport PRIVATE
"-Wl,--wrap=getaddrinfo"
"-Wl,--wrap=poll"
"-Wl,--wrap=recv"
"-Wl,--wrap=send"
Expand Down
Loading
Loading