diff --git a/.github/workflows/release-packages.yml b/.github/workflows/release-packages.yml index d6a2497..7ea5464 100644 --- a/.github/workflows/release-packages.yml +++ b/.github/workflows/release-packages.yml @@ -26,7 +26,7 @@ name: Release packages expected_version: description: 'Hand SDK version (transport may use a compatible newer patch)' required: true - default: '0.3.7' + default: '0.3.8' permissions: contents: read diff --git a/CHANGELOG.md b/CHANGELOG.md index aefe899..4e66337 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ [English](CHANGELOG.md) | [简体中文](CHANGELOG.zh-CN.md) +## 0.3.8 + +- Correct bounded poll shutdown, logical health generations and final optional-query errors in the explicit Python policy. +- Add Python-only routed receive capacity configuration, discovery propagation, overflow and installed-consumer regressions. Native defaults and transport v0.3.4 minimum remain unchanged. +- Rebuild SDK and consumers. No new hardware acceptance is claimed; see [release notes](RELEASE_NOTES.md). + ## 0.3.7 - Add publication-based channel sequences, identity sample metadata, filtered retained-cache waits, firmware-optional query degradation and unified angle feedback policies to the explicit Python runtime policy. diff --git a/CHANGELOG.zh-CN.md b/CHANGELOG.zh-CN.md index 96bcfd2..d767fa7 100644 --- a/CHANGELOG.zh-CN.md +++ b/CHANGELOG.zh-CN.md @@ -2,6 +2,12 @@ [English](CHANGELOG.md) | [简体中文](CHANGELOG.zh-CN.md) +## 0.3.8 + +- 修正显式 Python 策略下的有界停止轮询、健康逻辑代次和可选查询降级后的最终错误记录。 +- 增加 Python 策略接收路由容量配置、发现传递、溢出及安装消费者回归,保留 Native 默认行为及 transport v0.3.4 最低依赖。 +- SDK 和消费者需要重新编译,不新增实机验收结论,详见[发行说明](RELEASE_NOTES.md)。 + ## 0.3.7 - 为显式 Python 运行策略补齐按发布推进的通道序号、身份采样元数据、过滤反馈的缓存等待、固件可选查询降级和统一角度反馈策略。 diff --git a/CMakeLists.txt b/CMakeLists.txt index e332bc3..aa7da0d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,7 +1,7 @@ cmake_minimum_required(VERSION 3.16) project(linkerbot-hand-cpp - VERSION 0.3.7 + VERSION 0.3.8 DESCRIPTION "LinkerBot dexterous hand C++ SDK" LANGUAGES CXX ) diff --git a/README.en.md b/README.en.md index cef3ec6..ce29bac 100644 --- a/README.en.md +++ b/README.en.md @@ -60,7 +60,7 @@ This installs the SDK and communication library into the local `install/` direct Catch2 and vendor development packages are not required. Run the remaining commands from this repository's root directory. -Hand SDK 0.3.7 requires transport 0.3.4 or newer in the 0.3 minor line, +Hand SDK 0.3.8 requires transport 0.3.4 or newer in the 0.3 minor line, for source builds, prebuilt packages and installed consumers. To reuse a prebuilt transport v0.3.4 SDK, configure with diff --git a/README.md b/README.md index cef3ec6..ce29bac 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ This installs the SDK and communication library into the local `install/` direct Catch2 and vendor development packages are not required. Run the remaining commands from this repository's root directory. -Hand SDK 0.3.7 requires transport 0.3.4 or newer in the 0.3 minor line, +Hand SDK 0.3.8 requires transport 0.3.4 or newer in the 0.3 minor line, for source builds, prebuilt packages and installed consumers. To reuse a prebuilt transport v0.3.4 SDK, configure with diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 7cba768..56f1f62 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,5 +1,16 @@ # Release notes +## 0.3.8 + +- Bound Python-policy `stop_polling()` by `shutdown_timeout`. Remove unclaimed poll demand, release poll observers and wait for already-claimed polling transactions to finish. Keep coalesced active readers and other devices running. A stop timeout leaves the device open and its polling intent disabled; recovery does not restore the stopped plan. +- Project Python-policy health and channel-age generations onto the facade's successful open/recovery generation, matching retained snapshots across close, reopen and recovery. Physical Session generations remain separate. Native health projection is unchanged. +- Record an optional query's final `UnsupportedFirmwareError` in health without counting its underlying failure twice. Count post-decode unsupported results once when native metadata reads succeeded. Associate the update with that native failure's revision so a later error cannot be overwritten. Successful initialization clears prior errors only when no newer native error followed its last successful transaction. +- Add optional `route_queue_capacity` to device and discovery runtime configuration for the Python policy. The unset Python value selects 256 routed receive frames per device; a positive override reaches the existing Runtime inbox and survives recovery. Native defaults remain 512, and Native configurations reject the new Python-only override. Invalid capacity is rejected before transport creation. No receive layer or physical send queue is added. + +Existing C++ method signatures and Native defaults remain available. Public configuration additions require rebuilding the SDK and downstream consumers with matching headers and libraries; binary compatibility with earlier headers is not claimed. The minimum and paired transport remain v0.3.4 (`0e190032d65ed0b315337b45b3779cf4f650ed68`). + +Validation uses offline FakeTransport lifecycle, coalesced-reader, shared-Session isolation, error-ordering and inbox-overflow regressions, source/prebuilt/installed consumers, sanitizers, complete static analysis and the twelve-target release matrix. These checks do not add physical-device acceptance. Deployed adapters/drivers, firmware, physical timestamps, mixed CAN/FD topology and multi-device duration tests require separate hardware validation. O20, Python bindings and hand/arm integration are outside this patch; tactile-stage angle scheduling, extended identity parity and optional automatic reconnect remain separate work. + ## 0.3.7 - Extend the explicit `RuntimeBehavior::kPython` policy with publication-based channel sequences and coherent retained snapshots. Cache readers do not advance acquisition counters. Identity reads carry independent sample metadata; closed Python-policy caches retain identity evidence with invalid metadata. diff --git a/docs/en/guides/python-behavior-baseline.md b/docs/en/guides/python-behavior-baseline.md index fbcace9..46804a1 100644 --- a/docs/en/guides/python-behavior-baseline.md +++ b/docs/en/guides/python-behavior-baseline.md @@ -136,3 +136,13 @@ The following additions apply to the explicit Python runtime policy; native defa - `PollingChannel::angle_feedback` selects `kQueryOnly`, `kCommandFeedbackOnly` or `kAdaptive`; the Python-policy default is adaptive. Command-only mode requires a declared verified current-state response and never repeats motion targets. Query-only mode does not use command feedback to postpone queries. Adaptive mode postpones only redundant angle queries after verified feedback. Feedback, completed-read, timeout and missed-deadline metrics remain distinct. Reapplying an equivalent plan preserves deadlines and metrics. All physical frames still pass through the existing Session. Python bindings must map retained snapshot and filtered wait APIs explicitly. GIL, callback, destructor/interpreter-exit and allocation-failure proof, wall-clock conversion and shared hand/arm channel integration are not provided by this native patch. Hardware acceptance remains separate. O20 and automatic reconnect are not included. + +## Fourth compatibility batch (0.3.8) + +Only the explicit Python policy changes. `stop_polling()` removes future/unclaimed poll demand and waits up to `shutdown_timeout` for a polling transaction already claimed by the read coordinator. Stopping a poll does not cancel a coalesced active reader. A timed-out poll observer does not mean its transaction has finished. A stop timeout keeps the device open and leaves the desired plan disabled, including across recovery. Device-local draining never owns the shared Session sender. Native poll stop behavior and existing signatures remain unchanged. + +Public Python-policy health generations, including channel ages and recent-error context, use the same successful facade generation as retained snapshots. Closing and recovery preparation preserve that generation until successful initialization advances it. Nested Session/transport generations retain their physical meaning. Optional-query degradation replaces only the error revision produced by that query and does not double-count failures or hide later errors. Successful initialization clears prior errors after baseline completion without erasing a newer native error. + +`HandDeviceConfig::route_queue_capacity` and `HandRuntimeConfig::route_queue_capacity` are optional Python-only settings. Unset selects 256 frames per existing device route inbox; a positive override is propagated through discovery, direct creation and recovery. This differs from `read_waiter_capacity` (active caller capacity) and the Session's transmit capacity. Native construction retains its 512-frame default and rejects the new override. Conflicting capacity for an existing endpoint is rejected without rebinding it. Existing C++ signatures remain available; rebuild SDK and consumers after the appended configuration fields. + +This batch is offline preparation. It does not add O20, Python bindings, hand/arm integration or hardware acceptance. Tactile-stage angle scheduling, extended/raw identity fields, O30 field-by-field identity queries and optional automatic reconnect remain separate work. diff --git a/docs/en/guides/releases.md b/docs/en/guides/releases.md index fa1563b..f363d85 100644 --- a/docs/en/guides/releases.md +++ b/docs/en/guides/releases.md @@ -16,7 +16,7 @@ availability of proprietary ARM64 drivers. Hand SDK packages contain both hand and paired transport static libraries, headers, CMake packages, documentation and read-only command-line examples. Test executables and proprietary drivers/libraries -are excluded. Versions remain independent. Hand 0.3.7 requires transport 0.3.4 or newer in +are excluded. Versions remain independent. Hand 0.3.8 requires transport 0.3.4 or newer in the 0.3 minor line in source, prebuilt and installed-consumer builds. diff --git a/docs/en/guides/validation.md b/docs/en/guides/validation.md index 9e82754..d113164 100644 --- a/docs/en/guides/validation.md +++ b/docs/en/guides/validation.md @@ -14,7 +14,7 @@ A parent-provided `linkerbot::comm` target is reused and version-checked. The legacy empty `LINKERBOT_COMM_SOURCE_DIR=` still selects package discovery through `CMAKE_PREFIX_PATH` in AUTO mode. Configure logs identify the selected dependency. Use separate build directories when switching dependencies to avoid stale CMake caches. -Hand 0.3.7 requires transport 0.3.4 or newer in the 0.3 minor line. Source, +Hand 0.3.8 requires transport 0.3.4 or newer in the 0.3 minor line. Source, prebuilt and installed-consumer checks reject older patches and other minor lines. For paired development, keep the source commit pinned in README and CI; record the actual revision and configuration in test reports. diff --git a/docs/zh/guides/python-behavior-baseline.md b/docs/zh/guides/python-behavior-baseline.md index 226cdef..ea1e184 100644 --- a/docs/zh/guides/python-behavior-baseline.md +++ b/docs/zh/guides/python-behavior-baseline.md @@ -107,3 +107,13 @@ runtime.recovery_quiet = 200ms; - 统一角度轮询提供 query-only、command-feedback-only 和 adaptive 三种策略,Python 策略默认 adaptive。只有已声明为实际状态的命令反馈才能满足采样需求;command-only 不补发运动目标,query-only 不因命令反馈推迟查询。相同计划不重置期限或指标,物理帧继续交给既有 Session 发送。 绑定层仍需完成 API、异常、不可变对象及墙钟转换映射,并证明内存分配失败、GIL、回调、析构和解释器退出的行为。本批不接入 hand/arm,不包含 O20、自动重连或新的实机验收。实际后端、适配器/驱动、固件、触觉布局、时间戳及多设备长时间运行需独立验收。 + +## 第四批兼容补全(0.3.8) + +本批仅修改显式 Python 策略。`stop_polling()` 删除未来及未领取的轮询需求,并在 `shutdown_timeout` 内等待读取协调器已领取的轮询事务结束;不取消合并的主动读取。轮询观察者超时不等于底层事务结束。停止等待超时后设备仍打开,期望轮询计划保持停用,恢复时也不重新启用该计划。等待仅属于本设备,不占用共享 Session 发送器。Native 停止轮询行为和现有签名不变。 + +Python 策略公开健康状态、通道采样年龄和最近错误上下文的代次,与缓存使用同一成功打开/恢复代次;关闭及恢复准备期间保留上一成功代次,成功初始化后再推进。嵌套 Session/transport 代次仍表示物理连接。可选查询降级只替换该查询产生的错误记录,不重复计数,也不覆盖后续错误;成功初始化在基线完成后清理旧错误,不抹去更新的原生错误。 + +新增 `HandDeviceConfig::route_queue_capacity` 和 `HandRuntimeConfig::route_queue_capacity`,仅允许 Python 策略使用。未指定时,现有每设备路由接收队列容量为 256 帧;正值覆盖通过直接构造、发现和恢复传递。它不是 `read_waiter_capacity` 主动等待者容量,也不是 Session 发送队列容量。Native 构造仍使用默认 512 帧,并拒绝新的容量覆盖。同一端点已有不同容量绑定时拒绝复用,不修改其连接。新增结构体字段要求重新编译 SDK 和消费者。 + +本批为离线准备,不增加 O20、Python 绑定、hand/arm 接入或实机验收。触觉阶段之间的角度调度、扩展及原始身份字段、O30 逐字段身份查询和可选自动重连仍为独立工作。 diff --git a/docs/zh/guides/releases.md b/docs/zh/guides/releases.md index 1b2e753..5d0a2ca 100644 --- a/docs/zh/guides/releases.md +++ b/docs/zh/guides/releases.md @@ -15,7 +15,7 @@ libstdc++ ABI1 和 C++17。Windows 使用原生架构 runner 与 v143 14.44 工 产物是开发 SDK,Hand 包包含 hand 与配套 transport 静态库、头文件、CMake 包和文档, 保留只读命令行示例;不包含测试程序、厂商驱动或厂商库。 -两个项目版本独立。hand 0.3.7 的源码、预构建及安装消费者均要求 transport 0.3.x 且不低于 0.3.4。 +两个项目版本独立。hand 0.3.8 的源码、预构建及安装消费者均要求 transport 0.3.x 且不低于 0.3.4。 配套源码固定提交和预构建下载统一使用 v0.3.4 发行版。transport 公共结构有新增字段, diff --git a/docs/zh/guides/validation.md b/docs/zh/guides/validation.md index 9109308..58f5f36 100644 --- a/docs/zh/guides/validation.md +++ b/docs/zh/guides/validation.md @@ -13,7 +13,7 @@ 父项目已经提供 `linkerbot::comm` 时,复用父项目的目标,并检查版本。 显式设置旧参数 `LINKERBOT_COMM_SOURCE_DIR=` 仍可在 AUTO 下通过 `CMAKE_PREFIX_PATH` 使用安装包。配置日志会显示实际选择的依赖;切换依赖时使用独立构建目录,避免旧 CMake 缓存。 -hand 0.3.7 要求 transport 0.3.x 且不低于 0.3.4。源码、预构建及安装消费者测试均拒绝更低补丁版本和其他 minor 版本。 +hand 0.3.8 要求 transport 0.3.x 且不低于 0.3.4。源码、预构建及安装消费者测试均拒绝更低补丁版本和其他 minor 版本。 源码开发继续使用 README 与 CI 固定的提交,并在测试报告中记录实际版本和构建配置。 ### 从源码构建 diff --git a/include/linkerbot/hand/api/config.hpp b/include/linkerbot/hand/api/config.hpp index c8d3954..9a0b88c 100644 --- a/include/linkerbot/hand/api/config.hpp +++ b/include/linkerbot/hand/api/config.hpp @@ -48,6 +48,9 @@ namespace linkerbot::hand { std::optional response_timeout; std::chrono::milliseconds recovery_timeout{5000}; std::chrono::milliseconds recovery_quiet{200}; + // Python policy only: nullopt selects 256 routed receive frames per device. + // Native constructors retain their existing 512-frame default. + std::optional route_queue_capacity; }; // Fully resolved input consumed by HandDevice. Direct construction fills @@ -114,5 +117,8 @@ namespace linkerbot::hand { std::optional response_timeout; std::chrono::milliseconds recovery_timeout{5000}; std::chrono::milliseconds recovery_quiet{200}; + // Python policy only: nullopt selects 256 routed receive frames per device. + // Native constructors retain their existing 512-frame default. + std::optional route_queue_capacity; }; } // namespace linkerbot::hand diff --git a/include/linkerbot/hand/api/device.hpp b/include/linkerbot/hand/api/device.hpp index fd40491..5801a42 100644 --- a/include/linkerbot/hand/api/device.hpp +++ b/include/linkerbot/hand/api/device.hpp @@ -200,6 +200,9 @@ namespace linkerbot::hand { void start_polling(); void start_polling(const PollingPlan &plan); + // Python policy: remove future poll demand and boundedly drain claimed polls. + // A timeout keeps the device open and the desired polling plan disabled. + // Coalesced active readers continue; Native stop behavior is unchanged. void stop_polling(); // Ends the active generation, recovers the backend, verifies that the diff --git a/src/hand/discovery.cpp b/src/hand/discovery.cpp index 98539bf..45b8d6a 100644 --- a/src/hand/discovery.cpp +++ b/src/hand/discovery.cpp @@ -141,6 +141,7 @@ namespace linkerbot::hand { config.empty_polling_plan_disables = runtime.empty_polling_plan_disables; config.runtime_behavior = runtime.runtime_behavior; config.read_waiter_capacity = runtime.read_waiter_capacity; + config.route_queue_capacity = runtime.route_queue_capacity; config.response_timeout = runtime.response_timeout; config.recovery_timeout = runtime.recovery_timeout; config.recovery_quiet = runtime.recovery_quiet; diff --git a/src/hand/o30/sensor.cpp b/src/hand/o30/sensor.cpp index e8f78e7..b67061d 100644 --- a/src/hand/o30/sensor.cpp +++ b/src/hand/o30/sensor.cpp @@ -1,3 +1,4 @@ +#include "../unified/detail/runtime_access.hpp" #include "linkerbot/hand/o30/sensor_manager.hpp" #include @@ -216,7 +217,11 @@ namespace linkerbot::hand::o30 { throw UnsupportedOperationError("unified O30 tactile fields require HOP 0.0.4"); } SensorData result; - const auto deadline = std::chrono::steady_clock::now() + timeout_ms; + // Python response windows pass an unbounded composite budget. Saturate + // its conversion instead of adding the rounded duration to a later now. + const auto deadline = unified_detail::RuntimeAccess::uses_response_windows(*client_->runtime()) + ? runtime::deadline_after(timeout_ms) + : std::chrono::steady_clock::now() + timeout_ms; const auto remaining = [&]() { const auto value = std::chrono::duration_cast(deadline - std::chrono::steady_clock::now()); diff --git a/src/hand/runtime/device_runtime.cpp b/src/hand/runtime/device_runtime.cpp index a9d697f..b088465 100644 --- a/src/hand/runtime/device_runtime.cpp +++ b/src/hand/runtime/device_runtime.cpp @@ -101,6 +101,7 @@ namespace linkerbot::hand::runtime { const std::map *samples; }; thread_local std::optional current_snapshot_view; + thread_local std::optional construction_inbox_capacity; std::string canonical_channel(std::string_view domain) { if (domain == "angle" || domain == "angles") { @@ -346,6 +347,7 @@ namespace linkerbot::hand::runtime { unified_detail::RuntimeAccess::PlanTransform transform; Deadline deadline; std::function generation_valid; + unified_detail::RuntimeAccess::ErrorCapture error_capture; }; thread_local std::map response_policies; } @@ -378,6 +380,7 @@ namespace linkerbot::hand::runtime { bool return_after_send = false; bool scoped_command = false; bool scoped_response_policy = false; + unified_detail::RuntimeAccess::ErrorCapture error_capture; bool response_deadline_armed = false; bool response_window_expiry = false; std::optional receipt_response_deadline() const { @@ -565,6 +568,8 @@ namespace linkerbot::hand::runtime { std::map command_family_states; std::map domain_sequences; std::deque transaction_latencies; + uint64_t error_revision = 0; + uint64_t last_success_error_revision = 0; std::optional recent_error; uint64_t consecutive_errors = 0; uint64_t reconnect_count = 0; @@ -761,6 +766,7 @@ namespace linkerbot::hand::runtime { } catch (const HandError &hand_error) { health.last_error = hand_error.what(); recent_error = error_info(hand_error); + ++error_revision; ++consecutive_errors; } return classified; @@ -854,6 +860,9 @@ namespace linkerbot::hand::runtime { } if (error) { error = record_error_locked(std::move(error), state->context, ErrorCode::kProtocol); + if (state->error_capture) { + state->error_capture->store(error_revision, std::memory_order_release); + } } sync_command_receipt_locked(*state); if (isolate) { @@ -958,6 +967,9 @@ namespace linkerbot::hand::runtime { consecutive_errors = 0; if (completed_at) { last_successful_transaction = completed_at; + if (state->scoped_response_policy) { + last_success_error_revision = error_revision; + } } if (completed_latency) { transaction_latencies.push_back(*completed_latency); @@ -1365,6 +1377,9 @@ namespace linkerbot::hand::runtime { std::shared_ptr DeviceRuntime::acquire(std::shared_ptr session, std::string identity, std::vector routes, size_t capacity) { + if (construction_inbox_capacity) { + capacity = ::linkerbot::hand::detail::checked_value(construction_inbox_capacity, "missing inbox scope"); + } if (!session || identity.empty()) { throw ValidationError("runtime requires a session and identity"); } @@ -1454,6 +1469,9 @@ namespace linkerbot::hand::runtime { state->external = cancellation; state->generation_valid = std::move(generation_valid); state->scoped_response_policy = response_policies.count(this) != 0; + if (const auto policy = response_policies.find(this); policy != response_policies.end()) { + state->error_capture = policy->second.error_capture; + } state->owner = std::this_thread::get_id(); auto &p = *impl_; { @@ -2774,19 +2792,18 @@ void linkerbot::hand::unified_detail::RuntimeAccess::execute_command( restore(); } -void linkerbot::hand::unified_detail::RuntimeAccess::with_response_windows(runtime::DeviceRuntime &runtime, - PlanTransform transform, - const std::function &execute, - runtime::Deadline execution_deadline, - std::function generation_valid) { +void linkerbot::hand::unified_detail::RuntimeAccess::with_response_windows( + runtime::DeviceRuntime &runtime, PlanTransform transform, const std::function &execute, + runtime::Deadline execution_deadline, std::function generation_valid, ErrorCapture error_capture) { { const std::lock_guard lock(runtime.impl_->mutex); runtime.impl_->python_endpoint_policy = true; } auto previous = runtime::response_policies.extract(&runtime); try { - runtime::response_policies.emplace( - &runtime, runtime::ResponsePolicy{std::move(transform), execution_deadline, std::move(generation_valid)}); + runtime::response_policies.emplace(&runtime, runtime::ResponsePolicy{std::move(transform), execution_deadline, + std::move(generation_valid), + std::move(error_capture)}); } catch (...) { if (!previous.empty()) { runtime::response_policies.insert(std::move(previous)); @@ -2912,3 +2929,52 @@ void linkerbot::hand::unified_detail::RuntimeAccess::wait_command_feedback( [](const auto &request) { return request->plan.optional_response_window.has_value(); }); }); } + +linkerbot::hand::unified_detail::RuntimeAccess::InboxScope::InboxScope(size_t capacity) + : previous_(runtime::construction_inbox_capacity) { + if (capacity == 0) { + throw ValidationError("inbox capacity must be positive"); + } + runtime::construction_inbox_capacity = capacity; +} +linkerbot::hand::unified_detail::RuntimeAccess::InboxScope::~InboxScope() { + runtime::construction_inbox_capacity = previous_; +} +size_t linkerbot::hand::unified_detail::RuntimeAccess::inbox_capacity(const runtime::DeviceRuntime &device) { + const std::lock_guard lock(device.impl_->mutex); + return device.impl_->inbox_capacity; +} +uint64_t linkerbot::hand::unified_detail::RuntimeAccess::error_revision(const runtime::DeviceRuntime &device) { + const std::lock_guard lock(device.impl_->mutex); + return device.impl_->error_revision; +} +void linkerbot::hand::unified_detail::RuntimeAccess::promote_error(runtime::DeviceRuntime &device, uint64_t revision, + const HandError &error, + std::optional unrecorded_revision) { + const std::lock_guard lock(device.impl_->mutex); + // A later observer/transport error must not be replaced by an older query's downgrade. + if (revision != 0 && revision == device.impl_->error_revision) { + auto info = runtime::error_info(error); + device.impl_->recent_error = std::move(info); + device.impl_->health.last_error = error.what(); + } else if (revision == 0 && unrecorded_revision && + unrecorded_revision.value_or(0) == device.impl_->error_revision) { + // Some protocol adapters reject a decoded value after a successful native + // read. That logical failure has not yet been counted by the runtime. + (void)device.impl_->record_error_locked( + std::make_exception_ptr(UnsupportedFirmwareError(error.what(), error.context(), error.cause())), + device.impl_->error_context("runtime.read", error.context().channel)); + } +} + +void linkerbot::hand::unified_detail::RuntimeAccess::initialized_health(runtime::DeviceRuntime &device) { + const std::lock_guard lock(device.impl_->mutex); + if (device.impl_->python_endpoint_policy && + device.impl_->last_success_error_revision == device.impl_->error_revision) { + device.impl_->recent_error.reset(); + device.impl_->health.last_error.clear(); + device.impl_->consecutive_errors = 0; + ++device.impl_->error_revision; + device.impl_->last_success_error_revision = device.impl_->error_revision; + } +} diff --git a/src/hand/unified/backend.cpp b/src/hand/unified/backend.cpp index 9184a36..a3d76d6 100644 --- a/src/hand/unified/backend.cpp +++ b/src/hand/unified/backend.cpp @@ -351,7 +351,11 @@ namespace linkerbot::hand::unified_detail { static_cast(semantic), deadline, [this, semantic, valid, polling](const std::shared_ptr &lane) { ReadValue result; - with_windows([&] { result = python_read(semantic, polling, valid, lane); }, valid); + auto capture = std::make_shared>(0); + const auto before = RuntimeAccess::error_revision(*device_->runtime()); + with_python_read_windows( + [&] { result = python_read(semantic, polling, valid, lane, capture, before); }, + valid, capture); return result; }, [this, valid](const std::function &demand) { @@ -492,6 +496,7 @@ namespace linkerbot::hand::unified_detail { } void finish_initialization() override { if (python_) { + RuntimeAccess::initialized_health(*device_->runtime()); python_->initialized(); } } @@ -703,7 +708,16 @@ namespace linkerbot::hand::unified_detail { if (python_) { python_->stop_polling(); } - device_->stop_polling(); + if (reads_) { + const auto deadline = runtime::deadline_after(config_.shutdown_timeout); + reads_->cancel_polls(); + device_->stop_polling(); + if (!reads_->wait_polls_idle(deadline)) { + throw TimeoutError("polling transaction did not stop before shutdown deadline"); + } + } else { + device_->stop_polling(); + } } void wait_quiet(std::chrono::milliseconds quiet, runtime::Deadline deadline, const std::function &cancelled) override { @@ -767,7 +781,8 @@ namespace linkerbot::hand::unified_detail { private: TelemetrySample python_read(TelemetrySemantic semantic, bool polling, const std::function &valid, - const std::shared_ptr &lane) { + const std::shared_ptr &lane, const RuntimeAccess::ErrorCapture &capture, + uint64_t before) { if (python_->disabled(semantic)) { throw UnsupportedFirmwareError("optional telemetry is disabled for this generation"); } @@ -800,8 +815,14 @@ namespace linkerbot::hand::unified_detail { python_->read_succeeded(semantic, polling); return result; } catch (const HandError &error) { - python_->read_failed(semantic, device_->runtime()->runtime_health().sent_frames > sent, error, - polling); + try { + python_->read_failed(semantic, device_->runtime()->runtime_health().sent_frames > sent, error, + polling); + } catch (const UnsupportedFirmwareError &downgraded) { + RuntimeAccess::promote_error(*device_->runtime(), capture->load(std::memory_order_acquire), + downgraded, before); + throw; + } throw; } } @@ -813,6 +834,13 @@ namespace linkerbot::hand::unified_detail { read_epoch_.load(std::memory_order_acquire) == epoch; }; } + void with_python_read_windows(const std::function &execute, const std::function &valid, + const RuntimeAccess::ErrorCapture &error_capture) { + execute_python_transactions( + *device_->runtime(), config_, execute, + runtime::Deadline{runtime::Clock::duration{execution_deadline_.load(std::memory_order_acquire)}}, + valid, false, error_capture); + } void with_windows(const std::function &execute, const std::function &valid = {}, bool identity = false) { if (config_.runtime_behavior != RuntimeBehavior::kPython) { @@ -851,6 +879,76 @@ namespace linkerbot::hand::unified_detail { template [[nodiscard]] Side concrete_side(HandSide side) { return side == HandSide::kLeft ? Side::kLeft : Side::kRight; } + std::unique_ptr make_model_backend(const std::shared_ptr &session, + const HandDeviceConfig &config, + std::shared_ptr owned_session) { + const bool injected = static_cast(session); + switch (config.model) { + case Model::kL6: { + const auto side = concrete_side(config.side); + if (config.l6_protocol == L6Protocol::kHopV004) { + auto hand = + std::make_unique(side, session, config.address.value_or(l6::hop::kDefaultRequestId)); + return backend(std::move(hand), std::move(owned_session), config); + } + auto hand = + injected ? std::make_unique(side, session, config.interface_name, config.angle_mapping_path) + : std::make_unique(side, config.interface_name, config.angle_mapping_path); + return backend(std::move(hand), std::move(owned_session), config); + } + case Model::kO6: { + const auto side = concrete_side(config.side); + auto hand = + injected ? std::make_unique(side, session, config.interface_name, config.angle_mapping_path) + : std::make_unique(side, config.interface_name, config.angle_mapping_path); + return backend(std::move(hand), std::move(owned_session), config); + } + case Model::kL20Lite: { + const auto side = concrete_side(config.side); + auto hand = injected ? std::make_unique(side, session, config.interface_name, + config.angle_mapping_path) + : std::make_unique(side, config.interface_name, + config.angle_mapping_path); + return backend(std::move(hand), std::move(owned_session), + config); + } + case Model::kL20: { + const auto side = concrete_side(config.side); + auto hand = + injected + ? std::make_unique(side, session, config.interface_name, config.angle_mapping_path) + : std::make_unique(side, config.interface_name, config.angle_mapping_path); + return backend(std::move(hand), std::move(owned_session), config); + } + case Model::kL30: { + const auto node_id = static_cast(config.address.value_or(l30::kDefaultNodeId)); + const auto host_id = static_cast(config.host_address.value_or(l30::kDefaultHostId)); + auto hand = + injected ? std::make_unique(session, node_id, host_id, config.auto_start_periodic_reports) + : std::make_unique(config.interface_name, node_id, host_id, + config.auto_start_periodic_reports); + return backend(std::move(hand), std::move(owned_session), config); + } + case Model::kO30: { + const auto request_id = config.o30_layout == O30Layout::kPythonV25 + ? config.address.value_or(config.side == HandSide::kLeft ? 2U : 1U) + : config.address.value_or(o30::protocol::kDefaultRequestId); + auto hand = injected ? std::make_unique(session, request_id) + : std::make_unique(config.interface_name, request_id); + return backend(std::move(hand), std::move(owned_session), config); + } + case Model::kUnknown: + break; + } + throw ValidationError("HandDevice model must not be Unknown"); + } + + std::unique_ptr make_python_model_backend(const std::shared_ptr &session, + const HandDeviceConfig &config, + std::shared_ptr owned_session) { + const RuntimeAccess::InboxScope inbox_scope(config.route_queue_capacity.value_or(256)); + return make_model_backend(session, config, std::move(owned_session)); + } } // namespace std::unique_ptr make_backend(const HandDeviceConfig &config, @@ -883,62 +981,9 @@ namespace linkerbot::hand::unified_detail { if (session && config.runtime_behavior == RuntimeBehavior::kPython) { RuntimeAccess::retire_closed_profiles(session); } - const bool injected = static_cast(session); - switch (config.model) { - case Model::kL6: { - const auto side = concrete_side(config.side); - if (config.l6_protocol == L6Protocol::kHopV004) { - auto hand = - std::make_unique(side, session, config.address.value_or(l6::hop::kDefaultRequestId)); - return backend(std::move(hand), std::move(owned_session), config); - } - auto hand = injected - ? std::make_unique(side, session, config.interface_name, config.angle_mapping_path) - : std::make_unique(side, config.interface_name, config.angle_mapping_path); - return backend(std::move(hand), std::move(owned_session), config); - } - case Model::kO6: { - const auto side = concrete_side(config.side); - auto hand = injected - ? std::make_unique(side, session, config.interface_name, config.angle_mapping_path) - : std::make_unique(side, config.interface_name, config.angle_mapping_path); - return backend(std::move(hand), std::move(owned_session), config); - } - case Model::kL20Lite: { - const auto side = concrete_side(config.side); - auto hand = - injected ? std::make_unique(side, session, config.interface_name, - config.angle_mapping_path) - : std::make_unique(side, config.interface_name, config.angle_mapping_path); - return backend(std::move(hand), std::move(owned_session), config); - } - case Model::kL20: { - const auto side = concrete_side(config.side); - auto hand = - injected ? std::make_unique(side, session, config.interface_name, config.angle_mapping_path) - : std::make_unique(side, config.interface_name, config.angle_mapping_path); - return backend(std::move(hand), std::move(owned_session), config); - } - case Model::kL30: { - const auto node_id = static_cast(config.address.value_or(l30::kDefaultNodeId)); - const auto host_id = static_cast(config.host_address.value_or(l30::kDefaultHostId)); - auto hand = injected - ? std::make_unique(session, node_id, host_id, config.auto_start_periodic_reports) - : std::make_unique(config.interface_name, node_id, host_id, - config.auto_start_periodic_reports); - return backend(std::move(hand), std::move(owned_session), config); - } - case Model::kO30: { - const auto request_id = config.o30_layout == O30Layout::kPythonV25 - ? config.address.value_or(config.side == HandSide::kLeft ? 2U : 1U) - : config.address.value_or(o30::protocol::kDefaultRequestId); - auto hand = injected ? std::make_unique(session, request_id) - : std::make_unique(config.interface_name, request_id); - return backend(std::move(hand), std::move(owned_session), config); - } - case Model::kUnknown: - break; + if (config.runtime_behavior == RuntimeBehavior::kPython) { + return make_python_model_backend(session, config, std::move(owned_session)); } - throw ValidationError("HandDevice model must not be Unknown"); + return make_model_backend(session, config, std::move(owned_session)); } } // namespace linkerbot::hand::unified_detail diff --git a/src/hand/unified/common.cpp b/src/hand/unified/common.cpp index 92e020a..c5b5666 100644 --- a/src/hand/unified/common.cpp +++ b/src/hand/unified/common.cpp @@ -102,6 +102,10 @@ namespace linkerbot::hand::unified_detail { if (config.response_timeout && config.response_timeout->count() <= 0) { throw ValidationError("response_timeout must be positive when specified"); } + if (config.route_queue_capacity && + (config.runtime_behavior != RuntimeBehavior::kPython || config.route_queue_capacity.value_or(0) == 0)) { + throw ValidationError("route_queue_capacity requires Python policy and a positive value"); + } if (config.read_waiter_capacity == 0) { throw ValidationError("read_waiter_capacity must be positive"); } diff --git a/src/hand/unified/detail/python_transactions.hpp b/src/hand/unified/detail/python_transactions.hpp index 5c72710..69f4042 100644 --- a/src/hand/unified/detail/python_transactions.hpp +++ b/src/hand/unified/detail/python_transactions.hpp @@ -2,6 +2,7 @@ #include "classic_response.hpp" #include "python_l30.hpp" #include "python_tactile.hpp" +#include "runtime_access.hpp" #include #include #include @@ -10,7 +11,8 @@ namespace linkerbot::hand::unified_detail { void execute_python_transactions(runtime::DeviceRuntime &device, const HandDeviceConfig &config, const std::function &execute, runtime::Deadline deadline, - const std::function &valid, bool identity); + const std::function &valid, bool identity, + RuntimeAccess::ErrorCapture error_capture = {}); inline const char *python_polling_domain(TelemetrySemantic semantic) { static constexpr std::array names{"angle", diff --git a/src/hand/unified/detail/read_coordinator.hpp b/src/hand/unified/detail/read_coordinator.hpp index fcbb4bf..f0cd93a 100644 --- a/src/hand/unified/detail/read_coordinator.hpp +++ b/src/hand/unified/detail/read_coordinator.hpp @@ -58,6 +58,9 @@ namespace linkerbot::hand::unified_detail { if (!polling && waiters_ == capacity_) { throw QueueFullError("active read waiter capacity is full"); } + if (polling && (poll_cancel.cancelled() || (poll_demand && !poll_demand()))) { + throw ClosedError("poll demand ended before admission"); + } auto found = entries_.find(key); std::shared_ptr entry; if (found == entries_.end()) { @@ -90,9 +93,10 @@ namespace linkerbot::hand::unified_detail { changed_.notify_all(); bool completed = true; if (deadline == Clock::time_point::max()) { - changed_.wait(lock, [&] { return entry->done; }); + changed_.wait(lock, [&] { return entry->done || (polling && !entry->requested_by_poll); }); } else { - completed = changed_.wait_until(lock, deadline, [&] { return entry->done; }); + completed = changed_.wait_until(lock, deadline, + [&] { return entry->done || (polling && !entry->requested_by_poll); }); } if (!polling) { if (generation == generation_) { @@ -111,6 +115,9 @@ namespace linkerbot::hand::unified_detail { } } } + if (polling && !entry->done && !entry->requested_by_poll) { + throw ClosedError("poll demand stopped"); + } if (!completed || entry->completed_at >= deadline) { throw TimeoutError("active read did not complete before its deadline"); } @@ -140,6 +147,23 @@ namespace linkerbot::hand::unified_detail { queue_.clear(); changed_.notify_all(); } + // Stop poll observers without cancelling shared transactions or active readers. + void cancel_polls() { + const std::lock_guard lock(mutex_); + for (auto &[key, entry] : entries_) { + (void)key; + entry->requested_by_poll = false; + } + changed_.notify_all(); + } + bool wait_polls_idle(Clock::time_point deadline) { + std::unique_lock lock(mutex_); + return changed_.wait_until(lock, deadline, [&] { return !active_poll_; }); + } + size_t waiter_count() { + const std::lock_guard lock(mutex_); + return waiters_; + } void drain() { std::unique_lock lock(mutex_); changed_.wait(lock, [&] { return !active_; }); @@ -236,6 +260,8 @@ namespace linkerbot::hand::unified_detail { if (!demand_locked(*entry)) { throw ClosedError("unclaimed read no longer has a reader or poll demand"); } + active_poll_ = entry->requested_by_poll && !entry->poll_cancel.cancelled() && + (!entry->poll_demand || entry->poll_demand()); entry->claimed = true; } value.emplace(entry->execute(lease)); @@ -260,6 +286,7 @@ namespace linkerbot::hand::unified_detail { entries_.erase(it); } active_ = false; + active_poll_ = false; changed_.notify_all(); } } @@ -273,6 +300,7 @@ namespace linkerbot::hand::unified_detail { uint64_t generation_ = 0; bool accepting_ = true; bool active_ = false; + bool active_poll_ = false; bool stopping_ = false; std::thread worker_; }; diff --git a/src/hand/unified/detail/runtime_access.hpp b/src/hand/unified/detail/runtime_access.hpp index 1e67fac..b82852c 100644 --- a/src/hand/unified/detail/runtime_access.hpp +++ b/src/hand/unified/detail/runtime_access.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include @@ -8,6 +9,22 @@ namespace linkerbot::hand::unified_detail { // making their entire composite read share the unified execution arbiter. class RuntimeAccess { public: + class InboxScope { + public: + explicit InboxScope(size_t capacity); + ~InboxScope(); + InboxScope(const InboxScope &) = delete; + InboxScope &operator=(const InboxScope &) = delete; + + private: + std::optional previous_; + }; + static void initialized_health(runtime::DeviceRuntime &); + static size_t inbox_capacity(const runtime::DeviceRuntime &); + using ErrorCapture = std::shared_ptr>; + static uint64_t error_revision(const runtime::DeviceRuntime &); + static void promote_error(runtime::DeviceRuntime &, uint64_t revision, const HandError &error, + std::optional unrecorded_revision = {}); using PublicationHandler = std::function; // SDK-internal sink; runs under the runtime lock and must not reenter it. static void set_publication_handler(runtime::DeviceRuntime &, PublicationHandler); @@ -22,7 +39,7 @@ namespace linkerbot::hand::unified_detail { static void with_response_windows(runtime::DeviceRuntime &runtime, PlanTransform transform, const std::function &execute, runtime::Deadline execution_deadline = runtime::Deadline::max(), - std::function generation_valid = {}); + std::function generation_valid = {}, ErrorCapture error_capture = {}); using SendObserver = std::function; using Submission = std::function; using AdmitSend = std::function; diff --git a/src/hand/unified/device.cpp b/src/hand/unified/device.cpp index c6661fa..92e1a23 100644 --- a/src/hand/unified/device.cpp +++ b/src/hand/unified/device.cpp @@ -461,6 +461,20 @@ namespace linkerbot::hand { } } + void project_public_health(HandHealth &health, State state) const { + project_health(health, state); + if (config_.runtime_behavior == RuntimeBehavior::kPython) { + health.connection_generation = cache_generation_; + for (auto &channel : health.channels) { + channel.data_age.generation = cache_generation_; + } + if (health.recent_error) { + ::linkerbot::hand::detail::checked_value(health.recent_error, "missing recent error") + .context.generation = cache_generation_; + } + } + } + [[nodiscard]] CommandHandle submit_raw(CommandSemantic semantic, const std::vector &values, const char *operation, std::optional timeout = {}) { @@ -867,7 +881,7 @@ namespace linkerbot::hand { const auto state = impl_->state_.load(std::memory_order_acquire); if (!impl_->backend_ || (state != Impl::State::kReady && state != Impl::State::kFaulted)) { HandHealth health; - Impl::project_health(health, state); + impl_->project_public_health(health, state); health.command_families = impl_->scheduler_->metrics(); return health; } @@ -879,9 +893,13 @@ namespace linkerbot::hand { if (generation != impl_->generation_ || selected != impl_->backend_) { health = HandHealth{}; } - Impl::project_health(health, impl_->state_.load(std::memory_order_acquire)); + impl_->project_public_health(health, impl_->state_.load(std::memory_order_acquire)); if (impl_->state_.load(std::memory_order_acquire) == Impl::State::kFaulted && impl_->refresh_error_) { health.recent_error = impl_->refresh_error_; + if (impl_->config_.runtime_behavior == RuntimeBehavior::kPython && health.recent_error) { + ::linkerbot::hand::detail::checked_value(health.recent_error, "missing refresh error") + .context.generation = impl_->cache_generation_; + } } // Unified setter counters own logical acceptance/completion. Native // transaction/session counters remain available without being added a @@ -1547,6 +1565,11 @@ namespace linkerbot::hand { const std::lock_guard lifecycle(impl_->lifecycle_mutex_); const auto context = impl_->capture("stop_polling"); auto lease = impl_->begin_io(context, "stop_polling"); + if (impl_->config_.runtime_behavior == RuntimeBehavior::kPython) { + const std::lock_guard lock(impl_->mutex_); + impl_->check_context(context, "stop_polling"); + impl_->polling_plan_.reset(); + } context.backend->stop_polling(); const std::lock_guard lock(impl_->mutex_); impl_->check_context(context, "stop_polling"); diff --git a/src/hand/unified/python_state.cpp b/src/hand/unified/python_state.cpp index 2ef8129..5386102 100644 --- a/src/hand/unified/python_state.cpp +++ b/src/hand/unified/python_state.cpp @@ -241,7 +241,11 @@ namespace linkerbot::hand::unified_detail { ((error.code() == ErrorCode::kTimeout || error.code() == ErrorCode::kProtocol) && ++failures_[semantic] >= 3)) { disable_locked(semantic); - throw UnsupportedFirmwareError("firmware does not support this optional query", error.context(), + auto context = error.context(); + if (context.channel.empty()) { + context.channel = domain(semantic); + } + throw UnsupportedFirmwareError("firmware does not support this optional query", std::move(context), std::current_exception()); } } diff --git a/src/hand/unified/python_transaction_scope.cpp b/src/hand/unified/python_transaction_scope.cpp index d80415b..ffe04cd 100644 --- a/src/hand/unified/python_transaction_scope.cpp +++ b/src/hand/unified/python_transaction_scope.cpp @@ -4,7 +4,8 @@ namespace linkerbot::hand::unified_detail { void execute_python_transactions(runtime::DeviceRuntime &device, const HandDeviceConfig &config, const std::function &execute, runtime::Deadline deadline, - const std::function &valid, bool identity) { + const std::function &valid, bool identity, + RuntimeAccess::ErrorCapture error_capture) { RuntimeAccess::with_response_windows( device, [&config, identity](runtime::TransactionPlan plan) { @@ -14,6 +15,6 @@ namespace linkerbot::hand::unified_detail { } return plan; }, - execute, deadline, valid); + execute, deadline, valid, std::move(error_capture)); } } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 9d0c4bf..38b0c27 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -74,10 +74,10 @@ endif() # Hardware-in-the-loop cases live under [l6]/[o6]/[l20lite]/[l20]/[l30]/[o30] and need a real CAN interface. # Default ctest only runs FakeTransport integration tests. -add_test(NAME comm COMMAND tests "[comm]") +add_test(NAME comm COMMAND tests "[comm]" --durations yes) # Discovery contract and adapter tests are offline: injected enumerators and # FakeTransport cover platform sources without opening host CAN hardware. -add_test(NAME discovery COMMAND tests "[discovery]") +add_test(NAME discovery COMMAND tests "[discovery]" --durations yes) if(TARGET l6_read_version) add_dependencies(tests l6_read_version) diff --git a/tests/hand/device/test_device.cpp b/tests/hand/device/test_device.cpp index 484f14e..c2469bc 100644 --- a/tests/hand/device/test_device.cpp +++ b/tests/hand/device/test_device.cpp @@ -4044,6 +4044,7 @@ TEST_CASE("Python published metadata is stable across Classic and L30 models", " CAPTURE(model); auto config = config_for(model); config.runtime_behavior = RuntimeBehavior::kPython; + config.route_queue_capacity = 32; config.response_timeout = 200ms; std::shared_ptr hop; HandDevice device(config, [&] { @@ -4066,6 +4067,10 @@ TEST_CASE("Python published metadata is stable across Classic and L30 models", " device.open(); const auto reopened = device.get_cached_snapshot(); REQUIRE((reopened.connection_generation == 2)); + REQUIRE((device.get_health().connection_generation == reopened.connection_generation)); + for (const auto &channel : device.get_health().channels) { + REQUIRE((channel.data_age.generation == 2)); + } REQUIRE((test_support::require_value(reopened.angles).meta.sequence == 1)); REQUIRE((test_support::require_value(reopened.angles).meta.connection_generation == 2)); REQUIRE((test_support::require_value(reopened.device_info).meta.connection_generation == 2)); @@ -4151,3 +4156,33 @@ TEST_CASE("Python optional polling degradation preserves other poll channels and REQUIRE((device.get_currents(1s).meta.valid)); device.close(); } + +TEST_CASE("Python O30 no-sensor downgrade is recorded after successful metadata queries", "[comm][python-phase4]") { + using namespace std::chrono_literals; + auto config = config_for(Model::kO30); + config.runtime_behavior = RuntimeBehavior::kPython; + config.o30_layout = O30Layout::kPythonV25; + config.side = HandSide::kLeft; + config.identity_timeout = 300ms; + config.response_timeout = 300ms; + std::shared_ptr hop; + HandDevice device(config, [&] { + auto t = std::make_unique(); + hop = std::make_shared(); + hop->request_id = 2; + install_fake_device(Model::kO30, t.get(), hop); + hop->put_ascii(0x41, 0x61, 8, "0.0.4"); + std::vector metadata(0x73, 0); + const std::string type = "NO_SENSOR"; + std::copy(type.begin(), type.end(), metadata.begin()); + hop->put(o30::protocol::kMiSensorInfo, metadata); + return t; + }); + device.open(); + REQUIRE_THROWS_AS(device.get_force(3s), UnsupportedFirmwareError); + const auto health = device.get_health(); + REQUIRE((test_support::require_value(health.recent_error).code == ErrorCode::kUnsupportedFirmware)); + REQUIRE((health.consecutive_errors == 1)); + REQUIRE_FALSE(device.get_capabilities().supports(TelemetrySemantic::kForce)); + device.close(); +} diff --git a/tests/hand/device/test_python_polling_health.cpp b/tests/hand/device/test_python_polling_health.cpp new file mode 100644 index 0000000..24eda1b --- /dev/null +++ b/tests/hand/device/test_python_polling_health.cpp @@ -0,0 +1,392 @@ +#include "../../../src/hand/unified/detail/read_coordinator.hpp" +#include "../../../src/hand/unified/detail/runtime_access.hpp" +#include "../../../validation/fake_classic_hand.hpp" +#include "hand/support/optional_value.hpp" +#include +#include +#include + +using namespace linkerbot::hand; +using namespace std::chrono_literals; +using unified_detail::RuntimeAccess; +namespace { + HandDeviceConfig python_config() { + auto c = validation::config(); + c.runtime_behavior = RuntimeBehavior::kPython; + c.identity_timeout = 300ms; + c.response_timeout = 800ms; + c.transaction_timeout = 10ms; + return c; + } + linkerbot::can::Message angle_reply() { + linkerbot::can::Message m{}; + m.id = 0x28; + m.dlc = 7; + m.data[0] = 1; + for (size_t i = 1; i < 7; ++i) { + m.data[i] = 64; + } + return m; + } + void hold_angles(validation::FakeHand &d, std::promise &sent) { + d.transport->on_request(0x28, [&sent](const auto &r) { + if (r.data[0] == 1 && r.dlc == 1) { + sent.set_value(); + return std::vector{}; + } + return validation::l6_reply(r); + }); + } +} + +TEST_CASE("Python stop polling drains the claimed read after its poll observer times out", "[comm][python-phase4]") { + auto c = python_config(); + c.shutdown_timeout = 2s; + std::promise sent; + validation::FakeHand d(c); + hold_angles(d, sent); + d.hand.start_polling({{TelemetrySemantic::kAngles, 2s, AngleFeedbackPolicy::kQueryOnly}}); + REQUIRE((sent.get_future().wait_for(1s) == std::future_status::ready)); + std::this_thread::sleep_for(30ms); + auto stop = std::async(std::launch::async, [&] { d.hand.stop_polling(); }); + const auto before_reply = stop.wait_for(60ms); + d.transport->inject(angle_reply()); + stop.get(); + REQUIRE((before_reply == std::future_status::timeout)); + REQUIRE((d.hand.get_faults(300ms).meta.valid)); + REQUIRE_FALSE(d.hand.get_health().polling_active); + d.transport->on_request(0x28, validation::l6_reply); + d.hand.start_polling({{TelemetrySemantic::kAngles, 20ms}}); + d.hand.stop_polling(); +} + +TEST_CASE("Python stop polling reports its bound without ending the device generation", "[comm][python-phase4]") { + auto c = python_config(); + c.shutdown_timeout = 75ms; + std::promise sent; + validation::FakeHand d(c); + hold_angles(d, sent); + const auto generation = d.hand.get_cached_snapshot().connection_generation; + d.hand.start_polling({{TelemetrySemantic::kAngles, 2s}}); + REQUIRE((sent.get_future().wait_for(1s) == std::future_status::ready)); + const auto start = runtime::Clock::now(); + bool timed_out = false; + try { + d.hand.stop_polling(); + } catch (const TimeoutError &) { + timed_out = true; + } + const auto elapsed = runtime::Clock::now() - start; + d.transport->inject(angle_reply()); + REQUIRE((timed_out)); + REQUIRE((elapsed < 500ms)); + REQUIRE((d.hand.is_open())); + REQUIRE((d.hand.get_faults(1s).meta.valid)); + REQUIRE((d.hand.get_cached_snapshot().connection_generation == generation)); + REQUIRE_NOTHROW(d.hand.stop_polling()); + d.hand.recover_for(2s); + REQUIRE_FALSE(d.hand.get_health().polling_active); +} + +TEST_CASE("poll cancellation preserves a shared reader and drains only the claimed poll", "[comm][python-phase4]") { + unified_detail::ReadCoordinator reads(4); + std::promise entered, release; + auto gate = release.get_future().share(); + auto poll = std::async(std::launch::async, [&] { + try { + (void)reads.read( + 1, runtime::Clock::now() + 2s, + [&] { + entered.set_value(); + gate.wait(); + return 42; + }, + true); + return false; + } catch (const ClosedError &) { + return true; + } + }); + entered.get_future().wait(); + auto reader = + std::async(std::launch::async, [&] { return reads.read(1, runtime::Clock::now() + 2s, [] { return 99; }); }); + const auto joined_deadline = runtime::Clock::now() + 1s; + while (reads.waiter_count() == 0 && runtime::Clock::now() < joined_deadline) { + std::this_thread::yield(); + } + const bool joined = reads.waiter_count() == 1; + reads.cancel_polls(); + const auto cancelled = poll.wait_for(200ms); + const bool idle_before = reads.wait_polls_idle(runtime::Clock::now() + 20ms); + release.set_value(); + const bool stopped = poll.get(); + const auto value = reader.get(); + REQUIRE((joined)); + REQUIRE((cancelled == std::future_status::ready)); + REQUIRE((stopped)); + REQUIRE_FALSE(idle_before); + REQUIRE((value == 42)); + REQUIRE((reads.wait_polls_idle(runtime::Clock::now() + 1s))); +} + +TEST_CASE("poll cancellation does not wait for a read claimed without poll demand", "[comm][python-phase4]") { + unified_detail::ReadCoordinator reads(4); + std::promise entered, release; + auto gate = release.get_future().share(); + auto reader = std::async(std::launch::async, [&] { + return reads.read(1, runtime::Clock::now() + 2s, [&] { + entered.set_value(); + gate.wait(); + return 42; + }); + }); + entered.get_future().wait(); + reads.cancel_polls(); + const bool idle = reads.wait_polls_idle(runtime::Clock::now() + 20ms); + release.set_value(); + REQUIRE((reader.get() == 42)); + REQUIRE((idle)); +} + +TEST_CASE("Python health uses the successful facade generation across reopen and recovery", "[comm][python-phase4]") { + validation::FakeHand d(python_config()); + for (uint64_t generation = 1; generation <= 3; ++generation) { + const auto snapshot = d.hand.get_cached_snapshot(); + const auto health = d.hand.get_health(); + REQUIRE((snapshot.connection_generation == generation)); + REQUIRE((health.connection_generation == generation)); + REQUIRE_FALSE(health.channels.empty()); + for (const auto &channel : health.channels) { + REQUIRE((channel.data_age.generation == generation)); + } + if (generation == 1) { + d.hand.close(); + REQUIRE((d.hand.get_health().connection_generation == generation)); + d.hand.open(); + } else if (generation == 2) { + const auto physical = health.session.generation; + d.hand.recover_for(2s); + REQUIRE((d.hand.get_health().session.generation > physical)); + } + } +} + +TEST_CASE("Native health retains its original generation behavior on reopen", "[comm][python-phase4]") { + validation::FakeHand d; + const auto native = d.hand.get_health().connection_generation; + d.hand.close(); + d.hand.open(); + REQUIRE((d.hand.get_health().connection_generation == native)); + REQUIRE((d.hand.get_cached_snapshot().connection_generation == 2)); +} + +TEST_CASE("Python firmware downgrade records the final error once and preserves later errors", + "[comm][python-phase4]") { + auto c = python_config(); + c.response_timeout = 40ms; + validation::FakeHand d(c); + d.transport->on_request(0x28, [](const auto &r) { + if (r.data[0] == 0x36) { + return std::vector{}; + } + return validation::l6_reply(r); + }); + REQUIRE_THROWS_AS(d.hand.get_currents(1s), TimeoutError); + REQUIRE_THROWS_AS(d.hand.get_currents(1s), TimeoutError); + REQUIRE_THROWS_AS(d.hand.get_currents(1s), UnsupportedFirmwareError); + auto health = d.hand.get_health(); + REQUIRE((test_support::require_value(health.recent_error).code == ErrorCode::kUnsupportedFirmware)); + REQUIRE((test_support::require_value(health.recent_error).error_type == "UnsupportedFirmwareError")); + REQUIRE((health.consecutive_errors == 3)); + REQUIRE((d.hand.get_angles(1s).meta.valid)); + REQUIRE((d.hand.get_health().consecutive_errors == 0)); + d.transport->on_request(0x28, [](const auto &r) { + auto replies = validation::l6_reply(r); + replies.front().dlc = 1; + return replies; + }); + REQUIRE_THROWS_AS(d.hand.get_angles(1s), ProtocolError); + REQUIRE((test_support::require_value(d.hand.get_health().recent_error).code == ErrorCode::kProtocol)); + d.hand.recover_for(2s); + REQUIRE_FALSE(d.hand.get_health().recent_error.has_value()); +} + +TEST_CASE("an older query downgrade cannot replace a newer native error", "[comm][python-phase4]") { + auto transport = std::make_unique(); + auto session = linkerbot::transport::Session::own(std::move(transport)); + auto core = runtime::DeviceRuntime::acquire(session, "phase4", {{0x28, false}}); + auto capture = std::make_shared>(0); + runtime::TransactionPlan plan; + plan.domain = "test"; + plan.keys = {"test"}; + plan.frames = {angle_reply()}; + plan.response_window = 20ms; + plan.poison_on_timeout = false; + plan.consume = [](const auto &) { return protocol::Progress::unmatched(); }; + REQUIRE_THROWS_AS(RuntimeAccess::with_response_windows( + *core, [](auto p) { return p; }, [&] { (void)core->execute(plan, runtime::Deadline::max()); }, + runtime::Deadline::max(), {}, capture), + TimeoutError); + const auto revision = capture->load(); + REQUIRE((revision > 0)); + core->record_error(std::make_exception_ptr(TransportError("newer driver error"))); + RuntimeAccess::promote_error(*core, revision, UnsupportedFirmwareError("old downgrade")); + RuntimeAccess::initialized_health(*core); + const auto health = core->health(); + REQUIRE((test_support::require_value(health.recent_error).code == ErrorCode::kTransport)); + REQUIRE((health.consecutive_errors == 2)); + core->close(); +} + +TEST_CASE("Python receive capacity is applied to its routed inbox and survives recovery", "[comm][python-phase4]") { + for (const auto capacity : {size_t{256}, size_t{16}}) { + auto c = python_config(); + if (capacity == 16) { + c.route_queue_capacity = capacity; + } + auto session = linkerbot::transport::Session::create([] { + auto t = std::make_unique(); + t->on_request(0x28, validation::l6_reply); + return t; + }); + HandDevice hand(c, session); + hand.open(); + auto core = runtime::DeviceRuntime::acquire(session, "l6/40", {{0x28, false}}, capacity); + REQUIRE((RuntimeAccess::inbox_capacity(*core) == capacity)); + REQUIRE_THROWS_AS(runtime::DeviceRuntime::acquire(session, "l6/40", {{0x28, false}}, capacity + 1), + ValidationError); + REQUIRE((hand.get_angles(1s).meta.valid)); + hand.recover_for(2s); + REQUIRE((RuntimeAccess::inbox_capacity(*core) == capacity)); + hand.close(); + } +} + +TEST_CASE("Native receive capacity and construction scope survive Python construction failure", + "[comm][python-phase4]") { + auto session = linkerbot::transport::Session::own(std::make_unique()); + bool threw = false; + try { + RuntimeAccess::InboxScope scope(3); + throw std::runtime_error("construction failed"); + } catch (const std::runtime_error &) { + threw = true; + } + REQUIRE((threw)); + auto native = runtime::DeviceRuntime::acquire(session, "native", {{0x28, false}}); + REQUIRE((RuntimeAccess::inbox_capacity(*native) == 512)); + native->close(); +} + +TEST_CASE("receive capacity validation rejects before creating a transport", "[comm][python-phase4]") { + for (const auto python : {false, true}) { + auto c = python_config(); + if (!python) { + c.runtime_behavior = RuntimeBehavior::kNative; + } + c.route_queue_capacity = python ? 0 : 256; + int factories = 0; + REQUIRE_THROWS_AS(HandDevice(c, + [&] { + ++factories; + return std::make_unique(); + }), + ValidationError); + REQUIRE((factories == 0)); + } +} + +TEST_CASE("Python stop polling leaves another device on the same Session responsive", "[comm][python-phase4]") { + linkerbot::can::FakeTransport *raw = nullptr; + std::promise sent; + auto session = linkerbot::transport::Session::create([&] { + auto t = std::make_unique(); + raw = t.get(); + t->on_request(0x28, validation::l6_reply); + t->on_request(0x27, [](const auto &r) { + auto replies = validation::l6_reply(r); + if (r.data[0] == 0xC0) { + for (auto &reply : replies) { + if (reply.data[1] == 1) { + reply.data[7] = 'R'; + } + } + } + return replies; + }); + return t; + }); + auto c = python_config(); + c.shutdown_timeout = 2s; + HandDevice left(c, session); + c.side = HandSide::kRight; + HandDevice right(c, session); + left.open(); + right.open(); + raw->on_request(0x28, [&sent](const auto &r) { + if (r.data[0] == 1 && r.dlc == 1) { + sent.set_value(); + return std::vector{}; + } + return validation::l6_reply(r); + }); + left.start_polling({{TelemetrySemantic::kAngles, 2s}}); + REQUIRE((sent.get_future().wait_for(1s) == std::future_status::ready)); + auto stop = std::async(std::launch::async, [&] { left.stop_polling(); }); + const auto before_reply = stop.wait_for(40ms); + bool peer_ok = false; + try { + peer_ok = right.get_faults(300ms).meta.valid; + } catch (const HandError &) { + peer_ok = false; + } + raw->inject(angle_reply()); + stop.get(); + REQUIRE((before_reply == std::future_status::timeout)); + REQUIRE((peer_ok)); + REQUIRE((right.is_open())); + left.close(); + right.close(); +} + +TEST_CASE("unclaimed poll work is discarded while an active reader continues", "[comm][python-phase4]") { + unified_detail::ReadCoordinator reads(4); + std::promise entered, release, admitted; + auto gate = release.get_future().share(); + auto reader = std::async(std::launch::async, [&] { + return reads.read(1, runtime::Clock::now() + 3s, [&] { + entered.set_value(); + gate.wait(); + return 42; + }); + }); + entered.get_future().wait(); + std::atomic executions{0}; + auto poll = std::async(std::launch::async, [&] { + try { + (void)reads.read_prepared( + 2, runtime::Clock::now() + 3s, + [&](const auto &) { + ++executions; + return 99; + }, + {}, true, {}, + [&] { + admitted.set_value(); + return true; + }); + return false; + } catch (const ClosedError &) { + return true; + } + }); + admitted.get_future().wait(); + reads.cancel_polls(); + const bool idle = reads.wait_polls_idle(runtime::Clock::now() + 20ms); + release.set_value(); + REQUIRE((reader.get() == 42)); + REQUIRE((poll.get())); + reads.drain(); + REQUIRE((idle)); + REQUIRE((executions.load() == 0)); +} diff --git a/tests/hand/test_discovery.cpp b/tests/hand/test_discovery.cpp index 9856036..c78aa84 100644 --- a/tests/hand/test_discovery.cpp +++ b/tests/hand/test_discovery.cpp @@ -691,3 +691,19 @@ TEST_CASE("discovery connection keys and descriptors include backend timing iden REQUIRE(report.failures().size() == 1); CHECK(report.failures()[0].code() == "channel_descriptor_conflict"); } + +TEST_CASE("discovery preserves Python routed receive capacity without opening a device", "[discovery][python-phase4]") { + int created = 0; + const auto candidate = verified_candidate("python-inbox", &created); + HandRuntimeConfig runtime; + runtime.runtime_behavior = RuntimeBehavior::kPython; + runtime.route_queue_capacity = 17; + const auto config = candidate.make_config(runtime); + REQUIRE((config.route_queue_capacity == 17)); + auto hand = candidate.create_hand_device(runtime); + REQUIRE_FALSE(hand->is_open()); + REQUIRE((created == 0)); + runtime.route_queue_capacity = 0; + REQUIRE_THROWS_AS(candidate.make_config(runtime), ValidationError); + REQUIRE((created == 0)); +} diff --git a/tests/runtime/test_device_runtime_comm.cpp b/tests/runtime/test_device_runtime_comm.cpp index 0170831..595400e 100644 --- a/tests/runtime/test_device_runtime_comm.cpp +++ b/tests/runtime/test_device_runtime_comm.cpp @@ -2129,3 +2129,48 @@ TEST_CASE("late physical completion cannot publish profile success before its se REQUIRE((observed.load())); REQUIRE((rig.session->health().tx_frames == 1)); } + +TEST_CASE("Python construction capacity bounds the real receive inbox", "[comm][python-phase4]") { + ReportProbe probe; + auto transport = std::make_unique(); + auto *fake = transport.get(); + auto session = Session::own(std::move(transport)); + std::shared_ptr core; + { + linkerbot::hand::unified_detail::RuntimeAccess::InboxScope scope(1); + core = DeviceRuntime::acquire(session, "reports", {{0x28, false}}); + } + REQUIRE((linkerbot::hand::unified_detail::RuntimeAccess::inbox_capacity(*core) == 1)); + core->observe_factory("reports", probe.factory(true)); + fake->inject(fragment({1, 9})); + REQUIRE(probe.wait(1)); + fake->inject(fragment({0x7f})); + bool entered; + { + std::unique_lock lock(probe.mutex); + entered = probe.cv.wait_for(lock, 1s, [&] { return probe.blocked; }); + } + // Always release the decoder before assertions/unwinding. + fake->inject(fragment({2, 7})); + fake->inject(fragment({2, 8})); + const auto limit = Clock::now() + 1s; + while (!session->health().overflows && Clock::now() < limit) { + std::this_thread::yield(); + } + const auto overflow = session->health().overflows; + probe.unblock(); + REQUIRE(entered); + REQUIRE(overflow > 0); + REQUIRE(probe.wait(2, 2)); + (void)core->runtime_health(); // overflow reset and queued-fragment drain have completed + fake->inject(fragment({2, 7})); + REQUIRE(probe.wait(3, 2)); + REQUIRE_FALSE(core->snapshot_as("reports").has_value()); + fake->inject(fragment({1, 3})); + REQUIRE(probe.wait(4, 2)); + fake->inject(fragment({2, 4})); + REQUIRE(probe.wait(5, 2)); + REQUIRE(core->snapshot_as("reports") == 0x304); + core->close(); + session->close(); +} diff --git a/validation/installed-consumer/main.cpp b/validation/installed-consumer/main.cpp index a90bba0..efacff8 100644 --- a/validation/installed-consumer/main.cpp +++ b/validation/installed-consumer/main.cpp @@ -62,6 +62,7 @@ namespace { config.runtime_behavior = linkerbot::hand::RuntimeBehavior::kPython; config.command_timeout = 5ms; config.response_timeout = 50ms; + config.route_queue_capacity = 17; validation::FakeHand device(config); auto claimed = device.block(); validation::require(!claimed.wait_for(20ms), "profile command expired during a claimed send"); @@ -70,6 +71,8 @@ namespace { device.hand.recover_for(2s); validation::require(device.hand.is_open(), "profile recovery failed in installed consumer"); const auto before = device.hand.get_cached_snapshot(); + validation::require(device.hand.get_health().connection_generation == before.connection_generation, + "installed Python health generation differs from cache"); const auto refreshed = device.hand.refresh_device_info(1s); validation::require(refreshed.meta.valid && refreshed.meta.sequence > 0, "installed identity metadata missing"); const auto changed = device.hand.wait_for_cached_snapshot(before, 100ms, false);