feat: complete QUIC edge integration
Vendor MsQuic with build and install support, add DeviceManager status to configurable heartbeats, and report only enabled devices. Add the local QUIC gateway, protocol coverage, real MsQuic E2E tests, process smoke tests, and updated integration documentation.
This commit is contained in:
parent
1181b2d541
commit
428ee328de
1
.gitignore
vendored
1
.gitignore
vendored
@ -2,6 +2,7 @@
|
||||
/cmake-build-debug
|
||||
/cmake-build-sigma-debuggit
|
||||
/build
|
||||
/build-*
|
||||
/log
|
||||
/third_party/osqp/
|
||||
/third_party/OsqpEigen/
|
||||
|
||||
@ -81,6 +81,16 @@ find_package(gRPC REQUIRED)
|
||||
option(CMVR_ENABLE_MSQUIC_BACKEND
|
||||
"Enable the MsQuic backend for the QUIC edge service when available"
|
||||
ON)
|
||||
set(CMVR_MSQUIC_VERSION "2.5.9" CACHE STRING
|
||||
"Exact MsQuic version expected by cmvr-es")
|
||||
set(CMVR_MSQUIC_ROOT "" CACHE PATH
|
||||
"Explicit MsQuic installation prefix")
|
||||
option(CMVR_ALLOW_SYSTEM_MSQUIC
|
||||
"Allow MsQuic discovery outside the repository or CMVR_MSQUIC_ROOT"
|
||||
OFF)
|
||||
option(CMVR_REQUIRE_MSQUIC
|
||||
"Fail configuration when the requested MsQuic backend is unavailable"
|
||||
OFF)
|
||||
set(CMVR_HAS_MSQUIC OFF)
|
||||
if(CMVR_ENABLE_MSQUIC_BACKEND)
|
||||
find_package(MsQuic QUIET)
|
||||
@ -90,6 +100,9 @@ if(CMVR_ENABLE_MSQUIC_BACKEND)
|
||||
else()
|
||||
message(STATUS "MsQuic not found; building QUIC edge in unavailable/stub mode")
|
||||
endif()
|
||||
elseif(CMVR_REQUIRE_MSQUIC)
|
||||
message(FATAL_ERROR
|
||||
"CMVR_REQUIRE_MSQUIC=ON requires CMVR_ENABLE_MSQUIC_BACKEND=ON")
|
||||
endif()
|
||||
|
||||
############################################################
|
||||
@ -184,6 +197,10 @@ target_link_libraries(cmvr_es PRIVATE
|
||||
)
|
||||
|
||||
install(TARGETS cmvr_es RUNTIME DESTINATION bin)
|
||||
option(CMVR_INSTALL_DEFAULT_RUNTIME_ASSETS
|
||||
"Install default config/model and replace their existing output copies"
|
||||
ON)
|
||||
if(CMVR_INSTALL_DEFAULT_RUNTIME_ASSETS)
|
||||
install(CODE [[
|
||||
file(REMOVE_RECURSE
|
||||
"${CMAKE_INSTALL_PREFIX}/bin/config"
|
||||
@ -191,3 +208,17 @@ install(CODE [[
|
||||
]])
|
||||
install(DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/cmvr-es/config DESTINATION bin)
|
||||
install(DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/model DESTINATION bin)
|
||||
endif()
|
||||
|
||||
option(CMVR_BUILD_QUIC_TEST_GATEWAY
|
||||
"Build the development-only QUIC gateway and real MsQuic integration tests"
|
||||
${BUILD_TESTING})
|
||||
if(CMVR_BUILD_QUIC_TEST_GATEWAY)
|
||||
if(CMVR_HAS_MSQUIC)
|
||||
add_subdirectory(test)
|
||||
else()
|
||||
message(WARNING
|
||||
"CMVR_BUILD_QUIC_TEST_GATEWAY is ON, but no MsQuic backend was "
|
||||
"found; the real QUIC gateway and integration tests are skipped")
|
||||
endif()
|
||||
endif()
|
||||
|
||||
217
README.md
217
README.md
@ -1,11 +1,11 @@
|
||||
# CMVR-ES
|
||||
|
||||
CMVR-ES(CMVR Edge System)是部署在机器人边缘主机上的 C++17 运行时。它负责统一加载设备与任务、通过 gRPC 提供稳定的设备控制接口,并通过 QUIC 主动连接平台,上报节点在线状态、IP 地址以及允许丢帧的实时音视频。
|
||||
CMVR-ES(CMVR Edge System)是部署在机器人边缘主机上的 C++17 运行时。它负责统一加载设备与任务、通过 gRPC 提供稳定的设备控制接口,并通过 QUIC 主动连接平台,上报节点在线状态、IP 地址、DeviceManager 设备快照以及允许丢帧的实时音视频。
|
||||
|
||||
本项目的协议边界是:
|
||||
|
||||
- 机械臂、AGV、灵巧手等控制与状态查询继续使用 gRPC;
|
||||
- 节点注册、心跳和 IP 主动上报使用 QUIC 可靠流;
|
||||
- 节点注册、心跳、IP 和设备状态主动上报使用 QUIC 可靠流;
|
||||
- 摄像头和麦克风实时数据使用 QUIC DATAGRAM;
|
||||
- 浏览器不直接连接 CMVR 自定义 QUIC 协议,而是连接平台侧网关提供的 WebTransport、WebRTC、MSE/WebSocket 或其他 Web 接口。
|
||||
|
||||
@ -17,7 +17,7 @@ CMVR-ES(CMVR Edge System)是部署在机器人边缘主机上的 C++17 运
|
||||
| CMake / C++ 最低要求 | CMake 3.22、C++17 |
|
||||
| ARM | 目录和部分依赖已准备,但当前依赖树不完整,尚未形成可复现的完整构建 |
|
||||
| gRPC | 默认启用,监听 `0.0.0.0:50052`,reflection 默认开启 |
|
||||
| QUIC | 默认关闭;找到 MsQuic 时构建真实后端,否则构建不可用占位后端 |
|
||||
| QUIC | 仓库提供 MsQuic v2.5.9 源码构建脚本和真实后端;运行配置默认关闭,缺少依赖时仍可构建不可用占位后端 |
|
||||
| 物理设备 | 默认全部关闭,适合没有连接设备的开发主机 |
|
||||
| 平台网关 / Web 前端 | 不在本仓库中,需要平台项目按本文协议接入 |
|
||||
|
||||
@ -48,7 +48,7 @@ CMVR-ES(CMVR Edge System)是部署在机器人边缘主机上的 C++17 运
|
||||
| 设备统一管理 | 通过设备抽象、设备工厂和 `DeviceManager` 加载不同厂商实现 |
|
||||
| 任务统一管理 | 通过 `TaskManager` 运行周期任务和阻塞式服务任务 |
|
||||
| gRPC 控制面 | 提供系统、相机、音频、机械臂、AGV、灵巧手和生物头等服务 |
|
||||
| QUIC 节点在线面 | 边缘端主动连接平台,完成注册、心跳、IP 和 gRPC 地址上报 |
|
||||
| QUIC 节点在线面 | 边缘端主动连接平台,完成注册、心跳、IP、gRPC 地址和 DeviceManager 快照上报 |
|
||||
| QUIC 实时媒体面 | 通过 QUIC DATAGRAM 发送可丢弃的音视频帧,控制信息仍走可靠流 |
|
||||
| 协议无关媒体分发 | `MediaSourceHub` 已供 gRPC RGB/麦克风流和 QUIC 彩色/麦克风轨道共享采集源 |
|
||||
| 算法与仿真 | 包含运动学、轨迹规划、控制算法以及部分 MuJoCo 接入 |
|
||||
@ -154,7 +154,7 @@ cmvr-es/
|
||||
├── request.txt # 三方依赖清单
|
||||
├── cmake/
|
||||
│ ├── FindExternalLib.cmake # 仓库内依赖发现与安装
|
||||
│ └── FindMsQuic.cmake # 可选 MsQuic 后端发现
|
||||
│ └── FindMsQuic.cmake # 固定版本、仓库优先的 MsQuic 发现
|
||||
├── protos/
|
||||
│ └── cmvr/
|
||||
│ ├── api/ # 平台 gRPC API
|
||||
@ -182,12 +182,16 @@ cmvr-es/
|
||||
│ ├── algorithms/
|
||||
│ └── simulate/
|
||||
├── dependency/
|
||||
│ ├── x86/third_party/ # x86_64 预置依赖
|
||||
│ ├── x86/third_party/ # x86_64 预置依赖和源码构建的 MsQuic
|
||||
│ └── arm/third_party/ # ARM 部分依赖,当前尚未完整验证
|
||||
├── model/ # 运行模型和机器人资源
|
||||
├── assets/ # SDK 安装包、grpcurl 等辅助资产
|
||||
├── python/ # 标定和辅助脚本
|
||||
├── script/ # 历史辅助脚本
|
||||
├── script/
|
||||
│ └── build_msquic.sh # 构建固定版本 MsQuic 到 dependency/
|
||||
├── test/
|
||||
│ ├── quic_gateway/ # 本机真实 MsQuic 测试 Gateway
|
||||
│ └── e2e/ # QUIC 合成媒体和完整进程联调
|
||||
├── build/ # 本机构建目录,不纳入 Git
|
||||
└── output/ # cmake --install 产物,不纳入 Git
|
||||
```
|
||||
@ -236,6 +240,8 @@ sudo apt-get install -y \
|
||||
pkg-config \
|
||||
patchelf \
|
||||
file \
|
||||
openssl \
|
||||
perl \
|
||||
libboost-all-dev \
|
||||
libssl-dev
|
||||
```
|
||||
@ -280,16 +286,16 @@ sudo apt-get install -y can-utils
|
||||
sudo apt-get install -y gnuplot-qt
|
||||
```
|
||||
|
||||
FFmpeg、OpenCV、gRPC、Protobuf、RealSense、Hikvision SDK、AUBO SDK、Pinocchio、MuJoCo、ViSP、OSQP 等主要 C/C++ 依赖优先从 `dependency/<arch>/third_party/` 查找。完整清单和版本以 [`request.txt`](request.txt) 与 [`FindExternalLib.cmake`](cmake/FindExternalLib.cmake) 为准。
|
||||
FFmpeg、OpenCV、gRPC、Protobuf、RealSense、Hikvision SDK、AUBO SDK、Pinocchio、MuJoCo、ViSP、OSQP 等主要 C/C++ 依赖优先从 `dependency/<arch>/third_party/` 查找。MsQuic 也安装到该依赖树,但由 [`script/build_msquic.sh`](script/build_msquic.sh) 从固定版本源码构建。完整清单和版本以 [`request.txt`](request.txt) 与 [`FindExternalLib.cmake`](cmake/FindExternalLib.cmake) 为准。
|
||||
|
||||
### 4.3 仓库依赖注意事项
|
||||
|
||||
当前依赖管理方式有两个需要特别注意的历史问题:
|
||||
|
||||
1. 三方依赖的主要来源是仓库中的 `dependency/<arch>/third_party/`,不是 Git submodule。
|
||||
1. 三方依赖的主要来源是仓库中的 `dependency/<arch>/third_party/`,不是本项目的 Git submodule。
|
||||
2. 当前 `.gitmodules` 与仓库实际 gitlink 不一致,执行 `git submodule update --init --recursive` 会因 `assets/toppra` 缺少映射而失败。
|
||||
|
||||
因此,不要把旧版 README 中的 submodule 命令作为安装步骤。`script/install.sh` 也包含旧目录和硬编码架构,当前不作为标准安装入口。
|
||||
因此,不要把旧版 README 中的全仓库 submodule 命令作为安装步骤。MsQuic 构建脚本会在自己的构建缓存中,仅初始化官方源码要求的 QuicTLS submodule。`script/install.sh` 仍包含旧目录和硬编码架构,当前不作为标准安装入口。
|
||||
|
||||
## 5. 构建与安装
|
||||
|
||||
@ -367,34 +373,84 @@ ldd -r output/bin/cmvr_es
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
> 每次 `cmake --install` 都会删除并重新复制 `output/bin/config/` 和 `output/bin/model/`。不要把唯一一份生产配置、证书或现场模型直接维护在这两个目录中。
|
||||
> `CMVR_INSTALL_DEFAULT_RUNTIME_ASSETS` 默认为 `ON`,因此普通的
|
||||
> `cmake --install` 会删除并重新复制 `output/bin/config/` 和
|
||||
> `output/bin/model/`。不要把唯一一份生产配置、证书或现场模型只维护在这两个
|
||||
> 目录中。需要更新程序和动态库但保留现有配置、模型时,在首次配置构建目录时
|
||||
> 传入 `-DCMVR_INSTALL_DEFAULT_RUNTIME_ASSETS=OFF`。
|
||||
|
||||
### 5.4 构建真实 QUIC 后端
|
||||
|
||||
MsQuic 是可选编译依赖。未找到 MsQuic 时,工程仍能构建和运行 gRPC,但启用 QUIC 任务会返回明确的“后端不可用”错误。
|
||||
工程固定使用 MsQuic v2.5.9。它不需要安装到系统目录,也不需要 `sudo`。先从
|
||||
仓库根目录执行:
|
||||
|
||||
MsQuic 安装前缀需要至少包含:
|
||||
|
||||
```text
|
||||
<msquic-root>/
|
||||
├── include/msquic.h
|
||||
└── lib/libmsquic.so # 也可位于 lib64/ 或 bin/
|
||||
```bash
|
||||
script/build_msquic.sh \
|
||||
--arch x86 \
|
||||
--version 2.5.9 \
|
||||
--jobs "$(nproc)" \
|
||||
--clean
|
||||
```
|
||||
|
||||
配置真实后端:
|
||||
首次执行需要访问 GitHub,以获取官方 `v2.5.9` tag 和其 QuicTLS submodule。
|
||||
脚本还会校验该 tag 的已审核 commit,版本或 commit 不在脚本白名单时直接失败。
|
||||
源码、构建和 staging 缓存位于 `build/third_party/`,最终只把公开头文件、共享
|
||||
运行库、许可证和构建信息安装到:
|
||||
|
||||
```text
|
||||
dependency/x86/third_party/msquic/v2.5.9/
|
||||
├── BUILD-INFO.txt
|
||||
├── include/
|
||||
│ ├── msquic.h
|
||||
│ ├── msquic_posix.h
|
||||
│ └── quic_sal_stub.h
|
||||
├── lib/
|
||||
│ ├── libmsquic.so
|
||||
│ ├── libmsquic.so.2
|
||||
│ └── libmsquic.so.2.5.9
|
||||
└── share/licenses/msquic/
|
||||
```
|
||||
|
||||
脚本使用静态 QuicTLS 构建 MsQuic,因此生成的 `libmsquic.so` 不依赖系统
|
||||
`libssl`、`libcrypto` 或 `libnuma`。随后以严格模式构建项目:
|
||||
|
||||
```bash
|
||||
cmake -S . -B build-quic \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DCMVR_ARCH=x86 \
|
||||
-DCMVR_ENABLE_MSQUIC_BACKEND=ON \
|
||||
-DCMVR_MSQUIC_ROOT=/absolute/path/to/msquic
|
||||
-DCMVR_REQUIRE_MSQUIC=ON \
|
||||
-DCMVR_ALLOW_SYSTEM_MSQUIC=OFF \
|
||||
-DCMVR_MSQUIC_VERSION=2.5.9 \
|
||||
-DBUILD_TESTING=ON \
|
||||
-DCMVR_INSTALL_DEFAULT_RUNTIME_ASSETS=OFF
|
||||
|
||||
cmake --build build-quic -j"$(nproc)"
|
||||
ctest --test-dir build-quic --output-on-failure
|
||||
cmake --install build-quic
|
||||
ldd -r output/bin/cmvr_es
|
||||
```
|
||||
|
||||
也可以将 MsQuic 放在 `dependency/x86/third_party/msquic/<version>/`,CMake 会自动搜索。配置阶段应看到 `MsQuic found`;若看到 `building QUIC edge in unavailable/stub mode`,则当前构建不能启动 QUIC 任务。
|
||||
`FindMsQuic.cmake` 默认只接受
|
||||
`dependency/<arch>/third_party/msquic/v<CMVR_MSQUIC_VERSION>`,不会静默链接开发
|
||||
主机上的系统 MsQuic。各选项含义如下:
|
||||
|
||||
| 选项 | 默认值 | 作用 |
|
||||
| --- | --- | --- |
|
||||
| `CMVR_MSQUIC_VERSION` | `2.5.9` | 选择仓库依赖目录中的精确版本 |
|
||||
| `CMVR_MSQUIC_ROOT` | 空 | 显式指定另一个可信安装前缀;须包含匹配版本的 `BUILD-INFO.txt` |
|
||||
| `CMVR_ALLOW_SYSTEM_MSQUIC` | `OFF` | 是否允许在仓库和显式前缀以外搜索 |
|
||||
| `CMVR_REQUIRE_MSQUIC` | `OFF` | 找不到真实后端时是否让 CMake 直接失败 |
|
||||
|
||||
正式 QUIC 构建建议始终设置
|
||||
`CMVR_REQUIRE_MSQUIC=ON`、`CMVR_ALLOW_SYSTEM_MSQUIC=OFF`。配置阶段应看到
|
||||
`MsQuic found`;若看到 `building QUIC edge in unavailable/stub mode`,当前产物
|
||||
只能运行 gRPC,不能启动 QUIC 任务。安装时,MsQuic 运行库会复制到
|
||||
`output/lib/`,部署仍分发完整 `output/`。
|
||||
|
||||
在 ARM 主机原生构建时可把 `--arch` 改为 `arm`。跨架构构建还必须设置
|
||||
`CMVR_MSQUIC_TOOLCHAIN_FILE=/absolute/path/to/toolchain.cmake`;这只解决
|
||||
MsQuic 自身的交叉编译,整个工程的 ARM 依赖树仍需另行补齐和验证。
|
||||
|
||||
## 6. 配置说明
|
||||
|
||||
@ -482,7 +538,28 @@ QUIC 必须同时在两处开启:
|
||||
- 对外声明的 gRPC endpoint;
|
||||
- CA、服务端名称以及可选的客户端证书和私钥。
|
||||
|
||||
媒体轨道可以为空。零轨道模式仍会完成节点注册、IP 上报和心跳,适合先联调在线状态。
|
||||
`heartbeat_interval_ms` 是边缘端配置的本地心跳周期,允许范围为
|
||||
250~3600000 ms。Gateway 在 `NodeRegisterResponse` 中返回 `0` 时保留该值;
|
||||
返回非零值时使用 Gateway 协商值。需要完全按配置文件频率测试时,让测试
|
||||
Gateway 返回 `0`。
|
||||
|
||||
每次心跳还会携带一份 `DeviceManagerSnapshot`:
|
||||
|
||||
- 管理器名称、版本和描述;
|
||||
- 所有已启用设备,包括创建或初始化失败的已启用设备;
|
||||
- 设备 ID、稳定的设备类别、具体实现名和是否启用;
|
||||
- Manager 生命周期、健康状态、是否确认异常、错误信息和更新时间。
|
||||
|
||||
禁用设备只保留在边缘端 DeviceManager 的本地快照中,不通过 heartbeat 上报。
|
||||
已启用设备的创建、初始化或启动失败仍会保留在 heartbeat 设备表中。健康状态
|
||||
`UNSPECIFIED` 表示当前设备后端没有提供可信的内存健康探针,不能解释为健康;
|
||||
`has_error=false` 也只表示当前没有确认到错误。
|
||||
当前 Camera、Microphone 和 DexHand 抽象已适配各自的内存状态;AGV、Arm、
|
||||
MotorSystem 等类别在实现无阻塞缓存探针前会诚实上报 `UNSPECIFIED`,但
|
||||
DeviceManager 已知的 create/init/start/stop 失败仍会通过 `manager_state=ERROR`
|
||||
和 `has_error=true` 上报。
|
||||
|
||||
媒体轨道可以为空。零轨道模式仍会完成节点注册、IP、设备表和心跳上报,适合先联调在线状态。
|
||||
|
||||
正式环境应使用:
|
||||
|
||||
@ -589,6 +666,25 @@ ctest --test-dir build --output-on-failure
|
||||
| `grpc_camera_stream_policy_test` | gRPC 相机流积压阈值、帧龄判断和默认值 |
|
||||
| `quic_edge_protocol_test` | QUIC 控制帧、DATAGRAM 和 fake transport |
|
||||
| `quic_edge_task_test` | QUIC 任务配置和生命周期 |
|
||||
| `cmvr_quic_msquic_e2e_test` | 真实 MsQuic loopback、注册/心跳、合成 H.264/AAC 描述和 DATAGRAM 重组 |
|
||||
| `cmvr_es_quic_process_smoke_test` | 启动完整 `cmvr_es` 进程,以临时无设备配置连接本机测试 Gateway |
|
||||
|
||||
后两项仅在真实 MsQuic、`BUILD_TESTING=ON` 和 `openssl` 命令可用时登记;
|
||||
完整进程冒烟还要求 Python 3.10 或更高版本。它们使用 CMake 在构建目录生成的短期 loopback
|
||||
证书,不修改 `output/bin/config/`,也不要求连接摄像头或麦克风。可只运行真实
|
||||
QUIC 验证:
|
||||
|
||||
```bash
|
||||
ctest \
|
||||
--test-dir build-quic \
|
||||
--output-on-failure \
|
||||
-R 'cmvr_quic_msquic_e2e_test|cmvr_es_quic_process_smoke_test'
|
||||
```
|
||||
|
||||
`cmvr_quic_msquic_e2e_test` 向生产 `MediaSourceHub` 注入合成 H.264 视频和 AAC
|
||||
音频,验证可靠控制流、两类轨道描述、真实 QUIC DATAGRAM 和完整帧重组。
|
||||
`cmvr_es_quic_process_smoke_test` 则运行真正的 `cmvr_es` 可执行文件,动态创建
|
||||
全部设备关闭、只启用 QUIC 的临时配置树,验证节点注册和至少两次心跳 ACK。
|
||||
|
||||
仓库中其他以 `_test` 命名的程序可能需要真机、SDK、仿真环境或人工观察,不属于默认无设备验证。
|
||||
|
||||
@ -606,6 +702,33 @@ timeout \
|
||||
|
||||
如果进程保持运行并在 5 秒后由 `timeout` 终止,退出码通常为 `124`,这是烟雾测试的预期结果。测试期间应同时检查日志中是否存在配置、设备初始化或端口绑定错误。
|
||||
|
||||
### 7.5 本机 QUIC Gateway 联调
|
||||
|
||||
开发用真实 MsQuic Server 位于
|
||||
[`test/quic_gateway/`](test/quic_gateway/),会验证 QUIC edge v1 控制帧、注册、
|
||||
心跳、媒体元数据、DATAGRAM 和重组边界。它不包含生产鉴权、持久化、浏览器
|
||||
转发等平台能力,不能作为生产 Gateway 部署。
|
||||
|
||||
CMake 会生成启动包装器,用来补齐构建树中 gRPC 等传递动态库的搜索路径:
|
||||
|
||||
```bash
|
||||
cmake --build build-quic \
|
||||
--target cmvr_quic_test_gateway cmvr_quic_test_certificate \
|
||||
-j"$(nproc)"
|
||||
|
||||
build-quic/test/quic_gateway/run_cmvr_quic_test_gateway \
|
||||
--bind 127.0.0.1 \
|
||||
--port 4433 \
|
||||
--cert build-quic/test/certs/server.crt \
|
||||
--key build-quic/test/certs/server.key \
|
||||
--scenario normal
|
||||
```
|
||||
|
||||
自动验收优先使用上一节的两项 CTest;它们会选择空闲 UDP 端口、创建临时配置
|
||||
并安全关闭进程,避免改动部署配置。Gateway 的参数、故障注入场景和 summary
|
||||
判定字段见 [`test/quic_gateway/README.md`](test/quic_gateway/README.md),两条
|
||||
E2E 的边界见 [`test/e2e/README.md`](test/e2e/README.md)。
|
||||
|
||||
## 8. 生产部署
|
||||
|
||||
### 8.1 打包
|
||||
@ -790,11 +913,12 @@ Java 平台建议通过 Gradle/Maven 的 Protobuf 插件生成消息类和 grpc-
|
||||
平台调用流程建议:
|
||||
|
||||
1. 通过 QUIC 注册/心跳维护节点在线表;
|
||||
2. 保存最新 `node_id`、`session_id`、`observed_source_ip`、本地接口和 advertised gRPC endpoint;
|
||||
2. 保存最新 `node_id`、`session_id`、`observed_source_ip`、本地接口、advertised gRPC endpoint 和 `DeviceManagerSnapshot`;
|
||||
3. 按平台网络策略选择实际可达的 gRPC 地址;
|
||||
4. 建立并复用 gRPC channel;
|
||||
5. 为控制请求设置 deadline、幂等策略、错误映射和审计;
|
||||
6. 不要因为 QUIC 在线就假设设备本身在线。
|
||||
6. 用 `kind` 做机器分支,用 `type_name` 做展示和诊断;
|
||||
7. 区分 `UNSPECIFIED`、`DISABLED`、`FAULT`,不要因为 QUIC 在线或 `has_error=false` 就假设设备健康。
|
||||
|
||||
> [!WARNING]
|
||||
> 当前 gRPC Server 使用 `grpc::InsecureServerCredentials()`,没有 TLS、认证和授权。只能部署在可信内网、VPN、服务网格或受保护的网关后方。
|
||||
@ -844,7 +968,16 @@ QUIC reliable stream 与 DATAGRAM 之间没有跨通道到达顺序保证。即
|
||||
- `message_sequence` 在每个方向独立严格递增,允许有间隔;
|
||||
- Edge 当前第一条控制消息的 sequence 是 `0`,网关不能假设从 `1` 开始;
|
||||
- 默认控制响应超时为 1 秒,注册或心跳 ACK 缺失/不匹配会触发重连;
|
||||
- 每次心跳包含最新接口地址和 advertised gRPC endpoint;
|
||||
- 每次心跳包含最新接口地址、advertised gRPC endpoint 和按设备 ID 排序的
|
||||
`DeviceManagerSnapshot`;
|
||||
- `DeviceManagerSnapshot.devices` 只包含已启用设备;已启用但创建、初始化或启动
|
||||
失败的设备仍会上报,禁用设备不会上报;
|
||||
- `DEVICE_HEALTH_STATUS_UNSPECIFIED` 不等于健康;
|
||||
- Java 对 `DeviceKind`、`ManagedDeviceState` 和 `DeviceHealthStatus` 的
|
||||
`switch` 必须保留 `UNRECOGNIZED/default` 分支;如需转存未来版本值,保存
|
||||
`getKindValue()` 等原始整数,不能把未知枚举降级成 `HEALTHY`;
|
||||
- `heartbeat_interval_ms` 由边缘配置提供本地值,注册响应返回 `0` 时保留本地值,
|
||||
非零时采用 Gateway 协商值;
|
||||
- `observed_source_ip` 必须从已认证的 QUIC peer 地址推导,不能复制客户端上报字段;
|
||||
- 当前 Edge 接收方向实现支持 `NodeRegisterResponse`、`NodeHeartbeatAck` 和 `ProtocolError`;
|
||||
- 不要依赖 Gateway 下发 `MediaSessionClose` 来控制 Edge,本版本尚未实现该入站行为。
|
||||
@ -917,6 +1050,7 @@ H.265、AAC 等格式的浏览器支持度并不统一。平台不能假设收
|
||||
- 节点在线数、session 变化和最后心跳时间;
|
||||
- QUIC 握手/注册失败原因;
|
||||
- 心跳 RTT、ACK 超时和重连次数;
|
||||
- 每节点已启用设备总数、异常数、未知健康数以及状态更新时间;
|
||||
- 每轨道 DATAGRAM、完整帧、丢帧和重组超时;
|
||||
- descriptor/generation 变化;
|
||||
- gRPC 调用时延、错误码和设备离线状态;
|
||||
@ -1011,10 +1145,18 @@ H.265、AAC 等格式的浏览器支持度并不统一。平台不能假设收
|
||||
这不会影响 gRPC 和 fake transport 测试,但当前产物不能启动 QUIC 任务。检查:
|
||||
|
||||
- `CMVR_ENABLE_MSQUIC_BACKEND=ON`;
|
||||
- `CMVR_MSQUIC_ROOT` 使用绝对路径;
|
||||
- `<root>/include/msquic.h` 存在;
|
||||
- `<root>/lib/libmsquic.so`、`lib64/` 或 `bin/` 中存在库;
|
||||
- 重新配置时清理了错误的 CMake cache。
|
||||
- 已成功执行
|
||||
`script/build_msquic.sh --arch <arch> --version 2.5.9`;
|
||||
- `CMVR_ARCH` 与依赖目录架构一致;
|
||||
- `CMVR_MSQUIC_VERSION=2.5.9`;
|
||||
- `dependency/<arch>/third_party/msquic/v2.5.9/include/msquic.h` 存在;
|
||||
- `dependency/<arch>/third_party/msquic/v2.5.9/lib/libmsquic.so` 存在;
|
||||
- `BUILD-INFO.txt` 中的 `msquic_version` 和 `dependency_arch` 与本次构建一致。
|
||||
|
||||
需要保证构建不会悄悄退回占位后端时,增加
|
||||
`-DCMVR_REQUIRE_MSQUIC=ON`。系统路径默认不会参与搜索;只有明确接受不可复现
|
||||
的主机依赖时才设置 `-DCMVR_ALLOW_SYSTEM_MSQUIC=ON`。使用自定义可信前缀时,应
|
||||
把绝对路径传给 `CMVR_MSQUIC_ROOT`,不必打开系统 fallback。
|
||||
|
||||
### 12.3 启动后 gRPC connection refused
|
||||
|
||||
@ -1069,7 +1211,22 @@ find output/lib -maxdepth 1 -type f -name '*.so*' -print
|
||||
|
||||
### 12.7 安装后现场配置被覆盖
|
||||
|
||||
`cmake --install` 会重建 `output/bin/config/` 和 `output/bin/model/`。生产配置应保存在 `/etc/cmvr-es/` 等外部目录,并通过命令行显式传入根配置。
|
||||
普通构建中 `CMVR_INSTALL_DEFAULT_RUNTIME_ASSETS=ON`,因此
|
||||
`cmake --install` 会重建 `output/bin/config/` 和 `output/bin/model/`。生产配置
|
||||
应保存在 `/etc/cmvr-es/` 等外部目录,并通过命令行显式传入根配置。
|
||||
|
||||
本机已有调试配置,只想更新 `cmvr_es`、MsQuic 和其他动态库时,请在该构建目录
|
||||
首次配置或重新配置时设置:
|
||||
|
||||
```bash
|
||||
cmake -S . -B build-quic \
|
||||
-DCMVR_INSTALL_DEFAULT_RUNTIME_ASSETS=OFF
|
||||
cmake --build build-quic -j"$(nproc)"
|
||||
cmake --install build-quic
|
||||
```
|
||||
|
||||
该选项只跳过默认 `config/` 和 `model/` 的安装,不会阻止二进制和
|
||||
`output/lib/` 更新。
|
||||
|
||||
### 12.8 Git submodule 初始化失败
|
||||
|
||||
|
||||
@ -1,44 +1,178 @@
|
||||
# Locate an optional MsQuic installation without pulling in a second TLS runtime.
|
||||
# Locate the deterministic MsQuic runtime used by cmvr-es.
|
||||
#
|
||||
# The project supports either:
|
||||
# -DCMVR_MSQUIC_ROOT=/path/to/msquic/prefix
|
||||
# or a package placed below:
|
||||
# dependency/<arch>/third_party/msquic/<version>/
|
||||
# Default package layout:
|
||||
# dependency/<arch>/third_party/msquic/v<CMVR_MSQUIC_VERSION>/
|
||||
#
|
||||
# An explicit CMVR_MSQUIC_ROOT may point at another installation prefix.
|
||||
# System search paths are deliberately disabled unless
|
||||
# CMVR_ALLOW_SYSTEM_MSQUIC=ON, so two developer machines cannot silently link
|
||||
# different MsQuic versions.
|
||||
#
|
||||
# Result:
|
||||
# MsQuic_FOUND
|
||||
# MsQuic_VERSION
|
||||
# MsQuic_INCLUDE_DIR
|
||||
# MsQuic_LIBRARY
|
||||
# MsQuic_ROOT (empty for the opt-in system fallback)
|
||||
# MsQuic::msquic
|
||||
|
||||
include_guard(GLOBAL)
|
||||
|
||||
set(CMVR_MSQUIC_ROOT "" CACHE PATH "MsQuic installation prefix")
|
||||
set(CMVR_MSQUIC_VERSION "2.5.9" CACHE STRING
|
||||
"Exact MsQuic version expected by cmvr-es")
|
||||
set(CMVR_MSQUIC_ROOT "" CACHE PATH
|
||||
"Explicit MsQuic installation prefix")
|
||||
option(CMVR_ALLOW_SYSTEM_MSQUIC
|
||||
"Allow MsQuic discovery outside the repository or CMVR_MSQUIC_ROOT"
|
||||
OFF)
|
||||
option(CMVR_REQUIRE_MSQUIC
|
||||
"Fail configuration when the requested MsQuic backend is unavailable"
|
||||
OFF)
|
||||
|
||||
set(_cmvr_msquic_hints)
|
||||
if(CMVR_MSQUIC_VERSION STREQUAL "")
|
||||
message(FATAL_ERROR "CMVR_MSQUIC_VERSION must not be empty")
|
||||
endif()
|
||||
|
||||
set(_cmvr_msquic_roots)
|
||||
if(CMVR_MSQUIC_ROOT)
|
||||
list(APPEND _cmvr_msquic_hints "${CMVR_MSQUIC_ROOT}")
|
||||
list(APPEND _cmvr_msquic_roots "${CMVR_MSQUIC_ROOT}")
|
||||
elseif(DEFINED PROJECT_SOURCE_DIR AND DEFINED ARCH)
|
||||
list(APPEND _cmvr_msquic_roots
|
||||
"${PROJECT_SOURCE_DIR}/dependency/${ARCH}/third_party/msquic/v${CMVR_MSQUIC_VERSION}")
|
||||
endif()
|
||||
|
||||
if(DEFINED PROJECT_SOURCE_DIR AND DEFINED ARCH)
|
||||
file(GLOB _cmvr_msquic_bundled_roots LIST_DIRECTORIES true
|
||||
"${PROJECT_SOURCE_DIR}/dependency/${ARCH}/third_party/msquic/*")
|
||||
list(APPEND _cmvr_msquic_hints
|
||||
"${PROJECT_SOURCE_DIR}/dependency/${ARCH}/third_party/msquic"
|
||||
${_cmvr_msquic_bundled_roots})
|
||||
endif()
|
||||
|
||||
find_path(MsQuic_INCLUDE_DIR
|
||||
# Do not share find_path/find_library cache entries between the deterministic
|
||||
# root lookup and the explicitly enabled system fallback. Every configure
|
||||
# checks the selected files again, so adding/removing a repository package in
|
||||
# an existing build directory cannot leave a stale system result behind.
|
||||
unset(MsQuic_INCLUDE_DIR CACHE)
|
||||
unset(MsQuic_LIBRARY CACHE)
|
||||
set(MsQuic_INCLUDE_DIR "")
|
||||
set(MsQuic_LIBRARY "")
|
||||
set(MsQuic_ROOT "")
|
||||
set(_cmvr_msquic_selected_root "")
|
||||
foreach(_cmvr_msquic_root IN LISTS _cmvr_msquic_roots)
|
||||
unset(_cmvr_msquic_root_include)
|
||||
unset(_cmvr_msquic_root_library)
|
||||
find_path(_cmvr_msquic_root_include
|
||||
NAMES msquic.h
|
||||
HINTS ${_cmvr_msquic_hints}
|
||||
PATH_SUFFIXES include)
|
||||
|
||||
find_library(MsQuic_LIBRARY
|
||||
PATHS "${_cmvr_msquic_root}"
|
||||
PATH_SUFFIXES include
|
||||
NO_DEFAULT_PATH
|
||||
NO_CACHE)
|
||||
find_library(_cmvr_msquic_root_library
|
||||
NAMES msquic libmsquic
|
||||
HINTS ${_cmvr_msquic_hints}
|
||||
PATH_SUFFIXES lib lib64 bin)
|
||||
PATHS "${_cmvr_msquic_root}"
|
||||
PATH_SUFFIXES lib lib64 bin
|
||||
NO_DEFAULT_PATH
|
||||
NO_CACHE)
|
||||
if(_cmvr_msquic_root_include AND _cmvr_msquic_root_library)
|
||||
file(REAL_PATH "${_cmvr_msquic_root}" _cmvr_msquic_root_real)
|
||||
file(REAL_PATH "${_cmvr_msquic_root_include}/msquic.h"
|
||||
_cmvr_msquic_header_real)
|
||||
file(REAL_PATH "${_cmvr_msquic_root_library}"
|
||||
_cmvr_msquic_library_real)
|
||||
string(FIND "${_cmvr_msquic_header_real}"
|
||||
"${_cmvr_msquic_root_real}/"
|
||||
_cmvr_msquic_header_index)
|
||||
string(FIND "${_cmvr_msquic_library_real}"
|
||||
"${_cmvr_msquic_root_real}/"
|
||||
_cmvr_msquic_library_index)
|
||||
if(_cmvr_msquic_header_index EQUAL 0 AND
|
||||
_cmvr_msquic_library_index EQUAL 0)
|
||||
set(MsQuic_INCLUDE_DIR "${_cmvr_msquic_root_include}")
|
||||
set(MsQuic_LIBRARY "${_cmvr_msquic_root_library}")
|
||||
set(MsQuic_ROOT "${_cmvr_msquic_root_real}")
|
||||
set(_cmvr_msquic_selected_root "${_cmvr_msquic_root_real}")
|
||||
break()
|
||||
endif()
|
||||
endif()
|
||||
endforeach()
|
||||
|
||||
set(_cmvr_msquic_selected_system FALSE)
|
||||
if(NOT _cmvr_msquic_selected_root AND CMVR_ALLOW_SYSTEM_MSQUIC)
|
||||
unset(_cmvr_msquic_system_include)
|
||||
unset(_cmvr_msquic_system_library)
|
||||
find_path(_cmvr_msquic_system_include
|
||||
NAMES msquic.h
|
||||
NO_CACHE)
|
||||
find_library(_cmvr_msquic_system_library
|
||||
NAMES msquic libmsquic
|
||||
NO_CACHE)
|
||||
if(_cmvr_msquic_system_include AND _cmvr_msquic_system_library)
|
||||
file(REAL_PATH "${_cmvr_msquic_system_include}/msquic.h"
|
||||
_cmvr_msquic_header_real)
|
||||
file(REAL_PATH "${_cmvr_msquic_system_library}"
|
||||
_cmvr_msquic_library_real)
|
||||
string(REGEX REPLACE "/include(/.*)?$" ""
|
||||
_cmvr_msquic_system_include_prefix
|
||||
"${_cmvr_msquic_header_real}")
|
||||
string(REGEX REPLACE "/lib(64)?(/.*)?$" ""
|
||||
_cmvr_msquic_system_library_prefix
|
||||
"${_cmvr_msquic_library_real}")
|
||||
if(NOT "${_cmvr_msquic_system_include_prefix}" STREQUAL
|
||||
"${_cmvr_msquic_header_real}" AND
|
||||
NOT "${_cmvr_msquic_system_library_prefix}" STREQUAL
|
||||
"${_cmvr_msquic_library_real}" AND
|
||||
"${_cmvr_msquic_system_include_prefix}" STREQUAL
|
||||
"${_cmvr_msquic_system_library_prefix}")
|
||||
set(MsQuic_INCLUDE_DIR "${_cmvr_msquic_system_include}")
|
||||
set(MsQuic_LIBRARY "${_cmvr_msquic_system_library}")
|
||||
set(_cmvr_msquic_selected_system TRUE)
|
||||
endif()
|
||||
endif()
|
||||
endif()
|
||||
|
||||
set(_cmvr_msquic_version_valid FALSE)
|
||||
set(MsQuic_VERSION "")
|
||||
if(MsQuic_INCLUDE_DIR AND MsQuic_LIBRARY)
|
||||
if(_cmvr_msquic_selected_root)
|
||||
set(_cmvr_msquic_build_info
|
||||
"${_cmvr_msquic_selected_root}/BUILD-INFO.txt")
|
||||
if(EXISTS "${_cmvr_msquic_build_info}")
|
||||
file(STRINGS "${_cmvr_msquic_build_info}"
|
||||
_cmvr_msquic_version_line
|
||||
REGEX "^msquic_version="
|
||||
LIMIT_COUNT 1)
|
||||
string(REGEX REPLACE "^msquic_version=" ""
|
||||
MsQuic_VERSION "${_cmvr_msquic_version_line}")
|
||||
file(STRINGS "${_cmvr_msquic_build_info}"
|
||||
_cmvr_msquic_arch_line
|
||||
REGEX "^dependency_arch="
|
||||
LIMIT_COUNT 1)
|
||||
string(REGEX REPLACE "^dependency_arch=" ""
|
||||
_cmvr_msquic_detected_arch
|
||||
"${_cmvr_msquic_arch_line}")
|
||||
if("${MsQuic_VERSION}" STREQUAL "${CMVR_MSQUIC_VERSION}" AND
|
||||
(NOT DEFINED ARCH OR
|
||||
"${_cmvr_msquic_detected_arch}" STREQUAL "${ARCH}"))
|
||||
set(_cmvr_msquic_version_valid TRUE)
|
||||
endif()
|
||||
endif()
|
||||
elseif(_cmvr_msquic_selected_system)
|
||||
# Runtime version verification is intentionally unavailable in this
|
||||
# opt-in, non-reproducible fallback mode.
|
||||
set(MsQuic_VERSION "system-unverified")
|
||||
set(_cmvr_msquic_version_valid TRUE)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
include(FindPackageHandleStandardArgs)
|
||||
find_package_handle_standard_args(MsQuic
|
||||
REQUIRED_VARS MsQuic_INCLUDE_DIR MsQuic_LIBRARY)
|
||||
REQUIRED_VARS
|
||||
MsQuic_INCLUDE_DIR
|
||||
MsQuic_LIBRARY
|
||||
_cmvr_msquic_version_valid
|
||||
VERSION_VAR MsQuic_VERSION)
|
||||
|
||||
if(CMVR_REQUIRE_MSQUIC AND NOT MsQuic_FOUND)
|
||||
string(JOIN "\n " _cmvr_msquic_expected_roots
|
||||
${_cmvr_msquic_roots})
|
||||
message(FATAL_ERROR
|
||||
"MsQuic v${CMVR_MSQUIC_VERSION} is required but was not found.\n"
|
||||
"Expected one of:\n ${_cmvr_msquic_expected_roots}\n"
|
||||
"Build it with script/build_msquic.sh before configuring cmvr-es.")
|
||||
endif()
|
||||
|
||||
if(MsQuic_FOUND AND NOT TARGET MsQuic::msquic)
|
||||
add_library(MsQuic::msquic UNKNOWN IMPORTED)
|
||||
|
||||
16
cmake/msquic/ProcessorCount.cmake
Normal file
16
cmake/msquic/ProcessorCount.cmake
Normal file
@ -0,0 +1,16 @@
|
||||
# MsQuic's QuicTLS custom build asks CMake's ProcessorCount module for a
|
||||
# nested `make -jN` value. Route that query through the build script's
|
||||
# --jobs setting so the dependency build cannot silently oversubscribe the
|
||||
# host.
|
||||
|
||||
if(NOT DEFINED CMVR_MSQUIC_PROCESSOR_COUNT OR
|
||||
NOT CMVR_MSQUIC_PROCESSOR_COUNT MATCHES "^[1-9][0-9]*$")
|
||||
message(FATAL_ERROR
|
||||
"CMVR_MSQUIC_PROCESSOR_COUNT must be a positive integer")
|
||||
endif()
|
||||
|
||||
function(ProcessorCount result_variable)
|
||||
set("${result_variable}"
|
||||
"${CMVR_MSQUIC_PROCESSOR_COUNT}"
|
||||
PARENT_SCOPE)
|
||||
endfunction()
|
||||
@ -19,7 +19,7 @@ task_manager {
|
||||
type: TASK_TYPE_QUIC_EDGE
|
||||
run_mode: TASK_RUN_MODE_BLOCKING_SERVICE
|
||||
config_file: "tasks/quic_edge_task/quic_edge_task.pb.txt"
|
||||
# Host-development default: no MsQuic package or physical media devices.
|
||||
# Host-development default: no QUIC Gateway or physical media devices.
|
||||
enable: false
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
quic_edge {
|
||||
id: "quic_edge"
|
||||
|
||||
# The repository does not currently ship MsQuic. Keep this disabled until
|
||||
# the MsQuic dependency is installed and its backend is enabled at build time.
|
||||
# The x86 dependency tree ships the MsQuic backend. Keep the runtime task
|
||||
# disabled until a reachable QUIC Gateway and TLS policy are configured.
|
||||
enable: false
|
||||
|
||||
server_host: "quic-gateway.example.com"
|
||||
@ -19,6 +19,8 @@ quic_edge {
|
||||
grpc_endpoint_tls: false
|
||||
include_loopback_interfaces: false
|
||||
|
||||
# Local heartbeat period. The Gateway keeps this value when its registration
|
||||
# response returns heartbeat_interval_ms=0; a non-zero response overrides it.
|
||||
heartbeat_interval_ms: 5000
|
||||
control_response_timeout_ms: 1000
|
||||
|
||||
|
||||
@ -88,6 +88,7 @@ camera/vendor_camera/
|
||||
- `init()`;
|
||||
- 实际支持的 `start()` / `stop()`;
|
||||
- 状态查询和该类别核心能力;
|
||||
- 若能提供运行时健康信息,实现无阻塞的 `healthSnapshot()`;
|
||||
- 若支持实时媒体,完整实现流接口和并发停止。
|
||||
|
||||
不要为了厂商特例向抽象类加入 SDK handle、私有报文或厂商专有结构。只有多个后端都需要的稳定语义才进入抽象接口或 `common/types/`。
|
||||
@ -174,10 +175,13 @@ CameraDeviceConfig / AGVDeviceConfig / ... 的外层 id
|
||||
|
||||
- 相对配置路径以根 `cmvr_es.pb.txt` 所在目录解析;
|
||||
- 生产密码、token 和证书不得提交到样例配置;
|
||||
- 单个设备 init 失败时会被跳过,进程仍可能继续启动;
|
||||
- 单个设备 init 失败时不会进入可用对象表,进程仍可能继续启动;失败条目会保留
|
||||
在 DeviceManager 状态快照中,其中已启用的失败设备会通过 QUIC heartbeat
|
||||
上报,禁用设备不会上报;
|
||||
- 有初始化依赖的设备按配置顺序排列,例如 MotorSystem 在依赖它的 RobotArm 前;
|
||||
- DeviceManager stop 遍历 unordered_map,不能依赖跨设备停止顺序;
|
||||
- 当前不支持 service 运行期间并发热插拔设备集合。
|
||||
- DeviceManager 支持并发查询、状态快照和动态注册,但动态设备不会自动补执行
|
||||
已经发生的 `start()`,当前也没有设备移除或完整热插拔生命周期。
|
||||
|
||||
配置细节见 [`../config/README.md`](../config/README.md)。
|
||||
|
||||
@ -202,6 +206,9 @@ CameraDeviceConfig / AGVDeviceConfig / ... 的外层 id
|
||||
- SDK callback 不获取停止路径长期持有的控制锁;
|
||||
- getState 使用与状态写入相同的锁;
|
||||
- 含 `std::string`、vector 等状态不能无锁复制;
|
||||
- `healthSnapshot()` 只能读取已经缓存的内存状态,必须线程安全,不能同步访问
|
||||
SDK、网络、串口或设备总线;
|
||||
- 无法提供可信健康状态时返回 `UNKNOWN`,不能用“没有观察到错误”冒充健康;
|
||||
- 析构函数调用安全停止路径;
|
||||
- callback 捕获对象前保证 owner 生命周期。
|
||||
|
||||
|
||||
@ -39,6 +39,19 @@ namespace cmvr::device {
|
||||
return false;
|
||||
}
|
||||
|
||||
// This hook is sampled by DeviceManager while building heartbeats. It
|
||||
// must be thread-safe and complete in bounded time while only copying
|
||||
// in-memory state through atomics or a dedicated short-held state
|
||||
// lock. Implementations must not perform device I/O, network requests,
|
||||
// or wait on a lifecycle lock held across such I/O.
|
||||
//
|
||||
// The method is intentionally non-const because several legacy device
|
||||
// categories expose non-const state getters. The returned object is a
|
||||
// value and does not expose the device lifetime to callers.
|
||||
virtual DeviceHealthSnapshot healthSnapshot() {
|
||||
return {};
|
||||
}
|
||||
|
||||
protected:
|
||||
std::string id_; // 设备名称
|
||||
};
|
||||
|
||||
@ -92,6 +92,21 @@ namespace cmvr::device {
|
||||
// writers; CameraState contains std::string and cannot be snapshotted
|
||||
// safely while another thread mutates it.
|
||||
virtual void getState(CameraState &state) = 0;
|
||||
DeviceHealthSnapshot healthSnapshot() override {
|
||||
CameraState state{};
|
||||
getState(state);
|
||||
|
||||
DeviceHealthSnapshot health;
|
||||
health.error_message = state.error_message;
|
||||
if (state.is_error) {
|
||||
health.state = DeviceHealthState::Fault;
|
||||
} else if (!state.error_message.empty()) {
|
||||
health.state = DeviceHealthState::Degraded;
|
||||
} else if (state.is_initialized) {
|
||||
health.state = DeviceHealthState::Healthy;
|
||||
}
|
||||
return health;
|
||||
}
|
||||
virtual void getRGBImage(cv::Mat &color, Rs2Intrinsics& intrinsics) {}
|
||||
virtual void getDepthImage(cv::Mat &depth, Rs2Intrinsics& intrinsics) {}
|
||||
virtual void getRGBDImages(cv::Mat &color, cv::Mat &depth, Rs2Intrinsics& intrinsics) {}
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
#ifndef CMVR_ES_DEVICE_TYPES_H
|
||||
#define CMVR_ES_DEVICE_TYPES_H
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace cmvr::device {
|
||||
|
||||
@ -62,6 +64,87 @@ namespace cmvr::device {
|
||||
std::string type_name;
|
||||
};
|
||||
|
||||
// DeviceManager lifecycle and device-reported health are deliberately
|
||||
// separate. A device can, for example, be READY from the manager's point
|
||||
// of view while its backend has not implemented health reporting yet.
|
||||
enum class ManagedDeviceState {
|
||||
Unknown,
|
||||
Disabled,
|
||||
Initializing,
|
||||
Registered,
|
||||
Ready,
|
||||
Running,
|
||||
Stopped,
|
||||
Error,
|
||||
};
|
||||
|
||||
enum class DeviceHealthState {
|
||||
Unknown,
|
||||
Healthy,
|
||||
Degraded,
|
||||
Fault,
|
||||
};
|
||||
|
||||
inline std::string toString(ManagedDeviceState state) {
|
||||
switch (state) {
|
||||
case ManagedDeviceState::Disabled:
|
||||
return "Disabled";
|
||||
case ManagedDeviceState::Initializing:
|
||||
return "Initializing";
|
||||
case ManagedDeviceState::Registered:
|
||||
return "Registered";
|
||||
case ManagedDeviceState::Ready:
|
||||
return "Ready";
|
||||
case ManagedDeviceState::Running:
|
||||
return "Running";
|
||||
case ManagedDeviceState::Stopped:
|
||||
return "Stopped";
|
||||
case ManagedDeviceState::Error:
|
||||
return "Error";
|
||||
case ManagedDeviceState::Unknown:
|
||||
default:
|
||||
return "Unknown";
|
||||
}
|
||||
}
|
||||
|
||||
inline std::string toString(DeviceHealthState state) {
|
||||
switch (state) {
|
||||
case DeviceHealthState::Healthy:
|
||||
return "Healthy";
|
||||
case DeviceHealthState::Degraded:
|
||||
return "Degraded";
|
||||
case DeviceHealthState::Fault:
|
||||
return "Fault";
|
||||
case DeviceHealthState::Unknown:
|
||||
default:
|
||||
return "Unknown";
|
||||
}
|
||||
}
|
||||
|
||||
struct DeviceHealthSnapshot {
|
||||
DeviceHealthState state = DeviceHealthState::Unknown;
|
||||
std::string error_message;
|
||||
};
|
||||
|
||||
struct ManagedDeviceSnapshot {
|
||||
std::string id;
|
||||
DeviceKind kind = DeviceKind::Unknown;
|
||||
std::string type_name;
|
||||
bool enabled = false;
|
||||
ManagedDeviceState state = ManagedDeviceState::Unknown;
|
||||
DeviceHealthSnapshot health;
|
||||
bool abnormal = false;
|
||||
std::string error_message;
|
||||
std::uint64_t status_updated_at_unix_ms = 0;
|
||||
};
|
||||
|
||||
struct DeviceManagerSnapshot {
|
||||
std::string name;
|
||||
std::string version;
|
||||
std::string description;
|
||||
std::vector<ManagedDeviceSnapshot> devices;
|
||||
};
|
||||
|
||||
} // namespace cmvr::device
|
||||
|
||||
#endif // CMVR_ES_DEVICE_TYPES_H
|
||||
|
||||
@ -130,6 +130,24 @@ namespace cmvr::device {
|
||||
virtual Status state() const = 0;
|
||||
virtual std::string lastError() const = 0;
|
||||
|
||||
DeviceHealthSnapshot healthSnapshot() override {
|
||||
const auto lifecycle = state();
|
||||
const auto error = lastError();
|
||||
|
||||
DeviceHealthSnapshot health;
|
||||
health.error_message = error;
|
||||
if (lifecycle == Status::FAULT) {
|
||||
health.state = DeviceHealthState::Fault;
|
||||
} else if (!error.empty()) {
|
||||
health.state = DeviceHealthState::Degraded;
|
||||
} else if (lifecycle == Status::INITIALIZED ||
|
||||
lifecycle == Status::STREAMING ||
|
||||
lifecycle == Status::STOPPED) {
|
||||
health.state = DeviceHealthState::Healthy;
|
||||
}
|
||||
return health;
|
||||
}
|
||||
|
||||
virtual void getState(DexHandState& state) {
|
||||
state = DexHandState{};
|
||||
const auto lifecycle = this->state();
|
||||
|
||||
@ -19,6 +19,21 @@ namespace cmvr::device{
|
||||
|
||||
DeviceKind kind() const noexcept override { return DeviceKind::Microphone; }
|
||||
virtual void getState(MicrophoneState &state) {}
|
||||
DeviceHealthSnapshot healthSnapshot() override {
|
||||
MicrophoneState state{};
|
||||
getState(state);
|
||||
|
||||
DeviceHealthSnapshot health;
|
||||
health.error_message = state.error_message;
|
||||
if (state.is_error) {
|
||||
health.state = DeviceHealthState::Fault;
|
||||
} else if (!state.error_message.empty()) {
|
||||
health.state = DeviceHealthState::Degraded;
|
||||
} else if (state.is_initialized) {
|
||||
health.state = DeviceHealthState::Healthy;
|
||||
}
|
||||
return health;
|
||||
}
|
||||
virtual void startRecording(const std::string& outputFilePath) {}
|
||||
virtual void stopRecording() {}
|
||||
virtual void pause() {}
|
||||
|
||||
@ -77,13 +77,33 @@
|
||||
- 有初始化依赖的设备应把依赖项写在使用方之前;
|
||||
- `start()`、`stop()` 遍历 `unordered_map`,不能依赖启停顺序;
|
||||
- 某个设备 start 返回 false 时,当前实现会继续启动其他设备且不会回滚;
|
||||
- DeviceManager 不捕获设备 init/start/stop 抛出的异常,后端应把预期失败转换为返回值,不能让异常越过 manager 边界;
|
||||
- DeviceManager 会把 create/init/start/stop 和健康探针异常转换成设备状态错误,
|
||||
但后端仍应把预期失败转换为返回值;
|
||||
- collection 配置要求 manager entry ID 能找到同 ID 子配置;
|
||||
- ID 重复、不匹配或配置路径为空都会拒绝创建;
|
||||
- `registerDevice()` 不会替调用方调用 `init()`;
|
||||
- 运行阶段应把设备集合视为只读,动态注册必须在 service/task 启动前完成;
|
||||
- `devices_` 和状态表由读写锁保护,运行期动态注册不会与 heartbeat/query
|
||||
形成数据竞争;但动态设备不会自动补执行已经发生的 `start()`,也暂不支持移除;
|
||||
- `getDevice<T>()` 类型不匹配或 ID 不存在时返回空指针。
|
||||
|
||||
### 状态快照与 QUIC Heartbeat
|
||||
|
||||
`DeviceManager::snapshot()` 返回协议无关的纯值快照,包含 Manager
|
||||
名称、版本、描述以及按设备 ID 排序的完整设备表。状态表与可用设备对象表分开:
|
||||
|
||||
- 禁用、创建失败、初始化失败的配置项仍会出现在快照中;
|
||||
- `devices_` 仍只保存可供业务查询的已初始化对象,不改变现有 service 语义;
|
||||
- 动态注册设备初始为 `REGISTERED`,不会冒充已经由 Manager 初始化或启动;
|
||||
- create/init/start/stop 的已知错误会设置 Manager `ERROR` 和 `has_error`;设备
|
||||
health 与生命周期独立,仍由探针报告 `HEALTHY/DEGRADED/FAULT/UNKNOWN`;
|
||||
- `healthSnapshot()` 未实现时为 `UNKNOWN`,不能解释为健康;
|
||||
- heartbeat 只消费该内存快照,并在 QUIC 映射边界过滤 `enabled=false` 的设备;
|
||||
禁止从发送线程同步访问厂商 SDK 或设备网络。
|
||||
|
||||
快照复制设备元数据和临时 `shared_ptr` 后立即释放容器锁,再调用设备的轻量健康
|
||||
探针,避免持锁进入设备代码。start/stop 同样在锁外调用设备,并由单独的生命周期
|
||||
锁防止同一 Manager 并发启停。
|
||||
|
||||
不要在仍有 service/task 持有 manager 引用时调用 `destroyInstance()`。
|
||||
|
||||
## TaskManager
|
||||
@ -210,7 +230,9 @@ ctest \
|
||||
--output-on-failure
|
||||
```
|
||||
|
||||
DeviceManager 和 TaskManager 当前没有独立 CTest,这是现有测试缺口。修改其行为时至少补充:
|
||||
DeviceManager 已有 `device_manager_snapshot_test`,覆盖全量状态表、生命周期失败、
|
||||
异常限长、排序和值快照并发读取。TaskManager 仍缺少独立 CTest。修改其行为时
|
||||
至少补充:
|
||||
|
||||
- fake device 创建、ID 冲突和 init/start/stop 失败;
|
||||
- 设备依赖顺序;
|
||||
|
||||
@ -21,3 +21,29 @@ target_link_libraries(device_manager PRIVATE
|
||||
|
||||
add_library(cmvr_es::device_manager ALIAS device_manager)
|
||||
install(TARGETS device_manager LIBRARY DESTINATION lib)
|
||||
|
||||
if(BUILD_TESTING)
|
||||
add_executable(device_manager_snapshot_test
|
||||
tests/device_manager_snapshot_test.cpp
|
||||
)
|
||||
target_link_libraries(device_manager_snapshot_test PRIVATE
|
||||
cmvr_es::device_manager
|
||||
)
|
||||
add_test(
|
||||
NAME device_manager_snapshot_test
|
||||
COMMAND device_manager_snapshot_test
|
||||
)
|
||||
set_tests_properties(device_manager_snapshot_test PROPERTIES TIMEOUT 20)
|
||||
if(UNIX AND NOT APPLE)
|
||||
get_property(_device_manager_test_library_dirs
|
||||
DIRECTORY PROPERTY LINK_DIRECTORIES)
|
||||
list(PREPEND _device_manager_test_library_dirs
|
||||
"${CMAKE_BINARY_DIR}/cmvr_compiler_runtime")
|
||||
list(JOIN _device_manager_test_library_dirs ":"
|
||||
_device_manager_test_library_path)
|
||||
set_tests_properties(device_manager_snapshot_test PROPERTIES
|
||||
ENVIRONMENT
|
||||
"LD_LIBRARY_PATH=${_device_manager_test_library_path}"
|
||||
)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
@ -7,9 +7,12 @@
|
||||
|
||||
#include <list>
|
||||
#include <mutex>
|
||||
#include <shared_mutex>
|
||||
#include <string>
|
||||
#include <unordered_set>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
#include <vector>
|
||||
|
||||
#include "device_factory.h"
|
||||
#include "cmvr/config/device_manager_config/device_manager_config.pb.h"
|
||||
|
||||
@ -32,6 +35,7 @@ namespace cmvr::device {
|
||||
void registerDevice(const std::shared_ptr<AbstractDevice>& device);
|
||||
void registerDevice(const std::string& device_id, const std::shared_ptr<AbstractDevice>& device);
|
||||
std::shared_ptr<AbstractDevice> getDeviceBase(const std::string& device_id);
|
||||
DeviceManagerSnapshot snapshot() const;
|
||||
|
||||
std::string version() const;
|
||||
std::string name() const;
|
||||
@ -45,13 +49,18 @@ namespace cmvr::device {
|
||||
static std::shared_ptr<DeviceManager> instance_;
|
||||
|
||||
config::DeviceManagerConfig cfg_;
|
||||
mutable std::shared_mutex devices_mutex_;
|
||||
std::mutex lifecycle_mutex_;
|
||||
std::unordered_map<std::string, DeviceRecord> devices_;
|
||||
std::unordered_map<std::string, ManagedDeviceSnapshot> device_statuses_;
|
||||
std::unique_ptr<DeviceFactory> dev_factory_;
|
||||
|
||||
explicit DeviceManager(const config::DeviceManagerConfig &cfg);
|
||||
void log_device_plan_() const;
|
||||
void pre_scan_robot_arm_dependencies_() const;
|
||||
void init_devices_();
|
||||
void start_devices_();
|
||||
void stop_devices_();
|
||||
};
|
||||
} // cmvr
|
||||
|
||||
|
||||
@ -5,6 +5,12 @@
|
||||
|
||||
#include "../include/device_manager.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <exception>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "devices/agv/abstract_agv.h"
|
||||
#include "devices/arm/robot_arm.h"
|
||||
#include "devices/battery/abstract_battery.h"
|
||||
@ -24,11 +30,88 @@ using namespace cmvr::device;
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr std::size_t kMaximumDeviceErrorBytes = 512;
|
||||
|
||||
void logSection(const char* title)
|
||||
{
|
||||
CMVR_LOG(INFO) << "---------------- " << title << " ----------------";
|
||||
}
|
||||
|
||||
std::uint64_t unixTimeMilliseconds()
|
||||
{
|
||||
return static_cast<std::uint64_t>(
|
||||
std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
std::chrono::system_clock::now().time_since_epoch())
|
||||
.count());
|
||||
}
|
||||
|
||||
std::string boundedError(std::string message)
|
||||
{
|
||||
if (message.size() <= kMaximumDeviceErrorBytes) return message;
|
||||
|
||||
std::size_t end = kMaximumDeviceErrorBytes;
|
||||
// Do not leave a partial UTF-8 code point when a diagnostic contains
|
||||
// localized text. The protobuf string remains valid for Java/JSON users.
|
||||
while (end > 0U &&
|
||||
(static_cast<unsigned char>(message[end]) & 0xc0U) == 0x80U) {
|
||||
--end;
|
||||
}
|
||||
message.resize(end);
|
||||
return message;
|
||||
}
|
||||
|
||||
std::string exceptionError(const char* operation, const std::exception& error)
|
||||
{
|
||||
return boundedError(std::string(operation) + " threw: " + error.what());
|
||||
}
|
||||
|
||||
void updateManagedState(cmvr::device::ManagedDeviceSnapshot& status,
|
||||
const cmvr::device::ManagedDeviceState state,
|
||||
std::string error = {})
|
||||
{
|
||||
error = boundedError(std::move(error));
|
||||
const bool changed =
|
||||
status.state != state || status.error_message != error;
|
||||
|
||||
status.state = state;
|
||||
status.error_message = std::move(error);
|
||||
status.abnormal = state == cmvr::device::ManagedDeviceState::Error;
|
||||
// Manager lifecycle and device health are independent. Live health is
|
||||
// sampled on demand and is never cached in this state table.
|
||||
status.health = {};
|
||||
if (changed || status.status_updated_at_unix_ms == 0) {
|
||||
status.status_updated_at_unix_ms = unixTimeMilliseconds();
|
||||
}
|
||||
}
|
||||
|
||||
cmvr::device::DeviceKind deviceKindFromConfig(
|
||||
const cmvr::config::DeviceConfigEntry::DeviceType type)
|
||||
{
|
||||
using ConfigType = cmvr::config::DeviceConfigEntry;
|
||||
using Kind = cmvr::device::DeviceKind;
|
||||
switch (type) {
|
||||
case ConfigType::DEVICE_TYPE_BIO_HEAD_ROBOT:
|
||||
return Kind::BioHead;
|
||||
case ConfigType::DEVICE_TYPE_MOTOR_SYSTEM:
|
||||
return Kind::MotorSystem;
|
||||
case ConfigType::DEVICE_TYPE_ROBOT_ARM:
|
||||
return Kind::Arm;
|
||||
case ConfigType::DEVICE_TYPE_CAMERA:
|
||||
return Kind::Camera;
|
||||
case ConfigType::DEVICE_TYPE_DEXHAND:
|
||||
return Kind::DexHand;
|
||||
case ConfigType::DEVICE_TYPE_MICROPHONE:
|
||||
return Kind::Microphone;
|
||||
case ConfigType::DEVICE_TYPE_SPEAKER:
|
||||
return Kind::Speaker;
|
||||
case ConfigType::DEVICE_TYPE_AGV:
|
||||
return Kind::AGV;
|
||||
case ConfigType::DEVICE_TYPE_UNKNOWN:
|
||||
default:
|
||||
return Kind::Unknown;
|
||||
}
|
||||
}
|
||||
|
||||
const char* deviceTypeToString(const cmvr::config::DeviceConfigEntry::DeviceType type)
|
||||
{
|
||||
switch (type) {
|
||||
@ -105,35 +188,123 @@ void DeviceManager::destroyInstance() {
|
||||
MotorSystem::clearActiveJoints();
|
||||
}
|
||||
|
||||
void DeviceManager::start(){
|
||||
for (auto& [id, record] : devices_) {
|
||||
if (!record.device) {
|
||||
CMVR_LOG(WARNING) << "[DeviceManager]: Null pointer for device " << id;
|
||||
continue;
|
||||
void DeviceManager::start()
|
||||
{
|
||||
std::lock_guard lifecycle_lock(lifecycle_mutex_);
|
||||
start_devices_();
|
||||
}
|
||||
if (record.device->start()) {
|
||||
CMVR_LOG(INFO) << "[DeviceManager]: Start device " << id << " Success";
|
||||
|
||||
void DeviceManager::restart()
|
||||
{
|
||||
std::lock_guard lifecycle_lock(lifecycle_mutex_);
|
||||
stop_devices_();
|
||||
start_devices_();
|
||||
}
|
||||
|
||||
void DeviceManager::stop()
|
||||
{
|
||||
std::lock_guard lifecycle_lock(lifecycle_mutex_);
|
||||
stop_devices_();
|
||||
}
|
||||
|
||||
void DeviceManager::start_devices_()
|
||||
{
|
||||
std::vector<std::pair<std::string, std::shared_ptr<AbstractDevice>>> devices;
|
||||
{
|
||||
std::shared_lock lock(devices_mutex_);
|
||||
devices.reserve(devices_.size());
|
||||
for (const auto& [id, record] : devices_) {
|
||||
devices.emplace_back(id, record.device);
|
||||
}
|
||||
}
|
||||
|
||||
for (const auto& [id, device] : devices) {
|
||||
bool success = false;
|
||||
std::string error;
|
||||
if (!device) {
|
||||
error = "registered device pointer is null";
|
||||
} else {
|
||||
CMVR_LOG(ERROR) << "[DeviceManager]: Start device " << id << " Failed";
|
||||
try {
|
||||
success = device->start();
|
||||
if (!success) {
|
||||
error = "device start returned false";
|
||||
}
|
||||
} catch (const std::exception& exception) {
|
||||
error = exceptionError("device start", exception);
|
||||
} catch (...) {
|
||||
error = "device start threw an unknown exception";
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
std::unique_lock lock(devices_mutex_);
|
||||
const auto status_it = device_statuses_.find(id);
|
||||
if (status_it != device_statuses_.end()) {
|
||||
updateManagedState(
|
||||
status_it->second,
|
||||
success ? ManagedDeviceState::Running
|
||||
: ManagedDeviceState::Error,
|
||||
std::move(error));
|
||||
}
|
||||
}
|
||||
|
||||
if (success) {
|
||||
CMVR_LOG(INFO) << "[DeviceManager]: Start device " << id
|
||||
<< " Success";
|
||||
} else {
|
||||
CMVR_LOG(ERROR) << "[DeviceManager]: Start device " << id
|
||||
<< " Failed";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void DeviceManager::restart() {
|
||||
stop();
|
||||
start();
|
||||
void DeviceManager::stop_devices_()
|
||||
{
|
||||
std::vector<std::pair<std::string, std::shared_ptr<AbstractDevice>>> devices;
|
||||
{
|
||||
std::shared_lock lock(devices_mutex_);
|
||||
devices.reserve(devices_.size());
|
||||
for (const auto& [id, record] : devices_) {
|
||||
devices.emplace_back(id, record.device);
|
||||
}
|
||||
}
|
||||
|
||||
void DeviceManager::stop() {
|
||||
for (auto& [id, record] : devices_) {
|
||||
if (!record.device) {
|
||||
CMVR_LOG(WARNING) << "[DeviceManager]: Null pointer for device " << id;
|
||||
continue;
|
||||
}
|
||||
if (record.device->stop()) {
|
||||
CMVR_LOG(INFO) << "[DeviceManager]: Stop device " << id << " Success";
|
||||
for (const auto& [id, device] : devices) {
|
||||
bool success = false;
|
||||
std::string error;
|
||||
if (!device) {
|
||||
error = "registered device pointer is null";
|
||||
} else {
|
||||
CMVR_LOG(ERROR) << "[DeviceManager]: Stop device " << id << " Failed";
|
||||
try {
|
||||
success = device->stop();
|
||||
if (!success) {
|
||||
error = "device stop returned false";
|
||||
}
|
||||
} catch (const std::exception& exception) {
|
||||
error = exceptionError("device stop", exception);
|
||||
} catch (...) {
|
||||
error = "device stop threw an unknown exception";
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
std::unique_lock lock(devices_mutex_);
|
||||
const auto status_it = device_statuses_.find(id);
|
||||
if (status_it != device_statuses_.end()) {
|
||||
updateManagedState(
|
||||
status_it->second,
|
||||
success ? ManagedDeviceState::Stopped
|
||||
: ManagedDeviceState::Error,
|
||||
std::move(error));
|
||||
}
|
||||
}
|
||||
|
||||
if (success) {
|
||||
CMVR_LOG(INFO) << "[DeviceManager]: Stop device " << id
|
||||
<< " Success";
|
||||
} else {
|
||||
CMVR_LOG(ERROR) << "[DeviceManager]: Stop device " << id
|
||||
<< " Failed";
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -141,13 +312,20 @@ void DeviceManager::stop() {
|
||||
template <class DeviceType>
|
||||
std::shared_ptr<DeviceType> DeviceManager::getDevice(const std::string& device_id)
|
||||
{
|
||||
auto it = devices_.find(device_id);
|
||||
if (it == devices_.end()) {
|
||||
std::shared_ptr<AbstractDevice> device;
|
||||
{
|
||||
std::shared_lock lock(devices_mutex_);
|
||||
const auto it = devices_.find(device_id);
|
||||
if (it != devices_.end()) {
|
||||
device = it->second.device;
|
||||
}
|
||||
}
|
||||
if (!device) {
|
||||
CMVR_LOG(WARNING) << "[DeviceManager]: Device ID " << device_id << " not found.";
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
auto ptr = std::dynamic_pointer_cast<DeviceType>(it->second.device);
|
||||
auto ptr = std::dynamic_pointer_cast<DeviceType>(device);
|
||||
if (!ptr) {
|
||||
CMVR_LOG(WARNING) << "[DeviceManager]: Device ID " << device_id << " type mismatch.";
|
||||
return nullptr;
|
||||
@ -157,16 +335,24 @@ std::shared_ptr<DeviceType> DeviceManager::getDevice(const std::string& device_i
|
||||
|
||||
std::shared_ptr<AbstractDevice> DeviceManager::getDeviceBase(const std::string& device_id)
|
||||
{
|
||||
auto it = devices_.find(device_id);
|
||||
if (it == devices_.end()) {
|
||||
std::shared_ptr<AbstractDevice> device;
|
||||
{
|
||||
std::shared_lock lock(devices_mutex_);
|
||||
const auto it = devices_.find(device_id);
|
||||
if (it != devices_.end()) {
|
||||
device = it->second.device;
|
||||
}
|
||||
}
|
||||
if (!device) {
|
||||
CMVR_LOG(WARNING) << "[DeviceManager]: Device ID " << device_id << " not found.";
|
||||
return nullptr;
|
||||
}
|
||||
return it->second.device;
|
||||
return device;
|
||||
}
|
||||
|
||||
void DeviceManager::getDeviceList(std::list<std::pair<std::string, std::string>>& device_list){
|
||||
device_list.clear();
|
||||
std::shared_lock lock(devices_mutex_);
|
||||
for (const auto& [device_id, record] : devices_) {
|
||||
device_list.emplace_back(device_id, record.type_name);
|
||||
}
|
||||
@ -178,7 +364,8 @@ void DeviceManager::registerDevice(const std::shared_ptr<AbstractDevice>& device
|
||||
CMVR_LOG(ERROR) << "[DeviceManager]: Cannot register null device";
|
||||
return;
|
||||
}
|
||||
registerDevice(device->id(), device);
|
||||
const std::string device_id = device->id();
|
||||
registerDevice(device_id, device);
|
||||
}
|
||||
|
||||
void DeviceManager::registerDevice(const std::string& device_id,
|
||||
@ -192,20 +379,125 @@ void DeviceManager::registerDevice(const std::string& device_id,
|
||||
CMVR_LOG(ERROR) << "[DeviceManager]: Cannot register device with empty id";
|
||||
return;
|
||||
}
|
||||
if (devices_.count(device_id)) {
|
||||
CMVR_LOG(ERROR) << "[DeviceManager]: Duplicate device ID " << device_id;
|
||||
return;
|
||||
}
|
||||
DeviceRecord record;
|
||||
try {
|
||||
record.id = device_id;
|
||||
record.kind = device->kind();
|
||||
record.type_name = device->typeName();
|
||||
record.device = device;
|
||||
} catch (const std::exception& error) {
|
||||
CMVR_LOG(ERROR) << "[DeviceManager]: Cannot inspect device "
|
||||
<< device_id << ": " << error.what();
|
||||
return;
|
||||
} catch (...) {
|
||||
CMVR_LOG(ERROR) << "[DeviceManager]: Cannot inspect device "
|
||||
<< device_id << ": unknown exception";
|
||||
return;
|
||||
}
|
||||
|
||||
const auto registered_kind = record.kind;
|
||||
const auto registered_type_name = record.type_name;
|
||||
{
|
||||
std::unique_lock lock(devices_mutex_);
|
||||
if (devices_.count(device_id)) {
|
||||
CMVR_LOG(ERROR) << "[DeviceManager]: Duplicate device ID "
|
||||
<< device_id;
|
||||
return;
|
||||
}
|
||||
|
||||
ManagedDeviceSnapshot status;
|
||||
status.id = record.id;
|
||||
status.kind = record.kind;
|
||||
status.type_name = record.type_name;
|
||||
status.enabled = true;
|
||||
updateManagedState(status, ManagedDeviceState::Registered);
|
||||
device_statuses_[device_id] = std::move(status);
|
||||
devices_.emplace(record.id, std::move(record));
|
||||
}
|
||||
|
||||
CMVR_LOG(INFO) << "[DeviceManager]: Register device success"
|
||||
<< ", id=" << device_id
|
||||
<< ", type=" << device->typeName()
|
||||
<< ", kind=" << toString(device->kind());
|
||||
<< ", type=" << registered_type_name
|
||||
<< ", kind=" << toString(registered_kind);
|
||||
}
|
||||
|
||||
DeviceManagerSnapshot DeviceManager::snapshot() const
|
||||
{
|
||||
struct SnapshotSource {
|
||||
ManagedDeviceSnapshot status;
|
||||
std::shared_ptr<AbstractDevice> device;
|
||||
};
|
||||
|
||||
std::vector<SnapshotSource> sources;
|
||||
{
|
||||
std::shared_lock lock(devices_mutex_);
|
||||
sources.reserve(device_statuses_.size());
|
||||
for (const auto& [status_key, status] : device_statuses_) {
|
||||
SnapshotSource source;
|
||||
source.status = status;
|
||||
const auto device_it = devices_.find(status.id);
|
||||
if (device_it != devices_.end()) {
|
||||
source.device = device_it->second.device;
|
||||
} else {
|
||||
// Normally status_key and status.id are identical. The key
|
||||
// fallback keeps invalid/unnamed configuration entries safe.
|
||||
const auto key_device_it = devices_.find(status_key);
|
||||
if (key_device_it != devices_.end()) {
|
||||
source.device = key_device_it->second.device;
|
||||
}
|
||||
}
|
||||
sources.push_back(std::move(source));
|
||||
}
|
||||
}
|
||||
|
||||
DeviceManagerSnapshot result;
|
||||
result.name = name();
|
||||
result.version = version();
|
||||
result.description = description();
|
||||
result.devices.reserve(sources.size());
|
||||
|
||||
for (auto& source : sources) {
|
||||
auto& status = source.status;
|
||||
if (source.device) {
|
||||
try {
|
||||
status.health = source.device->healthSnapshot();
|
||||
status.health.error_message =
|
||||
boundedError(std::move(status.health.error_message));
|
||||
} catch (const std::exception& error) {
|
||||
status.health.state = DeviceHealthState::Fault;
|
||||
status.health.error_message =
|
||||
exceptionError("device health snapshot", error);
|
||||
} catch (...) {
|
||||
status.health.state = DeviceHealthState::Fault;
|
||||
status.health.error_message =
|
||||
"device health snapshot threw an unknown exception";
|
||||
}
|
||||
}
|
||||
|
||||
status.error_message = boundedError(std::move(status.error_message));
|
||||
status.abnormal =
|
||||
status.state == ManagedDeviceState::Error ||
|
||||
status.health.state == DeviceHealthState::Degraded ||
|
||||
status.health.state == DeviceHealthState::Fault;
|
||||
if (status.error_message.empty() &&
|
||||
!status.health.error_message.empty()) {
|
||||
status.error_message = status.health.error_message;
|
||||
}
|
||||
result.devices.push_back(std::move(status));
|
||||
}
|
||||
|
||||
std::sort(
|
||||
result.devices.begin(), result.devices.end(),
|
||||
[](const ManagedDeviceSnapshot& lhs,
|
||||
const ManagedDeviceSnapshot& rhs) {
|
||||
if (lhs.id != rhs.id) return lhs.id < rhs.id;
|
||||
if (lhs.kind != rhs.kind) {
|
||||
return static_cast<int>(lhs.kind) <
|
||||
static_cast<int>(rhs.kind);
|
||||
}
|
||||
return lhs.type_name < rhs.type_name;
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string DeviceManager::version() const {
|
||||
@ -389,27 +681,118 @@ void DeviceManager::pre_scan_robot_arm_dependencies_() const
|
||||
}
|
||||
}
|
||||
|
||||
void DeviceManager::init_devices_() {
|
||||
void DeviceManager::init_devices_()
|
||||
{
|
||||
std::size_t entry_index = 0;
|
||||
for (const auto& entry : cfg_.devices()) {
|
||||
std::string status_key = entry.id();
|
||||
if (status_key.empty()) {
|
||||
status_key = "<unnamed-config-device-" +
|
||||
std::to_string(entry_index) + ">";
|
||||
}
|
||||
++entry_index;
|
||||
|
||||
ManagedDeviceSnapshot initial_status;
|
||||
initial_status.id = entry.id();
|
||||
initial_status.kind = deviceKindFromConfig(entry.type());
|
||||
initial_status.type_name = toString(initial_status.kind);
|
||||
initial_status.enabled = entry.enable();
|
||||
updateManagedState(
|
||||
initial_status,
|
||||
entry.enable() ? ManagedDeviceState::Initializing
|
||||
: ManagedDeviceState::Disabled);
|
||||
|
||||
{
|
||||
std::unique_lock lock(devices_mutex_);
|
||||
const auto [status_it, inserted] =
|
||||
device_statuses_.emplace(status_key, initial_status);
|
||||
if (!inserted) {
|
||||
// Duplicate IDs are invalid, but if any duplicate entry is
|
||||
// enabled the consolidated error row must remain eligible for
|
||||
// heartbeat reporting regardless of configuration order.
|
||||
status_it->second.enabled =
|
||||
status_it->second.enabled || entry.enable();
|
||||
updateManagedState(
|
||||
status_it->second, ManagedDeviceState::Error,
|
||||
"duplicate configured device id: " + entry.id());
|
||||
CMVR_LOG(ERROR) << "[DeviceManager]: Duplicate configured "
|
||||
<< "device ID " << entry.id();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!entry.enable()) {
|
||||
continue;
|
||||
}
|
||||
if (entry.id().empty()) {
|
||||
std::unique_lock lock(devices_mutex_);
|
||||
updateManagedState(
|
||||
device_statuses_.at(status_key),
|
||||
ManagedDeviceState::Error,
|
||||
"configured device id is empty");
|
||||
CMVR_LOG(ERROR) << "[DeviceManager]: Enabled device id is empty";
|
||||
continue;
|
||||
}
|
||||
|
||||
CMVR_LOG(INFO) << "[DeviceManager]: Initialize device begin"
|
||||
<< ", id=" << entry.id()
|
||||
<< ", type=" << deviceTypeToString(entry.type())
|
||||
<< ", config_file=" << ConfigHelper::resolveConfigFile(entry.config_file());
|
||||
|
||||
DeviceRecord record = dev_factory_->create(entry);
|
||||
DeviceRecord record;
|
||||
try {
|
||||
record = dev_factory_->create(entry);
|
||||
} catch (const std::exception& error) {
|
||||
std::unique_lock lock(devices_mutex_);
|
||||
updateManagedState(
|
||||
device_statuses_.at(status_key),
|
||||
ManagedDeviceState::Error,
|
||||
exceptionError("device creation", error));
|
||||
CMVR_LOG(ERROR) << "[DeviceManager]: Device creation threw for "
|
||||
<< entry.id() << ": " << error.what();
|
||||
continue;
|
||||
} catch (...) {
|
||||
std::unique_lock lock(devices_mutex_);
|
||||
updateManagedState(
|
||||
device_statuses_.at(status_key),
|
||||
ManagedDeviceState::Error,
|
||||
"device creation threw an unknown exception");
|
||||
CMVR_LOG(ERROR) << "[DeviceManager]: Device creation threw for "
|
||||
<< entry.id();
|
||||
continue;
|
||||
}
|
||||
if (!record.device || record.id.empty()) {
|
||||
std::unique_lock lock(devices_mutex_);
|
||||
updateManagedState(
|
||||
device_statuses_.at(status_key),
|
||||
ManagedDeviceState::Error,
|
||||
"device creation failed");
|
||||
CMVR_LOG(ERROR) << "[DeviceManager]: Failed to create device for entry id=" << entry.id();
|
||||
continue;
|
||||
}
|
||||
|
||||
{
|
||||
std::unique_lock lock(devices_mutex_);
|
||||
auto& status = device_statuses_.at(status_key);
|
||||
status.id = record.id;
|
||||
status.kind = record.kind;
|
||||
status.type_name = record.type_name;
|
||||
}
|
||||
CMVR_LOG(INFO) << "[DeviceManager]: Create device object success"
|
||||
<< ", id=" << record.id
|
||||
<< ", type=" << record.type_name
|
||||
<< ", kind=" << toString(record.kind);
|
||||
if (devices_.count(record.id)) {
|
||||
bool duplicate_device = false;
|
||||
{
|
||||
std::shared_lock lock(devices_mutex_);
|
||||
duplicate_device = devices_.count(record.id) != 0;
|
||||
}
|
||||
if (duplicate_device) {
|
||||
std::unique_lock lock(devices_mutex_);
|
||||
updateManagedState(
|
||||
device_statuses_.at(status_key),
|
||||
ManagedDeviceState::Error,
|
||||
"duplicate device id: " + record.id);
|
||||
CMVR_LOG(ERROR) << "[DeviceManager]: Duplicate " << record.type_name << " Device ID " << record.id;
|
||||
continue;
|
||||
}
|
||||
@ -417,7 +800,27 @@ void DeviceManager::init_devices_() {
|
||||
<< ", id=" << record.id
|
||||
<< ", type=" << record.type_name
|
||||
<< ", kind=" << toString(record.kind);
|
||||
if (!record.device->init()) {
|
||||
bool initialized = false;
|
||||
std::string init_error;
|
||||
try {
|
||||
initialized = record.device->init();
|
||||
if (!initialized) {
|
||||
init_error = "device init returned false";
|
||||
}
|
||||
} catch (const std::exception& error) {
|
||||
init_error = exceptionError("device init", error);
|
||||
} catch (...) {
|
||||
init_error = "device init threw an unknown exception";
|
||||
}
|
||||
|
||||
if (!initialized) {
|
||||
{
|
||||
std::unique_lock lock(devices_mutex_);
|
||||
updateManagedState(
|
||||
device_statuses_.at(status_key),
|
||||
ManagedDeviceState::Error,
|
||||
std::move(init_error));
|
||||
}
|
||||
CMVR_LOG(ERROR) << "[DeviceManager]: Init device object failed"
|
||||
<< ", id=" << record.id
|
||||
<< ", type=" << record.type_name
|
||||
@ -430,6 +833,23 @@ void DeviceManager::init_devices_() {
|
||||
<< ", type=" << record.type_name
|
||||
<< ", kind=" << toString(record.kind)
|
||||
<< ", config_file=" << entry.config_file();
|
||||
devices_.emplace(record.id, std::move(record));
|
||||
{
|
||||
std::unique_lock lock(devices_mutex_);
|
||||
const auto record_id = record.id;
|
||||
const auto [device_it, inserted] =
|
||||
devices_.emplace(record_id, std::move(record));
|
||||
if (!inserted) {
|
||||
updateManagedState(
|
||||
device_statuses_.at(status_key),
|
||||
ManagedDeviceState::Error,
|
||||
"duplicate device id: " + record_id);
|
||||
CMVR_LOG(ERROR) << "[DeviceManager]: Duplicate device ID "
|
||||
<< record_id;
|
||||
continue;
|
||||
}
|
||||
updateManagedState(
|
||||
device_statuses_.at(status_key),
|
||||
ManagedDeviceState::Ready);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,379 @@
|
||||
#include "manager/device_manager/include/device_manager.h"
|
||||
|
||||
#include "devices/camera/abstract_camera.h"
|
||||
#include "devices/dexhand/abstract_dexhand.h"
|
||||
#include "devices/microphone/abstract_microphone.h"
|
||||
|
||||
#include <atomic>
|
||||
#include <cstddef>
|
||||
#include <memory>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
|
||||
namespace {
|
||||
|
||||
#define CHECK_TRUE(condition) \
|
||||
do { \
|
||||
if (!(condition)) { \
|
||||
return false; \
|
||||
} \
|
||||
} while (false)
|
||||
|
||||
using cmvr::device::AbstractDevice;
|
||||
using cmvr::device::DeviceHealthSnapshot;
|
||||
using cmvr::device::DeviceHealthState;
|
||||
using cmvr::device::DeviceKind;
|
||||
using cmvr::device::DeviceManager;
|
||||
using cmvr::device::DeviceManagerSnapshot;
|
||||
using cmvr::device::ManagedDeviceSnapshot;
|
||||
using cmvr::device::ManagedDeviceState;
|
||||
|
||||
class MemoryCamera final : public cmvr::device::AbstractCamera {
|
||||
public:
|
||||
std::string typeName() const override { return "MemoryCamera"; }
|
||||
void getState(cmvr::device::CameraState& output) override
|
||||
{
|
||||
output = state;
|
||||
}
|
||||
|
||||
cmvr::device::CameraState state{};
|
||||
};
|
||||
|
||||
class MemoryMicrophone final : public cmvr::device::AbstractMicrophone {
|
||||
public:
|
||||
std::string typeName() const override { return "MemoryMicrophone"; }
|
||||
void getState(cmvr::device::MicrophoneState& output) override
|
||||
{
|
||||
output = state;
|
||||
}
|
||||
|
||||
cmvr::device::MicrophoneState state{};
|
||||
};
|
||||
|
||||
class MemoryDexHand final : public cmvr::device::AbstractDexHand {
|
||||
public:
|
||||
std::string typeName() const override { return "MemoryDexHand"; }
|
||||
Status state() const override { return lifecycle; }
|
||||
std::string lastError() const override { return error; }
|
||||
void setAngles(const std::vector<int>&) override {}
|
||||
void setTactilePollingRegions(
|
||||
const std::vector<TactileRegionKey>&) override {}
|
||||
std::vector<TactileRegionData> getSensorData() override { return {}; }
|
||||
TactileRegionData getSensorData(FingerType, TactileRegion) override
|
||||
{
|
||||
return {};
|
||||
}
|
||||
ResultantForce getResultantForce(FingerType, TactileRegion) override
|
||||
{
|
||||
return {};
|
||||
}
|
||||
|
||||
Status lifecycle{Status::CREATED};
|
||||
std::string error;
|
||||
};
|
||||
|
||||
class FakeDevice final : public AbstractDevice {
|
||||
public:
|
||||
explicit FakeDevice(std::string id,
|
||||
DeviceKind kind = DeviceKind::Camera)
|
||||
: AbstractDevice(std::move(id)), kind_(kind)
|
||||
{
|
||||
}
|
||||
|
||||
DeviceKind kind() const noexcept override { return kind_; }
|
||||
std::string typeName() const override { return "FakeDevice"; }
|
||||
|
||||
bool start() override
|
||||
{
|
||||
++start_calls;
|
||||
if (throw_on_start) {
|
||||
throw std::runtime_error(std::string(700, 's'));
|
||||
}
|
||||
return start_result;
|
||||
}
|
||||
|
||||
bool stop() override
|
||||
{
|
||||
++stop_calls;
|
||||
if (throw_on_stop) {
|
||||
throw std::runtime_error(std::string(700, 't'));
|
||||
}
|
||||
return stop_result;
|
||||
}
|
||||
|
||||
DeviceHealthSnapshot healthSnapshot() override
|
||||
{
|
||||
++health_calls;
|
||||
if (throw_on_health) {
|
||||
throw std::runtime_error(std::string(700, 'h'));
|
||||
}
|
||||
return health;
|
||||
}
|
||||
|
||||
DeviceKind kind_;
|
||||
bool start_result{true};
|
||||
bool stop_result{true};
|
||||
bool throw_on_start{false};
|
||||
bool throw_on_stop{false};
|
||||
bool throw_on_health{false};
|
||||
DeviceHealthSnapshot health{DeviceHealthState::Healthy, {}};
|
||||
std::atomic<int> start_calls{0};
|
||||
std::atomic<int> stop_calls{0};
|
||||
std::atomic<int> health_calls{0};
|
||||
};
|
||||
|
||||
const ManagedDeviceSnapshot* findDevice(const DeviceManagerSnapshot& snapshot,
|
||||
const std::string& id)
|
||||
{
|
||||
for (const auto& device : snapshot.devices) {
|
||||
if (device.id == id) {
|
||||
return &device;
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
bool isSorted(const DeviceManagerSnapshot& snapshot)
|
||||
{
|
||||
for (std::size_t i = 1; i < snapshot.devices.size(); ++i) {
|
||||
if (snapshot.devices[i].id < snapshot.devices[i - 1].id) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool testCategoryHealthAdapters()
|
||||
{
|
||||
MemoryCamera camera;
|
||||
CHECK_TRUE(camera.healthSnapshot().state ==
|
||||
DeviceHealthState::Unknown);
|
||||
camera.state.is_initialized = true;
|
||||
CHECK_TRUE(camera.healthSnapshot().state ==
|
||||
DeviceHealthState::Healthy);
|
||||
camera.state.error_message = "camera warning";
|
||||
CHECK_TRUE(camera.healthSnapshot().state ==
|
||||
DeviceHealthState::Degraded);
|
||||
camera.state.is_error = true;
|
||||
CHECK_TRUE(camera.healthSnapshot().state ==
|
||||
DeviceHealthState::Fault);
|
||||
|
||||
MemoryMicrophone microphone;
|
||||
microphone.state.is_initialized = true;
|
||||
CHECK_TRUE(microphone.healthSnapshot().state ==
|
||||
DeviceHealthState::Healthy);
|
||||
microphone.state.is_error = true;
|
||||
microphone.state.error_message = "microphone fault";
|
||||
const auto microphone_health = microphone.healthSnapshot();
|
||||
CHECK_TRUE(microphone_health.state == DeviceHealthState::Fault);
|
||||
CHECK_TRUE(microphone_health.error_message == "microphone fault");
|
||||
|
||||
MemoryDexHand dexhand;
|
||||
CHECK_TRUE(dexhand.healthSnapshot().state ==
|
||||
DeviceHealthState::Unknown);
|
||||
dexhand.lifecycle = MemoryDexHand::Status::INITIALIZED;
|
||||
CHECK_TRUE(dexhand.healthSnapshot().state ==
|
||||
DeviceHealthState::Healthy);
|
||||
dexhand.error = "temporary warning";
|
||||
CHECK_TRUE(dexhand.healthSnapshot().state ==
|
||||
DeviceHealthState::Degraded);
|
||||
dexhand.lifecycle = MemoryDexHand::Status::FAULT;
|
||||
CHECK_TRUE(dexhand.healthSnapshot().state ==
|
||||
DeviceHealthState::Fault);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool testConfiguredAndDynamicSnapshots()
|
||||
{
|
||||
cmvr::config::DeviceManagerConfig config;
|
||||
config.set_name("snapshot-test");
|
||||
config.set_version("9.1");
|
||||
config.set_description("device manager snapshot test");
|
||||
|
||||
auto* disabled = config.add_devices();
|
||||
disabled->set_id("disabled_camera");
|
||||
disabled->set_type(
|
||||
cmvr::config::DeviceConfigEntry::DEVICE_TYPE_CAMERA);
|
||||
disabled->set_enable(false);
|
||||
|
||||
auto* broken = config.add_devices();
|
||||
broken->set_id("broken_device");
|
||||
broken->set_type(
|
||||
cmvr::config::DeviceConfigEntry::DEVICE_TYPE_UNKNOWN);
|
||||
broken->set_enable(true);
|
||||
|
||||
auto* duplicate_disabled = config.add_devices();
|
||||
duplicate_disabled->set_id("duplicate_device");
|
||||
duplicate_disabled->set_type(
|
||||
cmvr::config::DeviceConfigEntry::DEVICE_TYPE_CAMERA);
|
||||
duplicate_disabled->set_enable(false);
|
||||
|
||||
auto* duplicate_enabled = config.add_devices();
|
||||
duplicate_enabled->set_id("duplicate_device");
|
||||
duplicate_enabled->set_type(
|
||||
cmvr::config::DeviceConfigEntry::DEVICE_TYPE_CAMERA);
|
||||
duplicate_enabled->set_enable(true);
|
||||
|
||||
auto& manager = DeviceManager::getInstance(config);
|
||||
auto configured = manager.snapshot();
|
||||
CHECK_TRUE(configured.name == "snapshot-test");
|
||||
CHECK_TRUE(configured.version == "9.1");
|
||||
CHECK_TRUE(configured.description == "device manager snapshot test");
|
||||
CHECK_TRUE(configured.devices.size() == 3);
|
||||
CHECK_TRUE(isSorted(configured));
|
||||
|
||||
const auto* disabled_status =
|
||||
findDevice(configured, "disabled_camera");
|
||||
CHECK_TRUE(disabled_status != nullptr);
|
||||
CHECK_TRUE(!disabled_status->enabled);
|
||||
CHECK_TRUE(disabled_status->kind == DeviceKind::Camera);
|
||||
CHECK_TRUE(disabled_status->type_name == "Camera");
|
||||
CHECK_TRUE(disabled_status->state == ManagedDeviceState::Disabled);
|
||||
CHECK_TRUE(disabled_status->health.state ==
|
||||
DeviceHealthState::Unknown);
|
||||
CHECK_TRUE(!disabled_status->abnormal);
|
||||
CHECK_TRUE(disabled_status->status_updated_at_unix_ms != 0);
|
||||
|
||||
const auto* broken_status = findDevice(configured, "broken_device");
|
||||
CHECK_TRUE(broken_status != nullptr);
|
||||
CHECK_TRUE(broken_status->enabled);
|
||||
CHECK_TRUE(broken_status->state == ManagedDeviceState::Error);
|
||||
CHECK_TRUE(broken_status->health.state == DeviceHealthState::Unknown);
|
||||
CHECK_TRUE(broken_status->abnormal);
|
||||
CHECK_TRUE(!broken_status->error_message.empty());
|
||||
CHECK_TRUE(broken_status->error_message.size() <= 512);
|
||||
|
||||
const auto* duplicate_status =
|
||||
findDevice(configured, "duplicate_device");
|
||||
CHECK_TRUE(duplicate_status != nullptr);
|
||||
CHECK_TRUE(duplicate_status->enabled);
|
||||
CHECK_TRUE(duplicate_status->state == ManagedDeviceState::Error);
|
||||
CHECK_TRUE(duplicate_status->abnormal);
|
||||
CHECK_TRUE(duplicate_status->error_message ==
|
||||
"duplicate configured device id: duplicate_device");
|
||||
|
||||
auto healthy = std::make_shared<FakeDevice>("z_healthy");
|
||||
auto degraded = std::make_shared<FakeDevice>("a_degraded");
|
||||
degraded->health = {
|
||||
DeviceHealthState::Degraded, std::string(700, 'd')};
|
||||
auto start_fail = std::make_shared<FakeDevice>("m_start_fail");
|
||||
start_fail->start_result = false;
|
||||
auto stop_fail = std::make_shared<FakeDevice>("n_stop_fail");
|
||||
stop_fail->stop_result = false;
|
||||
auto health_throw = std::make_shared<FakeDevice>("b_health_throw");
|
||||
health_throw->throw_on_health = true;
|
||||
|
||||
manager.registerDevice(healthy);
|
||||
manager.registerDevice(degraded);
|
||||
manager.registerDevice(start_fail);
|
||||
manager.registerDevice(stop_fail);
|
||||
manager.registerDevice(health_throw);
|
||||
|
||||
// Duplicate registration must retain the original object and status.
|
||||
manager.registerDevice(
|
||||
std::make_shared<FakeDevice>("z_healthy", DeviceKind::Speaker));
|
||||
CHECK_TRUE(manager.getDeviceBase("z_healthy") == healthy);
|
||||
|
||||
const auto registered = manager.snapshot();
|
||||
CHECK_TRUE(isSorted(registered));
|
||||
const auto* healthy_registered =
|
||||
findDevice(registered, "z_healthy");
|
||||
CHECK_TRUE(healthy_registered != nullptr);
|
||||
CHECK_TRUE(healthy_registered->state ==
|
||||
ManagedDeviceState::Registered);
|
||||
CHECK_TRUE(healthy_registered->health.state ==
|
||||
DeviceHealthState::Healthy);
|
||||
CHECK_TRUE(!healthy_registered->abnormal);
|
||||
|
||||
const auto* degraded_registered =
|
||||
findDevice(registered, "a_degraded");
|
||||
CHECK_TRUE(degraded_registered != nullptr);
|
||||
CHECK_TRUE(degraded_registered->abnormal);
|
||||
CHECK_TRUE(degraded_registered->health.state ==
|
||||
DeviceHealthState::Degraded);
|
||||
CHECK_TRUE(degraded_registered->health.error_message.size() == 512);
|
||||
CHECK_TRUE(degraded_registered->error_message.size() == 512);
|
||||
|
||||
const auto* thrown_health =
|
||||
findDevice(registered, "b_health_throw");
|
||||
CHECK_TRUE(thrown_health != nullptr);
|
||||
CHECK_TRUE(thrown_health->abnormal);
|
||||
CHECK_TRUE(thrown_health->health.state ==
|
||||
DeviceHealthState::Fault);
|
||||
CHECK_TRUE(thrown_health->health.error_message.size() <= 512);
|
||||
CHECK_TRUE(thrown_health->error_message.size() <= 512);
|
||||
|
||||
manager.start();
|
||||
const auto running = manager.snapshot();
|
||||
CHECK_TRUE(findDevice(running, "z_healthy")->state ==
|
||||
ManagedDeviceState::Running);
|
||||
CHECK_TRUE(findDevice(running, "m_start_fail")->state ==
|
||||
ManagedDeviceState::Error);
|
||||
CHECK_TRUE(findDevice(running, "m_start_fail")->abnormal);
|
||||
CHECK_TRUE(findDevice(running, "m_start_fail")->health.state ==
|
||||
DeviceHealthState::Healthy);
|
||||
CHECK_TRUE(healthy->start_calls.load() == 1);
|
||||
|
||||
// The earlier value snapshot remains independent from manager mutations.
|
||||
CHECK_TRUE(healthy_registered->state ==
|
||||
ManagedDeviceState::Registered);
|
||||
|
||||
manager.stop();
|
||||
const auto stopped = manager.snapshot();
|
||||
CHECK_TRUE(findDevice(stopped, "z_healthy")->state ==
|
||||
ManagedDeviceState::Stopped);
|
||||
CHECK_TRUE(findDevice(stopped, "n_stop_fail")->state ==
|
||||
ManagedDeviceState::Error);
|
||||
CHECK_TRUE(findDevice(stopped, "n_stop_fail")->abnormal);
|
||||
CHECK_TRUE(healthy->stop_calls.load() == 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool testConcurrentSnapshotAndRegistration()
|
||||
{
|
||||
auto& manager = DeviceManager::getInstance();
|
||||
std::atomic<bool> done{false};
|
||||
std::atomic<bool> reader_ok{true};
|
||||
|
||||
std::thread reader([&] {
|
||||
while (!done.load(std::memory_order_acquire)) {
|
||||
const auto current = manager.snapshot();
|
||||
if (!isSorted(current)) {
|
||||
reader_ok.store(false, std::memory_order_release);
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
for (int i = 0; i < 32; ++i) {
|
||||
manager.registerDevice(
|
||||
std::make_shared<FakeDevice>(
|
||||
"concurrent_" + std::to_string(i)));
|
||||
}
|
||||
done.store(true, std::memory_order_release);
|
||||
reader.join();
|
||||
|
||||
CHECK_TRUE(reader_ok.load(std::memory_order_acquire));
|
||||
const auto final_snapshot = manager.snapshot();
|
||||
CHECK_TRUE(isSorted(final_snapshot));
|
||||
for (int i = 0; i < 32; ++i) {
|
||||
CHECK_TRUE(
|
||||
findDevice(final_snapshot,
|
||||
"concurrent_" + std::to_string(i)) != nullptr);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main()
|
||||
{
|
||||
DeviceManager::destroyInstance();
|
||||
const bool success =
|
||||
testCategoryHealthAdapters() &&
|
||||
testConfiguredAndDynamicSnapshots() &&
|
||||
testConcurrentSnapshotAndRegistration();
|
||||
DeviceManager::destroyInstance();
|
||||
return success ? 0 : 1;
|
||||
}
|
||||
@ -141,6 +141,7 @@ QUIC 配置中的 `grpc_endpoint_tls` 只是上报字段,不会启用 gRPC TLS
|
||||
| 传输抽象 | `quic_edge/include/quic_transport.h` |
|
||||
| MsQuic 后端 | `quic_edge/src/msquic_transport.cpp` |
|
||||
| 设备媒体适配 | `quic_edge/src/quic_edge_device_adapter.cpp` |
|
||||
| DeviceManager 心跳适配 | `quic_edge/src/quic_edge_device_adapter.cpp` |
|
||||
|
||||
线协议见 [`../../protos/cmvr/quic_edge/v1/README.md`](../../protos/cmvr/quic_edge/v1/README.md)。
|
||||
|
||||
@ -177,6 +178,19 @@ QUIC 配置中的 `grpc_endpoint_tls` 只是上报字段,不会启用 gRPC TLS
|
||||
|
||||
虽然 Proto 定义了 `MediaSessionClose`,本版本 Edge 收到它仍会判为 unexpected,不应将其描述为已实现的双向控制能力。
|
||||
|
||||
### DeviceManager 心跳快照
|
||||
|
||||
`QuicEdgeService` 通过可注入的 `DeviceSnapshotProvider` 获取协议无关的纯值
|
||||
快照。生产构造绑定已经初始化的 `DeviceManager`,fake transport 测试则注入
|
||||
合成快照,因此协议状态机不需要创建硬件对象或依赖 DeviceManager 单例。
|
||||
DeviceManager 的本地快照继续保留禁用设备;QUIC wire 映射层仅序列化
|
||||
`enabled=true` 的设备。已启用但创建、初始化或启动失败的设备不会被过滤。
|
||||
|
||||
心跳线程只读取 Manager 维护的内存状态,不能在这里同步访问厂商 SDK、网络或
|
||||
设备总线。新增设备健康探针必须实现 `AbstractDevice::healthSnapshot()` 的
|
||||
线程安全、无阻塞 I/O 契约;未实现时上报 `UNSPECIFIED`,不得伪造为健康。
|
||||
设备异常字符串会限长,整条消息仍受 `maximum_control_frame_bytes` 约束。
|
||||
|
||||
### 跨 QUIC 通道顺序
|
||||
|
||||
Edge 会先调用可靠流发送 session/descriptor,再调用 DATAGRAM 发送媒体,但 QUIC stream 与 DATAGRAM 没有跨通道到达顺序保证。
|
||||
@ -190,6 +204,8 @@ Gateway 必须容忍 DATAGRAM 先到,对未知 session epoch 或 codec generat
|
||||
- 控制消息拆包、粘包和超限;
|
||||
- 重复或倒退 sequence;
|
||||
- 注册、ACK 超时和重连;
|
||||
- DeviceManager 已启用设备过滤、类型/状态映射和 provider 失败隔离;
|
||||
- Gateway 返回零心跳周期时采用本地 `heartbeat_interval_ms`;
|
||||
- session epoch 清理;
|
||||
- DATAGRAM header 字节序;
|
||||
- 分片边界和超大帧;
|
||||
|
||||
@ -25,15 +25,32 @@ if(CMVR_HAS_MSQUIC)
|
||||
target_compile_definitions(quic_edge_service PRIVATE CMVR_HAS_MSQUIC=1)
|
||||
target_link_libraries(quic_edge_service PRIVATE MsQuic::msquic)
|
||||
|
||||
# Bundle a repository-local or explicitly supplied MsQuic runtime with the
|
||||
# existing relocatable output tree. System packages remain system-owned.
|
||||
string(FIND "${MsQuic_LIBRARY}" "${PROJECT_SOURCE_DIR}/dependency/"
|
||||
_cmvr_bundled_msquic_index)
|
||||
if(MsQuic_LIBRARY MATCHES "\\.so" AND
|
||||
(_cmvr_bundled_msquic_index EQUAL 0 OR CMVR_MSQUIC_ROOT))
|
||||
# Install only the runtime that FindMsQuic actually selected. Keeping this
|
||||
# out of the generic request.txt installer prevents a custom version/root
|
||||
# from being mixed with the repository default through the same soname.
|
||||
file(REAL_PATH "${MsQuic_LIBRARY}" _cmvr_msquic_library_real)
|
||||
if(MsQuic_ROOT AND MsQuic_LIBRARY MATCHES "\\.so")
|
||||
get_filename_component(_cmvr_msquic_library_dir "${MsQuic_LIBRARY}" DIRECTORY)
|
||||
install(DIRECTORY "${_cmvr_msquic_library_dir}/" DESTINATION lib
|
||||
FILES_MATCHING PATTERN "libmsquic.so*")
|
||||
file(GLOB _cmvr_msquic_library_candidates
|
||||
LIST_DIRECTORIES FALSE
|
||||
"${_cmvr_msquic_library_dir}/libmsquic.so*")
|
||||
set(_cmvr_msquic_selected_chain)
|
||||
foreach(_cmvr_msquic_library_candidate
|
||||
IN LISTS _cmvr_msquic_library_candidates)
|
||||
file(REAL_PATH "${_cmvr_msquic_library_candidate}"
|
||||
_cmvr_msquic_candidate_real)
|
||||
if(_cmvr_msquic_candidate_real STREQUAL
|
||||
_cmvr_msquic_library_real)
|
||||
list(APPEND _cmvr_msquic_selected_chain
|
||||
"${_cmvr_msquic_library_candidate}")
|
||||
endif()
|
||||
endforeach()
|
||||
if(NOT _cmvr_msquic_selected_chain)
|
||||
message(FATAL_ERROR
|
||||
"Could not resolve the selected MsQuic runtime chain: "
|
||||
"${MsQuic_LIBRARY}")
|
||||
endif()
|
||||
install(FILES ${_cmvr_msquic_selected_chain} DESTINATION lib)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
#include <vector>
|
||||
|
||||
#include "cmvr/config/quic_edge_config/quic_edge_config.pb.h"
|
||||
#include "devices/device_types.h"
|
||||
#include "manager/media_source_hub/include/media_source_hub.h"
|
||||
#include "service/quic_edge/include/control_framing.h"
|
||||
#include "service/quic_edge/include/datagram_packetizer.h"
|
||||
@ -70,10 +71,14 @@ struct QuicEdgeStatus {
|
||||
|
||||
class QuicEdgeService {
|
||||
public:
|
||||
using DeviceSnapshotProvider =
|
||||
std::function<device::DeviceManagerSnapshot()>;
|
||||
|
||||
explicit QuicEdgeService(config::QuicEdgeConfig config);
|
||||
QuicEdgeService(config::QuicEdgeConfig config,
|
||||
std::unique_ptr<QuicTransport> transport,
|
||||
media::MediaSourceHub& media_hub);
|
||||
media::MediaSourceHub& media_hub,
|
||||
DeviceSnapshotProvider device_snapshot_provider = {});
|
||||
~QuicEdgeService();
|
||||
|
||||
QuicEdgeService(const QuicEdgeService&) = delete;
|
||||
@ -152,6 +157,7 @@ private:
|
||||
bool using_default_transport_{false};
|
||||
bool using_global_media_hub_{false};
|
||||
SourceRegistrar source_registrar_;
|
||||
DeviceSnapshotProvider device_snapshot_provider_;
|
||||
|
||||
std::mutex lifecycle_mutex_;
|
||||
std::mutex control_send_mutex_;
|
||||
|
||||
@ -12,7 +12,10 @@ QuicEdgeService::QuicEdgeService(config::QuicEdgeConfig config)
|
||||
transport_(createDefaultQuicTransport(config_.datagram_send_queue_depth())),
|
||||
media_hub_(&media::globalMediaSourceHub()),
|
||||
using_default_transport_(true),
|
||||
using_global_media_hub_(true)
|
||||
using_global_media_hub_(true),
|
||||
device_snapshot_provider_([] {
|
||||
return device::DeviceManager::getInstance().snapshot();
|
||||
})
|
||||
{
|
||||
initializeIdentity();
|
||||
source_registrar_ = [this](
|
||||
|
||||
@ -33,6 +33,7 @@ constexpr std::uint32_t kMaximumDatagramBytes = 65527U;
|
||||
constexpr std::uint32_t kMaximumControlBytes = 16U * 1024U * 1024U;
|
||||
constexpr std::uint32_t kMaximumConfiguredFrameBytes = 256U * 1024U * 1024U;
|
||||
constexpr std::size_t kMaximumDrainPerPoll = 4096U;
|
||||
constexpr std::size_t kMaximumDeviceErrorBytes = 512U;
|
||||
constexpr std::uint32_t kMinimumHeartbeatIntervalMs = 250U;
|
||||
constexpr std::uint32_t kMaximumHeartbeatIntervalMs = 60U * 60U * 1000U;
|
||||
constexpr auto kControlPollInterval = std::chrono::milliseconds(50);
|
||||
@ -46,6 +47,18 @@ void setError(std::string* error, const std::string& message)
|
||||
}
|
||||
}
|
||||
|
||||
std::string boundedDeviceError(std::string message)
|
||||
{
|
||||
if (message.size() <= kMaximumDeviceErrorBytes) return message;
|
||||
std::size_t end = kMaximumDeviceErrorBytes;
|
||||
while (end > 0U &&
|
||||
(static_cast<unsigned char>(message[end]) & 0xc0U) == 0x80U) {
|
||||
--end;
|
||||
}
|
||||
message.resize(end);
|
||||
return message;
|
||||
}
|
||||
|
||||
std::string trim(std::string value)
|
||||
{
|
||||
const auto first = value.find_first_not_of(" \t\r\n");
|
||||
@ -231,6 +244,116 @@ void populateHeartbeatNetwork(const config::QuicEdgeConfig& config,
|
||||
endpoint->set_tls(config.grpc_endpoint_tls());
|
||||
}
|
||||
|
||||
v1::DeviceKind toProtoDeviceKind(const device::DeviceKind kind)
|
||||
{
|
||||
switch (kind) {
|
||||
case device::DeviceKind::AGV: return v1::DEVICE_KIND_AGV;
|
||||
case device::DeviceKind::Arm: return v1::DEVICE_KIND_ARM;
|
||||
case device::DeviceKind::Battery: return v1::DEVICE_KIND_BATTERY;
|
||||
case device::DeviceKind::BioHead: return v1::DEVICE_KIND_BIO_HEAD;
|
||||
case device::DeviceKind::Camera: return v1::DEVICE_KIND_CAMERA;
|
||||
case device::DeviceKind::CanBus: return v1::DEVICE_KIND_CAN_BUS;
|
||||
case device::DeviceKind::DexHand: return v1::DEVICE_KIND_DEX_HAND;
|
||||
case device::DeviceKind::Gripper: return v1::DEVICE_KIND_GRIPPER;
|
||||
case device::DeviceKind::Microphone: return v1::DEVICE_KIND_MICROPHONE;
|
||||
case device::DeviceKind::Motor: return v1::DEVICE_KIND_MOTOR;
|
||||
case device::DeviceKind::MotorSystem:
|
||||
return v1::DEVICE_KIND_MOTOR_SYSTEM;
|
||||
case device::DeviceKind::Robot: return v1::DEVICE_KIND_ROBOT;
|
||||
case device::DeviceKind::Speaker: return v1::DEVICE_KIND_SPEAKER;
|
||||
case device::DeviceKind::Unknown: break;
|
||||
}
|
||||
return v1::DEVICE_KIND_UNSPECIFIED;
|
||||
}
|
||||
|
||||
v1::ManagedDeviceState toProtoManagedDeviceState(
|
||||
const device::ManagedDeviceState state)
|
||||
{
|
||||
switch (state) {
|
||||
case device::ManagedDeviceState::Disabled:
|
||||
return v1::MANAGED_DEVICE_STATE_DISABLED;
|
||||
case device::ManagedDeviceState::Initializing:
|
||||
return v1::MANAGED_DEVICE_STATE_INITIALIZING;
|
||||
case device::ManagedDeviceState::Registered:
|
||||
return v1::MANAGED_DEVICE_STATE_REGISTERED;
|
||||
case device::ManagedDeviceState::Ready:
|
||||
return v1::MANAGED_DEVICE_STATE_READY;
|
||||
case device::ManagedDeviceState::Running:
|
||||
return v1::MANAGED_DEVICE_STATE_RUNNING;
|
||||
case device::ManagedDeviceState::Stopped:
|
||||
return v1::MANAGED_DEVICE_STATE_STOPPED;
|
||||
case device::ManagedDeviceState::Error:
|
||||
return v1::MANAGED_DEVICE_STATE_ERROR;
|
||||
case device::ManagedDeviceState::Unknown:
|
||||
break;
|
||||
}
|
||||
return v1::MANAGED_DEVICE_STATE_UNSPECIFIED;
|
||||
}
|
||||
|
||||
v1::DeviceHealthStatus toProtoDeviceHealthStatus(
|
||||
const device::DeviceHealthState state)
|
||||
{
|
||||
switch (state) {
|
||||
case device::DeviceHealthState::Healthy:
|
||||
return v1::DEVICE_HEALTH_STATUS_HEALTHY;
|
||||
case device::DeviceHealthState::Degraded:
|
||||
return v1::DEVICE_HEALTH_STATUS_DEGRADED;
|
||||
case device::DeviceHealthState::Fault:
|
||||
return v1::DEVICE_HEALTH_STATUS_FAULT;
|
||||
case device::DeviceHealthState::Unknown:
|
||||
break;
|
||||
}
|
||||
return v1::DEVICE_HEALTH_STATUS_UNSPECIFIED;
|
||||
}
|
||||
|
||||
void populateDeviceManagerSnapshot(
|
||||
const device::DeviceManagerSnapshot& source,
|
||||
const std::uint64_t sampled_at_unix_ms,
|
||||
v1::DeviceManagerSnapshot* destination)
|
||||
{
|
||||
if (!destination) return;
|
||||
destination->set_manager_name(source.name);
|
||||
destination->set_manager_version(source.version);
|
||||
destination->set_manager_description(source.description);
|
||||
destination->set_sampled_at_unix_ms(sampled_at_unix_ms);
|
||||
std::vector<const device::ManagedDeviceSnapshot*> ordered_devices;
|
||||
ordered_devices.reserve(source.devices.size());
|
||||
for (const auto& source_device : source.devices) {
|
||||
// DeviceManager keeps disabled entries for local configuration and
|
||||
// diagnostics, but the platform heartbeat only advertises devices
|
||||
// that are enabled on this edge node. Enabled entries remain visible
|
||||
// even when creation, initialization or start has failed.
|
||||
if (!source_device.enabled) continue;
|
||||
ordered_devices.push_back(&source_device);
|
||||
}
|
||||
std::sort(
|
||||
ordered_devices.begin(), ordered_devices.end(),
|
||||
[](const auto* lhs, const auto* rhs) {
|
||||
if (lhs->id != rhs->id) return lhs->id < rhs->id;
|
||||
if (lhs->kind != rhs->kind) return lhs->kind < rhs->kind;
|
||||
return lhs->type_name < rhs->type_name;
|
||||
});
|
||||
|
||||
for (const auto* source_device : ordered_devices) {
|
||||
auto* destination_device = destination->add_devices();
|
||||
destination_device->set_device_id(source_device->id);
|
||||
destination_device->set_kind(toProtoDeviceKind(source_device->kind));
|
||||
destination_device->set_type_name(source_device->type_name);
|
||||
destination_device->set_enabled(source_device->enabled);
|
||||
destination_device->set_manager_state(
|
||||
toProtoManagedDeviceState(source_device->state));
|
||||
destination_device->set_health(
|
||||
toProtoDeviceHealthStatus(source_device->health.state));
|
||||
destination_device->set_has_error(source_device->abnormal);
|
||||
destination_device->set_error_message(boundedDeviceError(
|
||||
source_device->error_message.empty()
|
||||
? source_device->health.error_message
|
||||
: source_device->error_message));
|
||||
destination_device->set_status_updated_at_unix_ms(
|
||||
source_device->status_updated_at_unix_ms);
|
||||
}
|
||||
}
|
||||
|
||||
v1::MediaKind toProtoKind(const media::MediaKind kind)
|
||||
{
|
||||
switch (kind) {
|
||||
@ -297,10 +420,12 @@ const char* toString(const QuicEdgeServiceState state)
|
||||
|
||||
QuicEdgeService::QuicEdgeService(config::QuicEdgeConfig config,
|
||||
std::unique_ptr<QuicTransport> transport,
|
||||
media::MediaSourceHub& media_hub)
|
||||
media::MediaSourceHub& media_hub,
|
||||
DeviceSnapshotProvider device_snapshot_provider)
|
||||
: config_(std::move(config)),
|
||||
transport_(std::move(transport)),
|
||||
media_hub_(&media_hub)
|
||||
media_hub_(&media_hub),
|
||||
device_snapshot_provider_(std::move(device_snapshot_provider))
|
||||
{
|
||||
initializeIdentity();
|
||||
}
|
||||
@ -708,14 +833,18 @@ void QuicEdgeService::run()
|
||||
connection_failed = true;
|
||||
break;
|
||||
}
|
||||
const auto heartbeat_sent_at =
|
||||
std::chrono::steady_clock::now();
|
||||
{
|
||||
std::lock_guard lock(mutex_);
|
||||
outstanding_heartbeat_sequence_ = sequence;
|
||||
heartbeat_deadline_ = now + std::chrono::milliseconds(
|
||||
heartbeat_deadline_ =
|
||||
heartbeat_sent_at + std::chrono::milliseconds(
|
||||
config_.control_response_timeout_ms());
|
||||
++stats_.heartbeats_sent;
|
||||
}
|
||||
next_heartbeat_ = now + std::chrono::milliseconds(
|
||||
next_heartbeat_ =
|
||||
heartbeat_sent_at + std::chrono::milliseconds(
|
||||
effective_heartbeat_interval_ms_);
|
||||
}
|
||||
|
||||
@ -963,6 +1092,24 @@ bool QuicEdgeService::sendNodeRegistration(std::string* error)
|
||||
bool QuicEdgeService::sendHeartbeat(const std::uint64_t sequence,
|
||||
std::string* error)
|
||||
{
|
||||
std::optional<device::DeviceManagerSnapshot> device_manager_snapshot;
|
||||
if (device_snapshot_provider_) {
|
||||
try {
|
||||
device_manager_snapshot = device_snapshot_provider_();
|
||||
} catch (const std::exception& exception) {
|
||||
setError(
|
||||
error,
|
||||
std::string("failed to snapshot DeviceManager for heartbeat: ") +
|
||||
exception.what());
|
||||
return false;
|
||||
} catch (...) {
|
||||
setError(error,
|
||||
"failed to snapshot DeviceManager for heartbeat: "
|
||||
"unknown exception");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
std::lock_guard control_lock(control_send_mutex_);
|
||||
std::string session_id;
|
||||
{
|
||||
@ -982,9 +1129,15 @@ bool QuicEdgeService::sendHeartbeat(const std::uint64_t sequence,
|
||||
heartbeat->set_boot_id(boot_id_);
|
||||
heartbeat->set_session_id(session_id);
|
||||
heartbeat->set_sequence(sequence);
|
||||
heartbeat->set_sent_at_unix_ms(unixTimeMs());
|
||||
const std::uint64_t sent_at_unix_ms = unixTimeMs();
|
||||
heartbeat->set_sent_at_unix_ms(sent_at_unix_ms);
|
||||
heartbeat->set_software_version(software_version_);
|
||||
populateHeartbeatNetwork(config_, heartbeat);
|
||||
if (device_manager_snapshot.has_value()) {
|
||||
populateDeviceManagerSnapshot(
|
||||
*device_manager_snapshot, sent_at_unix_ms,
|
||||
heartbeat->mutable_device_manager());
|
||||
}
|
||||
std::string serialized;
|
||||
if (!envelope.SerializeToString(&serialized)) {
|
||||
setError(error, "failed to serialize NodeHeartbeat");
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
#include <future>
|
||||
#include <iostream>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <utility>
|
||||
@ -36,11 +37,17 @@ public:
|
||||
explicit FakeTransport(const bool accept_registration = true,
|
||||
const bool acknowledge_heartbeats = true,
|
||||
const bool valid_heartbeat_session = true,
|
||||
const std::size_t media_session_would_block_count = 0U)
|
||||
const std::size_t media_session_would_block_count = 0U,
|
||||
const std::uint32_t registration_heartbeat_interval_ms =
|
||||
250U,
|
||||
const std::uint32_t heartbeat_ack_delay_ms = 0U)
|
||||
: accept_registration_(accept_registration),
|
||||
acknowledge_heartbeats_(acknowledge_heartbeats),
|
||||
valid_heartbeat_session_(valid_heartbeat_session),
|
||||
media_session_would_block_count_(media_session_would_block_count)
|
||||
media_session_would_block_count_(media_session_would_block_count),
|
||||
registration_heartbeat_interval_ms_(
|
||||
registration_heartbeat_interval_ms),
|
||||
heartbeat_ack_delay_ms_(heartbeat_ack_delay_ms)
|
||||
{
|
||||
}
|
||||
|
||||
@ -61,6 +68,10 @@ public:
|
||||
void disconnect() override
|
||||
{
|
||||
connected_.store(false);
|
||||
{
|
||||
std::lock_guard lock(mutex_);
|
||||
delayed_heartbeat_ack_.reset();
|
||||
}
|
||||
condition_.notify_all();
|
||||
}
|
||||
bool isConnected() const override { return connected_.load(); }
|
||||
@ -105,11 +116,20 @@ public:
|
||||
accept_registration_ ? "test-session" : "");
|
||||
registration->set_message(
|
||||
accept_registration_ ? "accepted" : "rejected for test");
|
||||
registration->set_heartbeat_interval_ms(250U);
|
||||
registration->set_heartbeat_interval_ms(
|
||||
registration_heartbeat_interval_ms_);
|
||||
registration->set_observed_source_ip("203.0.113.10");
|
||||
enqueueEnvelopeLocked(response);
|
||||
} else if (envelope.has_node_heartbeat() && acknowledge_heartbeats_) {
|
||||
} else if (envelope.has_node_heartbeat()) {
|
||||
const auto& heartbeat = envelope.node_heartbeat();
|
||||
last_heartbeat_ = heartbeat;
|
||||
has_last_heartbeat_ = true;
|
||||
heartbeat_times_.push_back(
|
||||
std::chrono::steady_clock::now());
|
||||
if (!acknowledge_heartbeats_) {
|
||||
condition_.notify_all();
|
||||
return quic_edge::TransportSendResult::QUEUED;
|
||||
}
|
||||
cmvr::quic_edge::v1::EdgeControlEnvelope response;
|
||||
response.set_protocol_version(quic_edge::kProtocolVersion);
|
||||
response.set_message_sequence(server_message_sequence_++);
|
||||
@ -119,7 +139,15 @@ public:
|
||||
ack->set_session_id(valid_heartbeat_session_
|
||||
? heartbeat.session_id() : "");
|
||||
ack->set_observed_source_ip("203.0.113.11");
|
||||
if (heartbeat_ack_delay_ms_ == 0U) {
|
||||
enqueueEnvelopeLocked(response);
|
||||
} else {
|
||||
delayed_heartbeat_ack_ = std::move(response);
|
||||
delayed_heartbeat_ack_ready_at_ =
|
||||
std::chrono::steady_clock::now() +
|
||||
std::chrono::milliseconds(
|
||||
heartbeat_ack_delay_ms_);
|
||||
}
|
||||
}
|
||||
}
|
||||
condition_.notify_all();
|
||||
@ -133,10 +161,24 @@ public:
|
||||
{
|
||||
if (!chunk) return quic_edge::TransportReceiveResult::ERROR;
|
||||
std::unique_lock lock(mutex_);
|
||||
releaseDelayedHeartbeatAckLocked();
|
||||
if (control_receive_queue_.empty() && connected_.load()) {
|
||||
condition_.wait_for(lock, timeout, [this]() {
|
||||
return !control_receive_queue_.empty() || !connected_.load();
|
||||
auto wait_duration = timeout;
|
||||
if (delayed_heartbeat_ack_) {
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
if (now < delayed_heartbeat_ack_ready_at_) {
|
||||
wait_duration = std::min(
|
||||
wait_duration,
|
||||
std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
delayed_heartbeat_ack_ready_at_ - now) +
|
||||
std::chrono::milliseconds(1));
|
||||
}
|
||||
}
|
||||
condition_.wait_for(lock, wait_duration, [this]() {
|
||||
return !control_receive_queue_.empty() ||
|
||||
!connected_.load();
|
||||
});
|
||||
releaseDelayedHeartbeatAckLocked();
|
||||
}
|
||||
if (!control_receive_queue_.empty()) {
|
||||
*chunk = std::move(control_receive_queue_.front());
|
||||
@ -223,7 +265,46 @@ public:
|
||||
return last_interface_count_;
|
||||
}
|
||||
|
||||
std::size_t heartbeatCount() const
|
||||
{
|
||||
std::lock_guard lock(mutex_);
|
||||
return heartbeat_times_.size();
|
||||
}
|
||||
|
||||
std::vector<std::chrono::milliseconds> heartbeatIntervals() const
|
||||
{
|
||||
std::lock_guard lock(mutex_);
|
||||
std::vector<std::chrono::milliseconds> intervals;
|
||||
for (std::size_t index = 1U;
|
||||
index < heartbeat_times_.size(); ++index) {
|
||||
intervals.push_back(
|
||||
std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
heartbeat_times_[index] -
|
||||
heartbeat_times_[index - 1U]));
|
||||
}
|
||||
return intervals;
|
||||
}
|
||||
|
||||
cmvr::quic_edge::v1::NodeHeartbeat lastHeartbeat() const
|
||||
{
|
||||
std::lock_guard lock(mutex_);
|
||||
return has_last_heartbeat_
|
||||
? last_heartbeat_
|
||||
: cmvr::quic_edge::v1::NodeHeartbeat{};
|
||||
}
|
||||
|
||||
private:
|
||||
void releaseDelayedHeartbeatAckLocked()
|
||||
{
|
||||
if (!delayed_heartbeat_ack_ ||
|
||||
std::chrono::steady_clock::now() <
|
||||
delayed_heartbeat_ack_ready_at_) {
|
||||
return;
|
||||
}
|
||||
enqueueEnvelopeLocked(*delayed_heartbeat_ack_);
|
||||
delayed_heartbeat_ack_.reset();
|
||||
}
|
||||
|
||||
void enqueueEnvelopeLocked(
|
||||
const cmvr::quic_edge::v1::EdgeControlEnvelope& envelope)
|
||||
{
|
||||
@ -250,6 +331,8 @@ private:
|
||||
bool acknowledge_heartbeats_{true};
|
||||
bool valid_heartbeat_session_{true};
|
||||
std::size_t media_session_would_block_count_{0U};
|
||||
std::uint32_t registration_heartbeat_interval_ms_{250U};
|
||||
std::uint32_t heartbeat_ack_delay_ms_{0U};
|
||||
std::uint64_t server_message_sequence_{0};
|
||||
std::string last_registered_node_id_;
|
||||
std::uint32_t last_grpc_endpoint_port_{0};
|
||||
@ -259,6 +342,13 @@ private:
|
||||
std::vector<std::vector<quic_edge::DatagramPacket>> datagrams_;
|
||||
std::vector<std::uint64_t> edge_message_sequences_;
|
||||
std::vector<std::chrono::steady_clock::time_point> connect_times_;
|
||||
std::vector<std::chrono::steady_clock::time_point> heartbeat_times_;
|
||||
bool has_last_heartbeat_{false};
|
||||
cmvr::quic_edge::v1::NodeHeartbeat last_heartbeat_;
|
||||
std::optional<cmvr::quic_edge::v1::EdgeControlEnvelope>
|
||||
delayed_heartbeat_ack_;
|
||||
std::chrono::steady_clock::time_point
|
||||
delayed_heartbeat_ack_ready_at_{};
|
||||
};
|
||||
|
||||
config::QuicEdgeConfig validConfig(const std::string& source_track_id)
|
||||
@ -521,6 +611,258 @@ bool testPresenceOnlyWithoutMedia()
|
||||
return true;
|
||||
}
|
||||
|
||||
bool testDeviceManagerSnapshotInHeartbeat()
|
||||
{
|
||||
device::DeviceManagerSnapshot snapshot;
|
||||
snapshot.name = "edge-device-manager";
|
||||
snapshot.version = "2.3.4";
|
||||
snapshot.description = "heartbeat snapshot test";
|
||||
|
||||
device::ManagedDeviceSnapshot disabled;
|
||||
disabled.id = "camera-disabled";
|
||||
disabled.kind = device::DeviceKind::Camera;
|
||||
disabled.type_name = "DEVICE_TYPE_CAMERA";
|
||||
disabled.enabled = false;
|
||||
disabled.state = device::ManagedDeviceState::Disabled;
|
||||
disabled.health.state = device::DeviceHealthState::Unknown;
|
||||
disabled.status_updated_at_unix_ms = 101U;
|
||||
snapshot.devices.push_back(disabled);
|
||||
|
||||
device::ManagedDeviceSnapshot running;
|
||||
running.id = "src1100";
|
||||
running.kind = device::DeviceKind::AGV;
|
||||
running.type_name = "Src1100Agv";
|
||||
running.enabled = true;
|
||||
running.state = device::ManagedDeviceState::Running;
|
||||
running.health.state = device::DeviceHealthState::Healthy;
|
||||
running.status_updated_at_unix_ms = 202U;
|
||||
snapshot.devices.push_back(running);
|
||||
|
||||
device::ManagedDeviceSnapshot failed;
|
||||
failed.id = "microphone-failed";
|
||||
failed.kind = device::DeviceKind::Microphone;
|
||||
failed.type_name = "FfmpegMicrophone";
|
||||
failed.enabled = true;
|
||||
failed.state = device::ManagedDeviceState::Error;
|
||||
failed.health.state = device::DeviceHealthState::Fault;
|
||||
failed.abnormal = true;
|
||||
failed.error_message = "device start returned false";
|
||||
failed.status_updated_at_unix_ms = 303U;
|
||||
snapshot.devices.push_back(failed);
|
||||
|
||||
media::MediaSourceHub hub;
|
||||
auto transport = std::make_unique<FakeTransport>();
|
||||
FakeTransport* transport_view = transport.get();
|
||||
quic_edge::QuicEdgeService service(
|
||||
validPresenceOnlyConfig(), std::move(transport), hub,
|
||||
[snapshot]() { return snapshot; });
|
||||
std::string error;
|
||||
CHECK_TRUE(service.initialize(&error));
|
||||
CHECK_TRUE(service.start(&error));
|
||||
CHECK_TRUE(waitUntil([&]() {
|
||||
return service.stats().heartbeats_acknowledged >= 1U;
|
||||
}));
|
||||
const auto heartbeat = transport_view->lastHeartbeat();
|
||||
service.stop();
|
||||
|
||||
CHECK_TRUE(heartbeat.has_device_manager());
|
||||
CHECK_TRUE(heartbeat.device_manager().manager_name() ==
|
||||
"edge-device-manager");
|
||||
CHECK_TRUE(heartbeat.device_manager().manager_version() == "2.3.4");
|
||||
CHECK_TRUE(heartbeat.device_manager().manager_description() ==
|
||||
"heartbeat snapshot test");
|
||||
CHECK_TRUE(heartbeat.device_manager().sampled_at_unix_ms() ==
|
||||
heartbeat.sent_at_unix_ms());
|
||||
CHECK_TRUE(heartbeat.device_manager().devices_size() == 2);
|
||||
|
||||
const auto& wire_failed = heartbeat.device_manager().devices(0);
|
||||
CHECK_TRUE(wire_failed.device_id() == "microphone-failed");
|
||||
CHECK_TRUE(wire_failed.enabled());
|
||||
CHECK_TRUE(wire_failed.kind() ==
|
||||
cmvr::quic_edge::v1::DEVICE_KIND_MICROPHONE);
|
||||
CHECK_TRUE(wire_failed.manager_state() ==
|
||||
cmvr::quic_edge::v1::MANAGED_DEVICE_STATE_ERROR);
|
||||
CHECK_TRUE(wire_failed.health() ==
|
||||
cmvr::quic_edge::v1::DEVICE_HEALTH_STATUS_FAULT);
|
||||
CHECK_TRUE(wire_failed.has_error());
|
||||
CHECK_TRUE(wire_failed.error_message() ==
|
||||
"device start returned false");
|
||||
|
||||
const auto& wire_running = heartbeat.device_manager().devices(1);
|
||||
CHECK_TRUE(wire_running.device_id() == "src1100");
|
||||
CHECK_TRUE(wire_running.enabled());
|
||||
CHECK_TRUE(wire_running.kind() ==
|
||||
cmvr::quic_edge::v1::DEVICE_KIND_AGV);
|
||||
CHECK_TRUE(wire_running.manager_state() ==
|
||||
cmvr::quic_edge::v1::MANAGED_DEVICE_STATE_RUNNING);
|
||||
CHECK_TRUE(wire_running.health() ==
|
||||
cmvr::quic_edge::v1::DEVICE_HEALTH_STATUS_HEALTHY);
|
||||
CHECK_TRUE(!wire_running.has_error());
|
||||
for (const auto& wire_device : heartbeat.device_manager().devices()) {
|
||||
CHECK_TRUE(wire_device.enabled());
|
||||
CHECK_TRUE(wire_device.device_id() != "camera-disabled");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool testAllDeviceKindAndStateMappings()
|
||||
{
|
||||
using ProtoKind = cmvr::quic_edge::v1::DeviceKind;
|
||||
using ProtoState = cmvr::quic_edge::v1::ManagedDeviceState;
|
||||
using ProtoHealth = cmvr::quic_edge::v1::DeviceHealthStatus;
|
||||
const std::vector<std::pair<device::DeviceKind, ProtoKind>> kinds{
|
||||
{device::DeviceKind::Unknown,
|
||||
cmvr::quic_edge::v1::DEVICE_KIND_UNSPECIFIED},
|
||||
{device::DeviceKind::AGV, cmvr::quic_edge::v1::DEVICE_KIND_AGV},
|
||||
{device::DeviceKind::Arm, cmvr::quic_edge::v1::DEVICE_KIND_ARM},
|
||||
{device::DeviceKind::Battery,
|
||||
cmvr::quic_edge::v1::DEVICE_KIND_BATTERY},
|
||||
{device::DeviceKind::BioHead,
|
||||
cmvr::quic_edge::v1::DEVICE_KIND_BIO_HEAD},
|
||||
{device::DeviceKind::Camera,
|
||||
cmvr::quic_edge::v1::DEVICE_KIND_CAMERA},
|
||||
{device::DeviceKind::CanBus,
|
||||
cmvr::quic_edge::v1::DEVICE_KIND_CAN_BUS},
|
||||
{device::DeviceKind::DexHand,
|
||||
cmvr::quic_edge::v1::DEVICE_KIND_DEX_HAND},
|
||||
{device::DeviceKind::Gripper,
|
||||
cmvr::quic_edge::v1::DEVICE_KIND_GRIPPER},
|
||||
{device::DeviceKind::Microphone,
|
||||
cmvr::quic_edge::v1::DEVICE_KIND_MICROPHONE},
|
||||
{device::DeviceKind::Motor,
|
||||
cmvr::quic_edge::v1::DEVICE_KIND_MOTOR},
|
||||
{device::DeviceKind::MotorSystem,
|
||||
cmvr::quic_edge::v1::DEVICE_KIND_MOTOR_SYSTEM},
|
||||
{device::DeviceKind::Robot,
|
||||
cmvr::quic_edge::v1::DEVICE_KIND_ROBOT},
|
||||
{device::DeviceKind::Speaker,
|
||||
cmvr::quic_edge::v1::DEVICE_KIND_SPEAKER},
|
||||
};
|
||||
const std::vector<std::pair<device::ManagedDeviceState, ProtoState>> states{
|
||||
{device::ManagedDeviceState::Unknown,
|
||||
cmvr::quic_edge::v1::MANAGED_DEVICE_STATE_UNSPECIFIED},
|
||||
{device::ManagedDeviceState::Disabled,
|
||||
cmvr::quic_edge::v1::MANAGED_DEVICE_STATE_DISABLED},
|
||||
{device::ManagedDeviceState::Initializing,
|
||||
cmvr::quic_edge::v1::MANAGED_DEVICE_STATE_INITIALIZING},
|
||||
{device::ManagedDeviceState::Registered,
|
||||
cmvr::quic_edge::v1::MANAGED_DEVICE_STATE_REGISTERED},
|
||||
{device::ManagedDeviceState::Ready,
|
||||
cmvr::quic_edge::v1::MANAGED_DEVICE_STATE_READY},
|
||||
{device::ManagedDeviceState::Running,
|
||||
cmvr::quic_edge::v1::MANAGED_DEVICE_STATE_RUNNING},
|
||||
{device::ManagedDeviceState::Stopped,
|
||||
cmvr::quic_edge::v1::MANAGED_DEVICE_STATE_STOPPED},
|
||||
{device::ManagedDeviceState::Error,
|
||||
cmvr::quic_edge::v1::MANAGED_DEVICE_STATE_ERROR},
|
||||
};
|
||||
const std::vector<std::pair<device::DeviceHealthState, ProtoHealth>> health{
|
||||
{device::DeviceHealthState::Unknown,
|
||||
cmvr::quic_edge::v1::DEVICE_HEALTH_STATUS_UNSPECIFIED},
|
||||
{device::DeviceHealthState::Healthy,
|
||||
cmvr::quic_edge::v1::DEVICE_HEALTH_STATUS_HEALTHY},
|
||||
{device::DeviceHealthState::Degraded,
|
||||
cmvr::quic_edge::v1::DEVICE_HEALTH_STATUS_DEGRADED},
|
||||
{device::DeviceHealthState::Fault,
|
||||
cmvr::quic_edge::v1::DEVICE_HEALTH_STATUS_FAULT},
|
||||
};
|
||||
|
||||
device::DeviceManagerSnapshot snapshot;
|
||||
snapshot.name = "mapping-test";
|
||||
for (std::size_t index = 0U; index < kinds.size(); ++index) {
|
||||
device::ManagedDeviceSnapshot row;
|
||||
row.id = std::string("kind-") + (index < 10U ? "0" : "") +
|
||||
std::to_string(index);
|
||||
row.kind = kinds[index].first;
|
||||
row.type_name = "mapping";
|
||||
row.enabled = true;
|
||||
row.state = states[index % states.size()].first;
|
||||
row.health.state = health[index % health.size()].first;
|
||||
row.abnormal = index % 2U != 0U;
|
||||
snapshot.devices.push_back(std::move(row));
|
||||
}
|
||||
|
||||
media::MediaSourceHub hub;
|
||||
auto transport = std::make_unique<FakeTransport>();
|
||||
FakeTransport* transport_view = transport.get();
|
||||
quic_edge::QuicEdgeService service(
|
||||
validPresenceOnlyConfig(), std::move(transport), hub,
|
||||
[snapshot]() { return snapshot; });
|
||||
std::string error;
|
||||
CHECK_TRUE(service.initialize(&error));
|
||||
CHECK_TRUE(service.start(&error));
|
||||
CHECK_TRUE(waitUntil([&]() {
|
||||
return service.stats().heartbeats_acknowledged >= 1U;
|
||||
}));
|
||||
const auto heartbeat = transport_view->lastHeartbeat();
|
||||
service.stop();
|
||||
|
||||
CHECK_TRUE(heartbeat.device_manager().devices_size() ==
|
||||
static_cast<int>(kinds.size()));
|
||||
for (std::size_t index = 0U; index < kinds.size(); ++index) {
|
||||
const auto& row =
|
||||
heartbeat.device_manager().devices(static_cast<int>(index));
|
||||
CHECK_TRUE(row.kind() == kinds[index].second);
|
||||
CHECK_TRUE(row.manager_state() ==
|
||||
states[index % states.size()].second);
|
||||
CHECK_TRUE(row.health() == health[index % health.size()].second);
|
||||
CHECK_TRUE(row.has_error() == (index % 2U != 0U));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool testConfiguredHeartbeatIntervalWithoutGatewayOverride()
|
||||
{
|
||||
media::MediaSourceHub hub;
|
||||
auto transport = std::make_unique<FakeTransport>(
|
||||
true, true, true, 0U, 0U);
|
||||
FakeTransport* transport_view = transport.get();
|
||||
auto config = validPresenceOnlyConfig();
|
||||
config.set_heartbeat_interval_ms(400U);
|
||||
quic_edge::QuicEdgeService service(
|
||||
config, std::move(transport), hub);
|
||||
std::string error;
|
||||
CHECK_TRUE(service.initialize(&error));
|
||||
CHECK_TRUE(service.start(&error));
|
||||
CHECK_TRUE(waitUntil([&]() {
|
||||
return transport_view->heartbeatCount() >= 3U;
|
||||
}));
|
||||
const auto intervals = transport_view->heartbeatIntervals();
|
||||
service.stop();
|
||||
|
||||
CHECK_TRUE(intervals.size() >= 2U);
|
||||
CHECK_TRUE(intervals[0].count() >= 350);
|
||||
CHECK_TRUE(intervals[1].count() >= 350);
|
||||
CHECK_TRUE(intervals[0].count() <= 900);
|
||||
CHECK_TRUE(intervals[1].count() <= 900);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool testSnapshotLatencyDoesNotConsumeAckDeadline()
|
||||
{
|
||||
media::MediaSourceHub hub;
|
||||
auto transport = std::make_unique<FakeTransport>(
|
||||
true, true, true, 0U, 250U, 70U);
|
||||
FakeTransport* transport_view = transport.get();
|
||||
auto config = validPresenceOnlyConfig();
|
||||
config.set_control_response_timeout_ms(100U);
|
||||
quic_edge::QuicEdgeService service(
|
||||
config, std::move(transport), hub, [] {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(120));
|
||||
return device::DeviceManagerSnapshot{};
|
||||
});
|
||||
std::string error;
|
||||
CHECK_TRUE(service.initialize(&error));
|
||||
CHECK_TRUE(service.start(&error));
|
||||
CHECK_TRUE(waitUntil([&]() {
|
||||
return service.stats().heartbeats_acknowledged >= 2U;
|
||||
}));
|
||||
CHECK_TRUE(transport_view->connectCount() == 1U);
|
||||
CHECK_TRUE(service.stats().heartbeat_timeouts == 0U);
|
||||
service.stop();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool testHeartbeatTimeoutReconnectsWithoutTaskFailure()
|
||||
{
|
||||
media::MediaSourceHub hub;
|
||||
@ -682,6 +1024,10 @@ int main()
|
||||
if (!testControlFraming() || !testPacketizer() ||
|
||||
!testServiceWithSharedHub() || !testMissingInjectedSourceRetriesSafely() ||
|
||||
!testPresenceOnlyWithoutMedia() ||
|
||||
!testDeviceManagerSnapshotInHeartbeat() ||
|
||||
!testAllDeviceKindAndStateMappings() ||
|
||||
!testConfiguredHeartbeatIntervalWithoutGatewayOverride() ||
|
||||
!testSnapshotLatencyDoesNotConsumeAckDeadline() ||
|
||||
!testHeartbeatTimeoutReconnectsWithoutTaskFailure() ||
|
||||
!testRegistrationRejectionBacksOff() ||
|
||||
!testHeartbeatAckRequiresSessionId() ||
|
||||
|
||||
8
dependency/x86/third_party/msquic/v2.5.9/BUILD-INFO.txt
vendored
Normal file
8
dependency/x86/third_party/msquic/v2.5.9/BUILD-INFO.txt
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
msquic_version=2.5.9
|
||||
source_tag=v2.5.9
|
||||
source_commit=87b53085d76bd7920d490a6f226c9999b6614d14
|
||||
dependency_arch=x86
|
||||
tls_backend=quictls-static
|
||||
dynamic_tls_providers=false
|
||||
system_libcrypto=false
|
||||
numa=false
|
||||
2123
dependency/x86/third_party/msquic/v2.5.9/include/msquic.h
vendored
Normal file
2123
dependency/x86/third_party/msquic/v2.5.9/include/msquic.h
vendored
Normal file
File diff suppressed because it is too large
Load Diff
577
dependency/x86/third_party/msquic/v2.5.9/include/msquic_posix.h
vendored
Normal file
577
dependency/x86/third_party/msquic/v2.5.9/include/msquic_posix.h
vendored
Normal file
@ -0,0 +1,577 @@
|
||||
/*++
|
||||
|
||||
Copyright (c) Microsoft Corporation.
|
||||
Licensed under the MIT License.
|
||||
|
||||
Abstract:
|
||||
|
||||
This file contains the platform specific definitions for MsQuic structures
|
||||
and error codes.
|
||||
|
||||
Environment:
|
||||
|
||||
POSIX (Linux and macOS)
|
||||
|
||||
--*/
|
||||
|
||||
#ifdef _WIN32
|
||||
#pragma once
|
||||
#endif
|
||||
|
||||
#ifndef _MSQUIC_POSIX_
|
||||
#define _MSQUIC_POSIX_
|
||||
|
||||
#include <stdlib.h>
|
||||
#include <stdio.h>
|
||||
#include <stdint.h>
|
||||
#include <sys/types.h>
|
||||
#include <string.h>
|
||||
#include <assert.h>
|
||||
#include <inttypes.h>
|
||||
#include <stddef.h>
|
||||
#include <netdb.h>
|
||||
#include <netinet/ip.h>
|
||||
#include <unistd.h>
|
||||
#include <sys/socket.h>
|
||||
#include <arpa/inet.h>
|
||||
#include <netdb.h>
|
||||
#include <errno.h>
|
||||
#include "quic_sal_stub.h"
|
||||
|
||||
#define QUIC_INLINE static inline
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C++" {
|
||||
template <size_t S> struct _ENUM_FLAG_INTEGER_FOR_SIZE;
|
||||
template <> struct _ENUM_FLAG_INTEGER_FOR_SIZE<1> {
|
||||
typedef uint8_t type;
|
||||
};
|
||||
template <> struct _ENUM_FLAG_INTEGER_FOR_SIZE<2> {
|
||||
typedef uint16_t type;
|
||||
};
|
||||
template <> struct _ENUM_FLAG_INTEGER_FOR_SIZE<4> {
|
||||
typedef uint32_t type;
|
||||
};
|
||||
template <> struct _ENUM_FLAG_INTEGER_FOR_SIZE<8> {
|
||||
typedef uint64_t type;
|
||||
};
|
||||
|
||||
// used as an approximation of std::underlying_type<T>
|
||||
template <class T> struct _ENUM_FLAG_SIZED_INTEGER
|
||||
{
|
||||
typedef typename _ENUM_FLAG_INTEGER_FOR_SIZE<sizeof(T)>::type type;
|
||||
};
|
||||
}
|
||||
|
||||
#define DEFINE_ENUM_FLAG_OPERATORS(ENUMTYPE) \
|
||||
extern "C++" { \
|
||||
QUIC_INLINE ENUMTYPE operator | (ENUMTYPE a, ENUMTYPE b) throw() { return ENUMTYPE(((_ENUM_FLAG_SIZED_INTEGER<ENUMTYPE>::type)a) | ((_ENUM_FLAG_SIZED_INTEGER<ENUMTYPE>::type)b)); } \
|
||||
QUIC_INLINE ENUMTYPE &operator |= (ENUMTYPE &a, ENUMTYPE b) throw() { return (ENUMTYPE &)(((_ENUM_FLAG_SIZED_INTEGER<ENUMTYPE>::type &)a) |= ((_ENUM_FLAG_SIZED_INTEGER<ENUMTYPE>::type)b)); } \
|
||||
QUIC_INLINE ENUMTYPE operator & (ENUMTYPE a, ENUMTYPE b) throw() { return ENUMTYPE(((_ENUM_FLAG_SIZED_INTEGER<ENUMTYPE>::type)a) & ((_ENUM_FLAG_SIZED_INTEGER<ENUMTYPE>::type)b)); } \
|
||||
QUIC_INLINE ENUMTYPE &operator &= (ENUMTYPE &a, ENUMTYPE b) throw() { return (ENUMTYPE &)(((_ENUM_FLAG_SIZED_INTEGER<ENUMTYPE>::type &)a) &= ((_ENUM_FLAG_SIZED_INTEGER<ENUMTYPE>::type)b)); } \
|
||||
QUIC_INLINE ENUMTYPE operator ~ (ENUMTYPE a) throw() { return ENUMTYPE(~((_ENUM_FLAG_SIZED_INTEGER<ENUMTYPE>::type)a)); } \
|
||||
QUIC_INLINE ENUMTYPE operator ^ (ENUMTYPE a, ENUMTYPE b) throw() { return ENUMTYPE(((_ENUM_FLAG_SIZED_INTEGER<ENUMTYPE>::type)a) ^ ((_ENUM_FLAG_SIZED_INTEGER<ENUMTYPE>::type)b)); } \
|
||||
QUIC_INLINE ENUMTYPE &operator ^= (ENUMTYPE &a, ENUMTYPE b) throw() { return (ENUMTYPE &)(((_ENUM_FLAG_SIZED_INTEGER<ENUMTYPE>::type &)a) ^= ((_ENUM_FLAG_SIZED_INTEGER<ENUMTYPE>::type)b)); } \
|
||||
}
|
||||
#else
|
||||
#define DEFINE_ENUM_FLAG_OPERATORS(ENUMTYPE) // NOP, C allows these operators.
|
||||
#endif
|
||||
|
||||
#define QUIC_API
|
||||
#define QUIC_MAIN_EXPORT
|
||||
#define QUIC_STATUS unsigned int
|
||||
#define QUIC_FAILED(X) ((int)(X) > 0)
|
||||
#define QUIC_SUCCEEDED(X) ((int)(X) <= 0)
|
||||
|
||||
//
|
||||
// The type of an error code generated by the system is mostly 'int'. In most
|
||||
// situations, we use the value of a system-generated error code as the value
|
||||
// of QUIC_STATUS. In some situations, we use a custom value for QUIC_STATUS.
|
||||
// In order to ensure that custom values don't conflict with system-generated
|
||||
// values, the custom values are all kept outside the range of any possible
|
||||
// 'int' value. There are static asserts to ensure that QUIC_STATUS type is
|
||||
// large enough for this purpose.
|
||||
//
|
||||
|
||||
#ifndef ESTRPIPE // undefined on macOS
|
||||
#define ESTRPIPE 86
|
||||
#endif // ESTRPIPE
|
||||
|
||||
#ifndef ENOKEY // undefined om macOS
|
||||
#define ENOKEY 126
|
||||
#endif // ENOKEY
|
||||
|
||||
#define ERROR_BASE 200000000 // 0xBEBC200
|
||||
#define TLS_ERROR_BASE 256 + ERROR_BASE // 0xBEBC300
|
||||
#define CERT_ERROR_BASE 512 + ERROR_BASE // 0xBEBC400
|
||||
|
||||
#define QUIC_STATUS_SUCCESS ((QUIC_STATUS)0) // 0
|
||||
#define QUIC_STATUS_PENDING ((QUIC_STATUS)-2) // -2
|
||||
#define QUIC_STATUS_CONTINUE ((QUIC_STATUS)-1) // -1
|
||||
#define QUIC_STATUS_OUT_OF_MEMORY ((QUIC_STATUS)ENOMEM) // 12
|
||||
#define QUIC_STATUS_INVALID_PARAMETER ((QUIC_STATUS)EINVAL) // 22
|
||||
#define QUIC_STATUS_INVALID_STATE ((QUIC_STATUS)EPERM) // 1
|
||||
#define QUIC_STATUS_NOT_SUPPORTED ((QUIC_STATUS)EOPNOTSUPP) // 95 (102 on macOS)
|
||||
#define QUIC_STATUS_NOT_FOUND ((QUIC_STATUS)ENOENT) // 2
|
||||
#define QUIC_STATUS_FILE_NOT_FOUND QUIC_STATUS_NOT_FOUND // 2
|
||||
#define QUIC_STATUS_BUFFER_TOO_SMALL ((QUIC_STATUS)EOVERFLOW) // 75 (84 on macOS)
|
||||
#define QUIC_STATUS_HANDSHAKE_FAILURE ((QUIC_STATUS)ECONNABORTED) // 103 (53 on macOS)
|
||||
#define QUIC_STATUS_ABORTED ((QUIC_STATUS)ECANCELED) // 125 (89 on macOS)
|
||||
#define QUIC_STATUS_ADDRESS_IN_USE ((QUIC_STATUS)EADDRINUSE) // 98 (48 on macOS)
|
||||
#define QUIC_STATUS_INVALID_ADDRESS ((QUIC_STATUS)EAFNOSUPPORT) // 97 (47 on macOS)
|
||||
#define QUIC_STATUS_CONNECTION_TIMEOUT ((QUIC_STATUS)ETIMEDOUT) // 110 (60 on macOS)
|
||||
#define QUIC_STATUS_CONNECTION_IDLE ((QUIC_STATUS)ETIME) // 62 (101 on macOS)
|
||||
#define QUIC_STATUS_INTERNAL_ERROR ((QUIC_STATUS)EIO) // 5
|
||||
#define QUIC_STATUS_CONNECTION_REFUSED ((QUIC_STATUS)ECONNREFUSED) // 111 (61 on macOS)
|
||||
#define QUIC_STATUS_PROTOCOL_ERROR ((QUIC_STATUS)EPROTO) // 71 (100 on macOS)
|
||||
#define QUIC_STATUS_VER_NEG_ERROR ((QUIC_STATUS)EPROTONOSUPPORT) // 93 (43 on macOS)
|
||||
#define QUIC_STATUS_UNREACHABLE ((QUIC_STATUS)EHOSTUNREACH) // 113 (65 on macOS)
|
||||
#define QUIC_STATUS_TLS_ERROR ((QUIC_STATUS)ENOKEY) // 126
|
||||
#define QUIC_STATUS_USER_CANCELED ((QUIC_STATUS)EOWNERDEAD) // 130 (105 on macOS)
|
||||
#define QUIC_STATUS_ALPN_NEG_FAILURE ((QUIC_STATUS)ENOPROTOOPT) // 92 (42 on macOS)
|
||||
#define QUIC_STATUS_STREAM_LIMIT_REACHED ((QUIC_STATUS)ESTRPIPE) // 86
|
||||
#define QUIC_STATUS_ALPN_IN_USE ((QUIC_STATUS)EPROTOTYPE) // 91 (41 on macOS)
|
||||
#define QUIC_STATUS_ADDRESS_NOT_AVAILABLE ((QUIC_STATUS)EADDRNOTAVAIL) // 99 (47 on macOS)
|
||||
|
||||
#define QUIC_STATUS_TLS_ALERT(Alert) ((QUIC_STATUS)(0xff & Alert) + TLS_ERROR_BASE)
|
||||
|
||||
#define QUIC_STATUS_CLOSE_NOTIFY QUIC_STATUS_TLS_ALERT(0) // 0xBEBC300 - Close notify
|
||||
#define QUIC_STATUS_BAD_CERTIFICATE QUIC_STATUS_TLS_ALERT(42) // 0xBEBC32A - Bad Certificate
|
||||
#define QUIC_STATUS_UNSUPPORTED_CERTIFICATE QUIC_STATUS_TLS_ALERT(43) // 0xBEBC32B - Unsupported Certficiate
|
||||
#define QUIC_STATUS_REVOKED_CERTIFICATE QUIC_STATUS_TLS_ALERT(44) // 0xBEBC32C - Revoked Certificate
|
||||
#define QUIC_STATUS_EXPIRED_CERTIFICATE QUIC_STATUS_TLS_ALERT(45) // 0xBEBC32D - Expired Certificate
|
||||
#define QUIC_STATUS_UNKNOWN_CERTIFICATE QUIC_STATUS_TLS_ALERT(46) // 0xBEBC32E - Unknown Certificate
|
||||
#define QUIC_STATUS_REQUIRED_CERTIFICATE QUIC_STATUS_TLS_ALERT(116) // 0xBEBC374 - Required Certificate
|
||||
|
||||
#define QUIC_STATUS_CERT_ERROR(Val) ((QUIC_STATUS)Val + CERT_ERROR_BASE)
|
||||
|
||||
#define QUIC_STATUS_CERT_EXPIRED QUIC_STATUS_CERT_ERROR(1) // 0xBEBC401
|
||||
#define QUIC_STATUS_CERT_UNTRUSTED_ROOT QUIC_STATUS_CERT_ERROR(2) // 0xBEBC402
|
||||
#define QUIC_STATUS_CERT_NO_CERT QUIC_STATUS_CERT_ERROR(3) // 0xBEBC403
|
||||
|
||||
typedef unsigned char BOOLEAN;
|
||||
typedef struct in_addr IN_ADDR;
|
||||
typedef struct in6_addr IN6_ADDR;
|
||||
typedef struct addrinfo ADDRINFO;
|
||||
typedef sa_family_t QUIC_ADDRESS_FAMILY;
|
||||
|
||||
#define QUIC_ADDRESS_FAMILY_UNSPEC AF_UNSPEC
|
||||
#define QUIC_ADDRESS_FAMILY_INET AF_INET
|
||||
#define QUIC_ADDRESS_FAMILY_INET6 AF_INET6
|
||||
|
||||
typedef union QUIC_ADDR {
|
||||
struct sockaddr Ip;
|
||||
struct sockaddr_in Ipv4;
|
||||
struct sockaddr_in6 Ipv6;
|
||||
} QUIC_ADDR;
|
||||
|
||||
#ifndef RTL_FIELD_SIZE
|
||||
#define RTL_FIELD_SIZE(type, field) (sizeof(((type *)0)->field))
|
||||
#endif
|
||||
|
||||
#define FIELD_OFFSET(type, field) offsetof(type, field)
|
||||
|
||||
#define QUIC_ADDR_V4_PORT_OFFSET FIELD_OFFSET(struct sockaddr_in, sin_port)
|
||||
#define QUIC_ADDR_V4_IP_OFFSET FIELD_OFFSET(struct sockaddr_in, sin_addr)
|
||||
|
||||
#define QUIC_ADDR_V6_PORT_OFFSET FIELD_OFFSET(struct sockaddr_in6, sin6_port)
|
||||
#define QUIC_ADDR_V6_IP_OFFSET FIELD_OFFSET(struct sockaddr_in6, sin6_addr)
|
||||
|
||||
#ifndef FALSE
|
||||
#define FALSE 0
|
||||
#define TRUE 1
|
||||
#endif
|
||||
|
||||
#define INITCODE
|
||||
#define PAGEDX
|
||||
#define QUIC_CACHEALIGN
|
||||
|
||||
#if defined(__cplusplus)
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
#if defined(CX_PLATFORM_DARWIN)
|
||||
#define QUIC_LOCALHOST_FOR_AF(Af) ("localhost")
|
||||
#else
|
||||
#define QUIC_LOCALHOST_FOR_AF(Af) ((Af == QUIC_ADDRESS_FAMILY_INET) ? "localhost" : "ip6-localhost")
|
||||
#endif
|
||||
|
||||
#define QUIC_CERTIFICATE_FLAG_IGNORE_REVOCATION 0x00000080
|
||||
#define QUIC_CERTIFICATE_FLAG_IGNORE_UNKNOWN_CA 0x00000100
|
||||
#define QUIC_CERTIFICATE_FLAG_IGNORE_WRONG_USAGE 0x00000200
|
||||
#define QUIC_CERTIFICATE_FLAG_IGNORE_CERTIFICATE_CN_INVALID 0x00001000 // bad common name in X509 Cert.
|
||||
#define QUIC_CERTIFICATE_FLAG_IGNORE_CERTIFICATE_DATE_INVALID 0x00002000 // expired X509 Cert.
|
||||
#define QUIC_CERTIFICATE_FLAG_IGNORE_WEAK_SIGNATURE 0x00010000
|
||||
|
||||
#if defined(__clang__)
|
||||
#define QUIC_NO_SANITIZE(X) __attribute__((no_sanitize(X)))
|
||||
#else
|
||||
#define QUIC_NO_SANITIZE(X)
|
||||
#endif
|
||||
|
||||
//
|
||||
// Helpers for Windows string functions.
|
||||
//
|
||||
|
||||
#define _strnicmp strncasecmp
|
||||
#define sprintf_s(dst, dst_len, format, ...) snprintf(dst, dst_len, format, __VA_ARGS__)
|
||||
#define _vsnprintf_s(dst, dst_len, flag, format, ...) vsnprintf(dst, dst_len, format, __VA_ARGS__)
|
||||
|
||||
//
|
||||
// IP Address Abstraction Helpers
|
||||
//
|
||||
|
||||
QUIC_INLINE
|
||||
BOOLEAN
|
||||
QuicAddrFamilyIsValid(
|
||||
_In_ QUIC_ADDRESS_FAMILY Family
|
||||
)
|
||||
{
|
||||
return
|
||||
Family == QUIC_ADDRESS_FAMILY_UNSPEC ||
|
||||
Family == QUIC_ADDRESS_FAMILY_INET ||
|
||||
Family == QUIC_ADDRESS_FAMILY_INET6;
|
||||
}
|
||||
|
||||
QUIC_INLINE
|
||||
BOOLEAN
|
||||
QuicAddrIsValid(
|
||||
_In_ const QUIC_ADDR* const Addr
|
||||
)
|
||||
{
|
||||
return QuicAddrFamilyIsValid(Addr->Ip.sa_family);
|
||||
}
|
||||
|
||||
QUIC_INLINE
|
||||
BOOLEAN
|
||||
QuicAddrCompareIp(
|
||||
_In_ const QUIC_ADDR* const Addr1,
|
||||
_In_ const QUIC_ADDR* const Addr2
|
||||
)
|
||||
{
|
||||
if (QUIC_ADDRESS_FAMILY_INET == Addr1->Ip.sa_family) {
|
||||
return memcmp(&Addr1->Ipv4.sin_addr, &Addr2->Ipv4.sin_addr, sizeof(IN_ADDR)) == 0;
|
||||
} else {
|
||||
return memcmp(&Addr1->Ipv6.sin6_addr, &Addr2->Ipv6.sin6_addr, sizeof(IN6_ADDR)) == 0;
|
||||
}
|
||||
}
|
||||
|
||||
QUIC_INLINE
|
||||
BOOLEAN
|
||||
QuicAddrCompare(
|
||||
_In_ const QUIC_ADDR* const Addr1,
|
||||
_In_ const QUIC_ADDR* const Addr2
|
||||
)
|
||||
{
|
||||
if (Addr1->Ip.sa_family != Addr2->Ip.sa_family ||
|
||||
Addr1->Ipv4.sin_port != Addr2->Ipv4.sin_port) {
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
if (QUIC_ADDRESS_FAMILY_INET == Addr1->Ip.sa_family) {
|
||||
return memcmp(&Addr1->Ipv4.sin_addr, &Addr2->Ipv4.sin_addr, sizeof(IN_ADDR)) == 0;
|
||||
} else {
|
||||
return memcmp(&Addr1->Ipv6.sin6_addr, &Addr2->Ipv6.sin6_addr, sizeof(IN6_ADDR)) == 0;
|
||||
}
|
||||
}
|
||||
|
||||
QUIC_INLINE
|
||||
QUIC_ADDRESS_FAMILY
|
||||
QuicAddrGetFamily(
|
||||
_In_ const QUIC_ADDR* const Addr
|
||||
)
|
||||
{
|
||||
return Addr->Ip.sa_family;
|
||||
}
|
||||
|
||||
QUIC_INLINE
|
||||
void
|
||||
QuicAddrSetFamily(
|
||||
_In_ QUIC_ADDR* Addr,
|
||||
_In_ QUIC_ADDRESS_FAMILY Family
|
||||
)
|
||||
{
|
||||
Addr->Ip.sa_family = Family;
|
||||
}
|
||||
|
||||
QUIC_INLINE
|
||||
uint16_t
|
||||
QuicAddrGetPort(
|
||||
_In_ const QUIC_ADDR* const Addr
|
||||
)
|
||||
{
|
||||
if (QUIC_ADDRESS_FAMILY_INET == Addr->Ip.sa_family) {
|
||||
return ntohs(Addr->Ipv4.sin_port);
|
||||
} else {
|
||||
return ntohs(Addr->Ipv6.sin6_port);
|
||||
}
|
||||
}
|
||||
|
||||
QUIC_INLINE
|
||||
void
|
||||
QuicAddrSetPort(
|
||||
_Out_ QUIC_ADDR* Addr,
|
||||
_In_ uint16_t Port
|
||||
)
|
||||
{
|
||||
if (QUIC_ADDRESS_FAMILY_INET == Addr->Ip.sa_family) {
|
||||
Addr->Ipv4.sin_port = htons(Port);
|
||||
} else {
|
||||
Addr->Ipv6.sin6_port = htons(Port);
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Test only API to increment the IP address value.
|
||||
//
|
||||
QUIC_INLINE
|
||||
void
|
||||
QuicAddrIncrement(
|
||||
_Inout_ QUIC_ADDR* Addr
|
||||
)
|
||||
{
|
||||
if (Addr->Ip.sa_family == QUIC_ADDRESS_FAMILY_INET) {
|
||||
((uint8_t*)&Addr->Ipv4.sin_addr)[3]++;
|
||||
} else {
|
||||
((uint8_t*)&Addr->Ipv6.sin6_addr)[15]++;
|
||||
}
|
||||
}
|
||||
|
||||
QUIC_INLINE
|
||||
void
|
||||
QuicAddrSetToLoopback(
|
||||
_Inout_ QUIC_ADDR* Addr
|
||||
)
|
||||
{
|
||||
if (Addr->Ip.sa_family == QUIC_ADDRESS_FAMILY_INET) {
|
||||
Addr->Ipv4.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
|
||||
} else {
|
||||
Addr->Ipv6.sin6_addr = in6addr_loopback;
|
||||
}
|
||||
}
|
||||
|
||||
QUIC_INLINE
|
||||
uint32_t
|
||||
QUIC_NO_SANITIZE("unsigned-integer-overflow")
|
||||
QuicAddrHash(
|
||||
_In_ const QUIC_ADDR* Addr
|
||||
)
|
||||
{
|
||||
uint32_t Hash = 5387; // A random prime number.
|
||||
#define UPDATE_HASH(byte) Hash = ((Hash << 5) - Hash) + (byte)
|
||||
if (Addr->Ip.sa_family == QUIC_ADDRESS_FAMILY_INET) {
|
||||
UPDATE_HASH(Addr->Ipv4.sin_port & 0xFF);
|
||||
UPDATE_HASH(Addr->Ipv4.sin_port >> 8);
|
||||
for (uint8_t i = 0; i < sizeof(Addr->Ipv4.sin_addr); ++i) {
|
||||
UPDATE_HASH(((uint8_t*)&Addr->Ipv4.sin_addr)[i]);
|
||||
}
|
||||
} else {
|
||||
UPDATE_HASH(Addr->Ipv6.sin6_port & 0xFF);
|
||||
UPDATE_HASH(Addr->Ipv6.sin6_port >> 8);
|
||||
for (uint8_t i = 0; i < sizeof(Addr->Ipv6.sin6_addr); ++i) {
|
||||
UPDATE_HASH(((uint8_t*)&Addr->Ipv6.sin6_addr)[i]);
|
||||
}
|
||||
}
|
||||
return Hash;
|
||||
}
|
||||
|
||||
QUIC_INLINE
|
||||
BOOLEAN
|
||||
QuicAddrIsWildCard(
|
||||
_In_ const QUIC_ADDR* const Addr
|
||||
)
|
||||
{
|
||||
if (Addr->Ip.sa_family == QUIC_ADDRESS_FAMILY_UNSPEC) {
|
||||
return TRUE;
|
||||
} else if (Addr->Ip.sa_family == QUIC_ADDRESS_FAMILY_INET) {
|
||||
const IN_ADDR ZeroAddr = {0};
|
||||
return memcmp(&Addr->Ipv4.sin_addr.s_addr, &ZeroAddr, sizeof(IN_ADDR)) == 0;
|
||||
} else {
|
||||
const IN6_ADDR ZeroAddr = {0};
|
||||
return memcmp(&Addr->Ipv6.sin6_addr, &ZeroAddr, sizeof(IN6_ADDR)) == 0;
|
||||
}
|
||||
}
|
||||
|
||||
QUIC_INLINE
|
||||
BOOLEAN
|
||||
QuicAddr4FromString(
|
||||
_In_z_ const char* AddrStr,
|
||||
_Out_ QUIC_ADDR* Addr
|
||||
)
|
||||
{
|
||||
if (AddrStr[0] == '[') {
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
const char* PortStart = strchr(AddrStr, ':');
|
||||
if (PortStart != NULL) {
|
||||
if (strchr(PortStart+1, ':') != NULL) {
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
char TmpAddrStr[16];
|
||||
size_t AddrLength = PortStart - AddrStr;
|
||||
if (AddrLength >= sizeof(TmpAddrStr)) {
|
||||
return FALSE;
|
||||
}
|
||||
memcpy(TmpAddrStr, AddrStr, AddrLength);
|
||||
TmpAddrStr[AddrLength] = '\0';
|
||||
|
||||
if (inet_pton(AF_INET, TmpAddrStr, &Addr->Ipv4.sin_addr) != 1) {
|
||||
return FALSE;
|
||||
}
|
||||
Addr->Ipv4.sin_port = htons(atoi(PortStart+1));
|
||||
} else {
|
||||
if (inet_pton(AF_INET, AddrStr, &Addr->Ipv4.sin_addr) != 1) {
|
||||
return FALSE;
|
||||
}
|
||||
}
|
||||
Addr->Ip.sa_family = QUIC_ADDRESS_FAMILY_INET;
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
QUIC_INLINE
|
||||
BOOLEAN
|
||||
QuicAddr6FromString(
|
||||
_In_z_ const char* AddrStr,
|
||||
_Out_ QUIC_ADDR* Addr
|
||||
)
|
||||
{
|
||||
if (AddrStr[0] == '[') {
|
||||
const char* BracketEnd = strchr(AddrStr, ']');
|
||||
if (BracketEnd == NULL || *(BracketEnd+1) != ':') {
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
char TmpAddrStr[64];
|
||||
size_t AddrLength = BracketEnd - AddrStr - 1;
|
||||
if (AddrLength >= sizeof(TmpAddrStr)) {
|
||||
return FALSE;
|
||||
}
|
||||
memcpy(TmpAddrStr, AddrStr + 1, AddrLength);
|
||||
TmpAddrStr[AddrLength] = '\0';
|
||||
|
||||
if (inet_pton(AF_INET6, TmpAddrStr, &Addr->Ipv6.sin6_addr) != 1) {
|
||||
return FALSE;
|
||||
}
|
||||
Addr->Ipv6.sin6_port = htons(atoi(BracketEnd+2));
|
||||
} else {
|
||||
if (inet_pton(AF_INET6, AddrStr, &Addr->Ipv6.sin6_addr) != 1) {
|
||||
return FALSE;
|
||||
}
|
||||
}
|
||||
Addr->Ip.sa_family = QUIC_ADDRESS_FAMILY_INET6;
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
QUIC_INLINE
|
||||
BOOLEAN
|
||||
QuicAddrFromString(
|
||||
_In_z_ const char* AddrStr,
|
||||
_In_ uint16_t Port, // Host byte order
|
||||
_Out_ QUIC_ADDR* Addr
|
||||
)
|
||||
{
|
||||
Addr->Ipv4.sin_port = htons(Port);
|
||||
return
|
||||
QuicAddr4FromString(AddrStr, Addr) ||
|
||||
QuicAddr6FromString(AddrStr, Addr);
|
||||
}
|
||||
|
||||
//
|
||||
// Represents an IP address and (optionally) port number as a string.
|
||||
//
|
||||
typedef struct QUIC_ADDR_STR {
|
||||
char Address[64];
|
||||
} QUIC_ADDR_STR;
|
||||
|
||||
QUIC_INLINE
|
||||
BOOLEAN
|
||||
QuicAddrToString(
|
||||
_In_ const QUIC_ADDR* Addr,
|
||||
_Out_ QUIC_ADDR_STR* AddrStr
|
||||
)
|
||||
{
|
||||
size_t AvailSpace = sizeof(AddrStr->Address);
|
||||
char* Address = AddrStr->Address;
|
||||
if (Addr->Ip.sa_family == QUIC_ADDRESS_FAMILY_INET6 && Addr->Ipv6.sin6_port != 0) {
|
||||
Address[0] = '[';
|
||||
Address++;
|
||||
AvailSpace--;
|
||||
}
|
||||
if (inet_ntop(
|
||||
Addr->Ip.sa_family == QUIC_ADDRESS_FAMILY_INET ? AF_INET : AF_INET6,
|
||||
Addr->Ip.sa_family == QUIC_ADDRESS_FAMILY_INET ? (void*)&Addr->Ipv4.sin_addr : (void*)&Addr->Ipv6.sin6_addr,
|
||||
Address,
|
||||
AvailSpace) == NULL) {
|
||||
return FALSE;
|
||||
}
|
||||
if (Addr->Ipv4.sin_port != 0) {
|
||||
Address += strlen(Address);
|
||||
if (Addr->Ip.sa_family == QUIC_ADDRESS_FAMILY_INET6) {
|
||||
Address[0] = ']';
|
||||
Address++;
|
||||
}
|
||||
AvailSpace = sizeof(AddrStr->Address) - (Address - AddrStr->Address);
|
||||
snprintf(Address, AvailSpace, ":%hu", ntohs(Addr->Ipv4.sin_port));
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
//
|
||||
// Event Queue Abstraction
|
||||
//
|
||||
|
||||
#if __linux__ // epoll
|
||||
|
||||
#include <sys/epoll.h>
|
||||
#include <sys/eventfd.h>
|
||||
|
||||
typedef int QUIC_EVENTQ;
|
||||
|
||||
typedef struct epoll_event QUIC_CQE;
|
||||
|
||||
typedef
|
||||
void
|
||||
(QUIC_EVENT_COMPLETION)(
|
||||
_In_ QUIC_CQE* Cqe
|
||||
);
|
||||
typedef QUIC_EVENT_COMPLETION *QUIC_EVENT_COMPLETION_HANDLER;
|
||||
|
||||
typedef struct QUIC_SQE {
|
||||
int fd;
|
||||
QUIC_EVENT_COMPLETION_HANDLER Completion;
|
||||
} QUIC_SQE;
|
||||
|
||||
#elif __APPLE__ || __FreeBSD__ // kqueue
|
||||
|
||||
#include <sys/event.h>
|
||||
#include <fcntl.h>
|
||||
|
||||
typedef int QUIC_EVENTQ;
|
||||
|
||||
typedef struct kevent QUIC_CQE;
|
||||
|
||||
typedef
|
||||
void
|
||||
(QUIC_EVENT_COMPLETION)(
|
||||
_In_ QUIC_CQE* Cqe
|
||||
);
|
||||
typedef QUIC_EVENT_COMPLETION *QUIC_EVENT_COMPLETION_HANDLER;
|
||||
|
||||
typedef struct QUIC_SQE {
|
||||
uintptr_t Handle;
|
||||
QUIC_EVENT_COMPLETION_HANDLER Completion;
|
||||
} QUIC_SQE;
|
||||
|
||||
#else
|
||||
|
||||
#error Unsupported Platform
|
||||
|
||||
#endif
|
||||
|
||||
#if defined(__cplusplus)
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
310
dependency/x86/third_party/msquic/v2.5.9/include/quic_sal_stub.h
vendored
Normal file
310
dependency/x86/third_party/msquic/v2.5.9/include/quic_sal_stub.h
vendored
Normal file
@ -0,0 +1,310 @@
|
||||
/*++
|
||||
|
||||
Copyright (c) Microsoft Corporation.
|
||||
Licensed under the MIT License.
|
||||
|
||||
--*/
|
||||
|
||||
#ifndef _SAL_STUB_H
|
||||
#define _SAL_STUB_H
|
||||
|
||||
#pragma once
|
||||
|
||||
//
|
||||
// Necessary when SAL isn't supported to tell compiler it's not necessary.
|
||||
//
|
||||
#define INIT_NO_SAL(X) = X
|
||||
|
||||
#ifndef _Must_inspect_result_
|
||||
#define _Must_inspect_result_
|
||||
#endif
|
||||
|
||||
#ifndef _Pre_defensive_
|
||||
#define _Pre_defensive_
|
||||
#endif
|
||||
|
||||
#ifndef _Ret_notnull_
|
||||
#define _Ret_notnull_
|
||||
#endif
|
||||
|
||||
#ifndef _IRQL_requires_max_
|
||||
#define _IRQL_requires_max_(...)
|
||||
#endif
|
||||
|
||||
#ifndef _Function_class_
|
||||
#define _Function_class_(...)
|
||||
#endif
|
||||
|
||||
#ifndef _In_
|
||||
#define _In_
|
||||
#endif
|
||||
|
||||
#ifndef _In_opt_
|
||||
#define _In_opt_
|
||||
#endif
|
||||
|
||||
#ifndef _In_opt_z_
|
||||
#define _In_opt_z_
|
||||
#endif
|
||||
|
||||
#ifndef _Inout_
|
||||
#define _Inout_
|
||||
#endif
|
||||
|
||||
#ifndef _Inout_opt_
|
||||
#define _Inout_opt_
|
||||
#endif
|
||||
|
||||
#ifndef _In_z_
|
||||
#define _In_z_
|
||||
#endif
|
||||
|
||||
#ifndef _Out_
|
||||
#define _Out_
|
||||
#endif
|
||||
|
||||
#ifndef _Out_range_
|
||||
#define _Out_range_(...)
|
||||
#endif
|
||||
|
||||
#ifndef _Field_size_
|
||||
#define _Field_size_(...)
|
||||
#endif
|
||||
|
||||
#ifndef _Field_size_opt_
|
||||
#define _Field_size_opt_(...)
|
||||
#endif
|
||||
|
||||
#ifndef _Field_size_bytes_
|
||||
#define _Field_size_bytes_(...)
|
||||
#endif
|
||||
|
||||
#ifndef _Field_size_bytes_opt_
|
||||
#define _Field_size_bytes_opt_(...)
|
||||
#endif
|
||||
|
||||
#ifndef _In_reads_
|
||||
#define _In_reads_(...)
|
||||
#endif
|
||||
|
||||
#ifndef _In_reads_bytes_
|
||||
#define _In_reads_bytes_(...)
|
||||
#endif
|
||||
|
||||
#ifndef _In_reads_z_
|
||||
#define _In_reads_z_(...)
|
||||
#endif
|
||||
|
||||
#ifndef _In_reads_opt_z_
|
||||
#define _In_reads_opt_z_(...)
|
||||
#endif
|
||||
|
||||
#ifndef _In_reads_or_z_opt_
|
||||
#define _In_reads_or_z_opt_(...)
|
||||
#endif
|
||||
|
||||
#ifndef _Out_writes_bytes_opt_
|
||||
#define _Out_writes_bytes_opt_(...)
|
||||
#endif
|
||||
|
||||
#ifndef _Null_terminated_
|
||||
#define _Null_terminated_
|
||||
#endif
|
||||
|
||||
#ifndef _NullNull_terminated_
|
||||
#define _NullNull_terminated_
|
||||
#endif
|
||||
|
||||
#ifndef _Out_writes_bytes_
|
||||
#define _Out_writes_bytes_(...)
|
||||
#endif
|
||||
|
||||
#ifndef _Field_size_
|
||||
#define _Field_size_(...)
|
||||
#endif
|
||||
|
||||
#ifndef _Success_
|
||||
#define _Success_(...)
|
||||
#endif
|
||||
|
||||
#ifndef _Field_range_
|
||||
#define _Field_range_(...)
|
||||
#endif
|
||||
|
||||
#ifndef _In_reads_bytes_opt_
|
||||
#define _In_reads_bytes_opt_(...)
|
||||
#endif
|
||||
|
||||
#ifndef _Out_writes_bytes_to_opt_
|
||||
#define _Out_writes_bytes_to_opt_(...)
|
||||
#endif
|
||||
|
||||
#ifndef _Deref_pre_opt_count_
|
||||
#define _Deref_pre_opt_count_(...)
|
||||
#endif
|
||||
|
||||
#ifndef _Deref_post_opt_count_
|
||||
#define _Deref_post_opt_count_(...)
|
||||
#endif
|
||||
|
||||
#ifndef _Outptr_result_buffer_
|
||||
#define _Outptr_result_buffer_(...)
|
||||
#endif
|
||||
|
||||
#ifndef _Outptr_result_buffer_maybenull_
|
||||
#define _Outptr_result_buffer_maybenull_(...)
|
||||
#endif
|
||||
|
||||
#ifndef _Inout_updates_bytes_
|
||||
#define _Inout_updates_bytes_(...)
|
||||
#endif
|
||||
|
||||
#ifndef _Inout_updates_bytes_opt_
|
||||
#define _Inout_updates_bytes_opt_(...)
|
||||
#endif
|
||||
|
||||
#ifndef _Inout_updates_
|
||||
#define _Inout_updates_(...)
|
||||
#endif
|
||||
|
||||
#ifndef _Out_opt_
|
||||
#define _Out_opt_
|
||||
#endif
|
||||
|
||||
#ifndef _Outptr_result_maybenull_
|
||||
#define _Outptr_result_maybenull_
|
||||
#endif
|
||||
|
||||
#ifndef _Outptr_
|
||||
#define _Outptr_
|
||||
#endif
|
||||
|
||||
#ifndef _Ret_maybenull_
|
||||
#define _Ret_maybenull_
|
||||
#endif
|
||||
|
||||
#ifndef _Must_inspect_result_
|
||||
#define _Must_inspect_result_
|
||||
#endif
|
||||
|
||||
#ifndef _Post_invalid_
|
||||
#define _Post_invalid_
|
||||
#endif
|
||||
|
||||
#ifndef _Post_writable_byte_size_
|
||||
#define _Post_writable_byte_size_(...)
|
||||
#endif
|
||||
|
||||
#ifndef __drv_allocatesMem
|
||||
#define __drv_allocatesMem(...)
|
||||
#endif
|
||||
|
||||
#ifndef __drv_freesMem
|
||||
#define __drv_freesMem(...)
|
||||
#endif
|
||||
|
||||
#ifndef __drv_aliasesMem
|
||||
#define __drv_aliasesMem
|
||||
#endif
|
||||
|
||||
#ifndef _Frees_ptr_
|
||||
#define _Frees_ptr_
|
||||
#endif
|
||||
|
||||
#ifndef _Frees_ptr_opt_
|
||||
#define _Frees_ptr_opt_
|
||||
#endif
|
||||
|
||||
#ifndef _In_range_
|
||||
#define _In_range_(...)
|
||||
#endif
|
||||
|
||||
#ifndef _When_
|
||||
#define _When_(...)
|
||||
#endif
|
||||
|
||||
#ifndef _Post_equal_to_
|
||||
#define _Post_equal_to_(...)
|
||||
#endif
|
||||
|
||||
#ifndef _Deref_in_range_
|
||||
#define _Deref_in_range_(...)
|
||||
#endif
|
||||
|
||||
#ifndef _Deref_out_range_
|
||||
#define _Deref_out_range_(...)
|
||||
#endif
|
||||
|
||||
#ifndef _Out_writes_all_
|
||||
#define _Out_writes_all_(...)
|
||||
#endif
|
||||
|
||||
#ifndef _Out_writes_to_
|
||||
#define _Out_writes_to_(...)
|
||||
#endif
|
||||
|
||||
#ifndef _Out_writes_
|
||||
#define _Out_writes_(...)
|
||||
#endif
|
||||
|
||||
#ifndef _Field_z_
|
||||
#define _Field_z_
|
||||
#endif
|
||||
|
||||
#ifndef __analysis_assume
|
||||
#define __analysis_assume(expr)
|
||||
#endif
|
||||
|
||||
#ifndef _Out_writes_bytes_all_
|
||||
#define _Out_writes_bytes_all_(...)
|
||||
#endif
|
||||
|
||||
#ifndef _Analysis_assume_
|
||||
#define _Analysis_assume_(expr)
|
||||
#endif
|
||||
|
||||
#ifndef _Ret_range_
|
||||
#define _Ret_range_(...)
|
||||
#endif
|
||||
|
||||
#ifndef _Ret_writes_bytes_
|
||||
#define _Ret_writes_bytes_(...)
|
||||
#endif
|
||||
|
||||
#ifndef _Printf_format_string_
|
||||
#define _Printf_format_string_
|
||||
#endif
|
||||
|
||||
#ifndef _Interlocked_operand_
|
||||
#define _Interlocked_operand_
|
||||
#endif
|
||||
|
||||
#ifndef _In_reads_opt_
|
||||
#define _In_reads_opt_(...)
|
||||
#endif
|
||||
|
||||
#ifndef _At_
|
||||
#define _At_(...)
|
||||
#endif
|
||||
|
||||
#ifndef _At_buffer_
|
||||
#define _At_buffer_(...)
|
||||
#endif
|
||||
|
||||
#ifndef _Check_return_
|
||||
#define _Check_return_
|
||||
#endif
|
||||
|
||||
#ifndef _Requires_lock_held_
|
||||
#define _Requires_lock_held_(...)
|
||||
#endif
|
||||
|
||||
#ifndef _Requires_exclusive_lock_held_
|
||||
#define _Requires_exclusive_lock_held_(...)
|
||||
#endif
|
||||
|
||||
#ifndef _Requires_shared_lock_held_
|
||||
#define _Requires_shared_lock_held_(...)
|
||||
#endif
|
||||
|
||||
#endif // _SAL_STUB_H
|
||||
1
dependency/x86/third_party/msquic/v2.5.9/lib/libmsquic.so
vendored
Symbolic link
1
dependency/x86/third_party/msquic/v2.5.9/lib/libmsquic.so
vendored
Symbolic link
@ -0,0 +1 @@
|
||||
libmsquic.so.2
|
||||
1
dependency/x86/third_party/msquic/v2.5.9/lib/libmsquic.so.2
vendored
Symbolic link
1
dependency/x86/third_party/msquic/v2.5.9/lib/libmsquic.so.2
vendored
Symbolic link
@ -0,0 +1 @@
|
||||
libmsquic.so.2.5.9
|
||||
BIN
dependency/x86/third_party/msquic/v2.5.9/lib/libmsquic.so.2.5.9
vendored
Normal file
BIN
dependency/x86/third_party/msquic/v2.5.9/lib/libmsquic.so.2.5.9
vendored
Normal file
Binary file not shown.
21
dependency/x86/third_party/msquic/v2.5.9/share/licenses/msquic/LICENSE
vendored
Normal file
21
dependency/x86/third_party/msquic/v2.5.9/share/licenses/msquic/LICENSE
vendored
Normal file
@ -0,0 +1,21 @@
|
||||
Copyright (c) Microsoft Corporation.
|
||||
|
||||
MIT License
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
187
dependency/x86/third_party/msquic/v2.5.9/share/licenses/msquic/THIRD-PARTY-NOTICES
vendored
Normal file
187
dependency/x86/third_party/msquic/v2.5.9/share/licenses/msquic/THIRD-PARTY-NOTICES
vendored
Normal file
@ -0,0 +1,187 @@
|
||||
In some configuration, MsQuic uses third-party libraries or other resources
|
||||
that may be distributed under licenses different than the MsQuic software.
|
||||
|
||||
In the event that we accidentally failed to list a required notice, please
|
||||
bring it to our attention by posting a GitHub issue or Discussion item.
|
||||
|
||||
The attached notices are provided for information only.
|
||||
|
||||
License notice for OpenSSL
|
||||
-------------------------------
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
https://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
@ -71,7 +71,9 @@ message QuicEdgeConfig {
|
||||
uint32 grpc_endpoint_port = 16;
|
||||
bool grpc_endpoint_tls = 17;
|
||||
|
||||
// The receiver may override heartbeat_interval_ms in NodeRegisterResponse.
|
||||
// Local heartbeat frequency in milliseconds. A zero
|
||||
// NodeRegisterResponse.heartbeat_interval_ms keeps this value; a non-zero
|
||||
// response is the platform's negotiated override.
|
||||
// control_response_timeout_ms applies while waiting for registration and
|
||||
// heartbeat acknowledgements on the reliable stream.
|
||||
uint32 heartbeat_interval_ms = 18;
|
||||
|
||||
@ -25,20 +25,73 @@ participate in edge-node registration or heartbeat.
|
||||
|
||||
## Build and host-safe defaults
|
||||
|
||||
MsQuic is optional at configure time. It can be installed under
|
||||
`dependency/<arch>/third_party/msquic/<version>` or supplied explicitly:
|
||||
cmvr-es pins MsQuic v2.5.9 and builds it from source into the repository
|
||||
dependency tree. It is not installed system-wide:
|
||||
|
||||
```bash
|
||||
cmake -S . -B build \
|
||||
script/build_msquic.sh \
|
||||
--arch x86 \
|
||||
--version 2.5.9 \
|
||||
--jobs 2 \
|
||||
--clean
|
||||
|
||||
cmake -S . -B build-quic \
|
||||
-DCMVR_ARCH=x86 \
|
||||
-DCMVR_ENABLE_MSQUIC_BACKEND=ON \
|
||||
-DCMVR_MSQUIC_ROOT=/absolute/path/to/msquic
|
||||
cmake --build build -j2
|
||||
cmake --install build
|
||||
-DCMVR_REQUIRE_MSQUIC=ON \
|
||||
-DCMVR_ALLOW_SYSTEM_MSQUIC=OFF \
|
||||
-DCMVR_MSQUIC_VERSION=2.5.9 \
|
||||
-DBUILD_TESTING=ON \
|
||||
-DCMVR_INSTALL_DEFAULT_RUNTIME_ASSETS=OFF
|
||||
cmake --build build-quic -j2
|
||||
ctest --test-dir build-quic --output-on-failure
|
||||
cmake --install build-quic
|
||||
```
|
||||
|
||||
Without MsQuic the project still builds, but enabling the QUIC task fails with
|
||||
an explicit error. The repository default keeps `quic_edge` disabled. The
|
||||
existing inbound gRPC server remains enabled independently.
|
||||
The source helper clones the official `v2.5.9` tag, verifies its reviewed
|
||||
source commit, and initializes its QuicTLS submodule
|
||||
into `build/third_party/`, then installs the relocatable public result under
|
||||
`dependency/x86/third_party/msquic/v2.5.9`. Its first run therefore requires
|
||||
network access, but neither root privileges nor a system MsQuic package. The
|
||||
result uses a statically linked QuicTLS backend; `libmsquic.so` is copied to
|
||||
`output/lib` when cmvr-es is installed.
|
||||
|
||||
`FindMsQuic.cmake` searches only
|
||||
`dependency/<arch>/third_party/msquic/v<CMVR_MSQUIC_VERSION>` or an explicit
|
||||
`CMVR_MSQUIC_ROOT` by default. Repository and explicit prefixes must include a
|
||||
matching `BUILD-INFO.txt`; the requested version is not inferred from the
|
||||
directory name. System fallback is disabled unless
|
||||
`CMVR_ALLOW_SYSTEM_MSQUIC=ON` is deliberately selected.
|
||||
`CMVR_REQUIRE_MSQUIC=ON` makes a missing or mismatched repository dependency a
|
||||
configure error. Without that strict option, the project still builds its
|
||||
unavailable/stub backend, but enabling the QUIC task fails with an explicit
|
||||
error. The repository runtime configuration keeps `quic_edge` disabled, and
|
||||
the existing inbound gRPC server remains enabled independently.
|
||||
|
||||
`CMVR_INSTALL_DEFAULT_RUNTIME_ASSETS=OFF` preserves an existing
|
||||
`output/bin/config` and `output/bin/model` while updating the binary and
|
||||
runtime libraries. Leave its default value `ON` when a clean copy of the
|
||||
repository defaults is desired.
|
||||
|
||||
## Local real-QUIC verification
|
||||
|
||||
The development-only server in `test/quic_gateway` listens through real
|
||||
MsQuic/TLS/UDP and implements the v1 control and DATAGRAM receiver. With the
|
||||
strict build above, CTest registers:
|
||||
|
||||
- `cmvr_quic_msquic_e2e_test`, which connects the production edge service and
|
||||
feeds synthetic H.264 video plus AAC audio through `MediaSourceHub`, then
|
||||
verifies registration, heartbeat, descriptors, DATAGRAMs and frame
|
||||
reassembly;
|
||||
- `cmvr_es_quic_process_smoke_test`, which starts the actual `cmvr_es`
|
||||
executable with a temporary no-device configuration and verifies
|
||||
registration and heartbeat ACKs against the local gateway.
|
||||
|
||||
Both tests use a short-lived loopback certificate generated under the build
|
||||
tree. They do not modify `output/bin/config` or require physical devices. See
|
||||
[`test/e2e/README.md`](../../../../test/e2e/README.md) and
|
||||
[`test/quic_gateway/README.md`](../../../../test/quic_gateway/README.md) for
|
||||
their exact scope and manual gateway options.
|
||||
|
||||
To enable node presence without media:
|
||||
|
||||
@ -85,7 +138,10 @@ The legal session order is:
|
||||
3. The edge sends `NodeHeartbeat` at the negotiated interval. The gateway returns
|
||||
`NodeHeartbeatAck` with the same `session_id` and exact
|
||||
`acknowledged_sequence`. A missing, rejected or mismatched ACK causes the edge
|
||||
to reconnect and register again after its configured backoff.
|
||||
to reconnect and register again after its configured backoff. Every heartbeat
|
||||
also carries a fresh `DeviceManagerSnapshot`; its `devices` list contains
|
||||
only enabled devices, including enabled devices whose construction,
|
||||
initialization or start failed.
|
||||
4. If media tracks are available, the edge sends `MediaSessionOpen`, binding its
|
||||
`session_epoch` to the accepted `session_id`, followed by track descriptors.
|
||||
The edge calls reliable descriptor send before DATAGRAM send for a new
|
||||
@ -102,8 +158,53 @@ The edge sends a fresh local-interface snapshot in every heartbeat, so an IP
|
||||
change is reported without a second protocol. These addresses are edge claims.
|
||||
`observed_source_ip` is authoritative for the public/NAT-facing address and must
|
||||
be derived by the gateway from the authenticated QUIC peer, never copied from a
|
||||
client field. `heartbeat_interval_ms=0` in the registration response means the
|
||||
edge retains its configured interval.
|
||||
client field.
|
||||
|
||||
The heartbeat's `device_manager.devices` contains only entries with
|
||||
`enabled=true`. Disabled configuration entries remain available in the edge's
|
||||
local DeviceManager snapshot but are not transmitted. An enabled entry that
|
||||
fails creation, initialization or start remains visible with
|
||||
`MANAGED_DEVICE_STATE_ERROR`, `has_error=true` and an error detail. Its
|
||||
independent health value may still be `UNSPECIFIED` when no trustworthy device
|
||||
probe exists. `kind` is the stable category used for machine decisions, while
|
||||
`type_name` is a concrete implementation name when the object exists and
|
||||
otherwise a category label; it is intended only for display and diagnostics.
|
||||
Senders order entries by `device_id` to make
|
||||
snapshots deterministic. `sampled_at_unix_ms` is the snapshot time, whereas
|
||||
`status_updated_at_unix_ms` records when DeviceManager last changed that row's
|
||||
lifecycle/error record. The enclosing snapshot time is the freshness timestamp
|
||||
for the health observation.
|
||||
|
||||
Lifecycle and health are deliberately separate. In particular,
|
||||
`DEVICE_HEALTH_STATUS_UNSPECIFIED` means that no trustworthy health observation
|
||||
was available; it is not equivalent to `DEVICE_HEALTH_STATUS_HEALTHY`.
|
||||
Similarly, `has_error=false` only means that no error is currently confirmed
|
||||
and must not be used to turn unknown health into healthy health. A health probe
|
||||
failure must degrade that row to an unknown or fault result without suppressing
|
||||
the rest of the heartbeat.
|
||||
|
||||
The edge caps each diagnostic string at 512 bytes without splitting a UTF-8
|
||||
code point. Device identifiers, implementation names and manager metadata are
|
||||
not silently truncated because doing so would change identity. The deployment
|
||||
must therefore size `maximum_control_frame_bytes` for its enabled inventory;
|
||||
the sender and receiver both reject an oversized control frame. Very large
|
||||
inventories require a future explicit pagination/truncation extension rather
|
||||
than silently dropping rows from this enabled-device snapshot.
|
||||
|
||||
The locally configured `QuicEdgeConfig.heartbeat_interval_ms` controls the
|
||||
reporting interval before registration. A gateway may negotiate a different
|
||||
interval through `NodeRegisterResponse.heartbeat_interval_ms`: zero keeps the
|
||||
locally configured value, while a non-zero value overrides it for the current
|
||||
registered QUIC connection. The edge clamps the negotiated value to its
|
||||
supported safety range and returns to the local value on reconnect until a new
|
||||
registration response is accepted.
|
||||
|
||||
`device_manager = 9` is an additive protobuf field in `NodeHeartbeat`, so this
|
||||
extension remains QUIC edge protocol v1. Existing gateways ignore the unknown
|
||||
field. Updated gateways must continue accepting older v1 heartbeats where
|
||||
`device_manager` is absent and must not treat an absent snapshot as an empty,
|
||||
healthy DeviceManager. Enum values may only be appended; existing numeric
|
||||
meanings must never be renumbered or reused.
|
||||
|
||||
DATAGRAM negotiation is required only when at least one media track is enabled.
|
||||
The reliable registration and heartbeat path remains valid for a zero-track
|
||||
|
||||
@ -45,6 +45,83 @@ message GrpcEndpoint {
|
||||
bool tls = 3;
|
||||
}
|
||||
|
||||
// Stable protocol-level categories for devices managed by cmvr-es. These
|
||||
// values intentionally do not reuse the configuration or gRPC API enums:
|
||||
// their zero values and supported categories have different semantics.
|
||||
enum DeviceKind {
|
||||
DEVICE_KIND_UNSPECIFIED = 0;
|
||||
DEVICE_KIND_AGV = 1;
|
||||
DEVICE_KIND_ARM = 2;
|
||||
DEVICE_KIND_BATTERY = 3;
|
||||
DEVICE_KIND_BIO_HEAD = 4;
|
||||
DEVICE_KIND_CAMERA = 5;
|
||||
DEVICE_KIND_CAN_BUS = 6;
|
||||
DEVICE_KIND_DEX_HAND = 7;
|
||||
DEVICE_KIND_GRIPPER = 8;
|
||||
DEVICE_KIND_MICROPHONE = 9;
|
||||
DEVICE_KIND_MOTOR = 10;
|
||||
DEVICE_KIND_MOTOR_SYSTEM = 11;
|
||||
DEVICE_KIND_ROBOT = 12;
|
||||
DEVICE_KIND_SPEAKER = 13;
|
||||
}
|
||||
|
||||
// DeviceManager's view of a configured entry. REGISTERED means that the
|
||||
// manager owns a device record but has no more specific lifecycle signal.
|
||||
enum ManagedDeviceState {
|
||||
MANAGED_DEVICE_STATE_UNSPECIFIED = 0;
|
||||
MANAGED_DEVICE_STATE_DISABLED = 1;
|
||||
MANAGED_DEVICE_STATE_INITIALIZING = 2;
|
||||
MANAGED_DEVICE_STATE_REGISTERED = 3;
|
||||
MANAGED_DEVICE_STATE_READY = 4;
|
||||
MANAGED_DEVICE_STATE_RUNNING = 5;
|
||||
MANAGED_DEVICE_STATE_STOPPED = 6;
|
||||
MANAGED_DEVICE_STATE_ERROR = 7;
|
||||
}
|
||||
|
||||
// Health is independent of lifecycle. UNSPECIFIED means that no trustworthy
|
||||
// health observation is available and must never be interpreted as healthy.
|
||||
enum DeviceHealthStatus {
|
||||
DEVICE_HEALTH_STATUS_UNSPECIFIED = 0;
|
||||
DEVICE_HEALTH_STATUS_HEALTHY = 1;
|
||||
DEVICE_HEALTH_STATUS_DEGRADED = 2;
|
||||
DEVICE_HEALTH_STATUS_FAULT = 3;
|
||||
}
|
||||
|
||||
message ManagedDeviceStatus {
|
||||
string device_id = 1;
|
||||
DeviceKind kind = 2;
|
||||
|
||||
// Concrete implementation name when a device object exists; otherwise a
|
||||
// category label. It is for display/diagnostics only. Consumers use kind,
|
||||
// rather than this free-form string, for machine decisions.
|
||||
string type_name = 3;
|
||||
|
||||
bool enabled = 4;
|
||||
ManagedDeviceState manager_state = 5;
|
||||
DeviceHealthStatus health = 6;
|
||||
|
||||
// false means that no error is currently confirmed. It does not turn
|
||||
// DEVICE_HEALTH_STATUS_UNSPECIFIED into a healthy observation.
|
||||
bool has_error = 7;
|
||||
string error_message = 8;
|
||||
|
||||
// Time at which DeviceManager last changed the lifecycle/error record.
|
||||
// DeviceManagerSnapshot.sampled_at_unix_ms is the freshness timestamp for
|
||||
// the health observation carried by this heartbeat.
|
||||
uint64 status_updated_at_unix_ms = 9;
|
||||
}
|
||||
|
||||
message DeviceManagerSnapshot {
|
||||
string manager_name = 1;
|
||||
string manager_version = 2;
|
||||
string manager_description = 3;
|
||||
|
||||
// Current cmvr-es senders include only enabled devices. The enabled field in
|
||||
// each row and DISABLED enum value remain part of v1 for wire compatibility.
|
||||
repeated ManagedDeviceStatus devices = 4;
|
||||
uint64 sampled_at_unix_ms = 5;
|
||||
}
|
||||
|
||||
message NodeDescriptor {
|
||||
string node_id = 1;
|
||||
string boot_id = 2;
|
||||
@ -84,6 +161,7 @@ message NodeHeartbeat {
|
||||
string software_version = 6;
|
||||
repeated NetworkInterfaceAddress local_interfaces = 7;
|
||||
GrpcEndpoint grpc_endpoint = 8;
|
||||
DeviceManagerSnapshot device_manager = 9;
|
||||
}
|
||||
|
||||
message NodeHeartbeatAck {
|
||||
|
||||
@ -30,10 +30,11 @@ third_party/urdfdom/v5.0.3
|
||||
third_party/urdfdom_headers/v2.0.1
|
||||
third_party/opencv/4.13.0
|
||||
third_party/modbus/3.1.11
|
||||
# MsQuic is discovered and installed from the selected version/root separately.
|
||||
# third_party/msquic/v2.5.9
|
||||
third_party/visp/3.7.0
|
||||
third_party/mainif/0.0.5
|
||||
third_party/matplotplusplus/1.2.0
|
||||
third_party/huayan_robot/v1.0
|
||||
third_party/aubo_sdk/v0.27.1
|
||||
third_party/hikvision_sdk/v6.1.11.5
|
||||
|
||||
|
||||
412
script/build_msquic.sh
Executable file
412
script/build_msquic.sh
Executable file
@ -0,0 +1,412 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: script/build_msquic.sh [options]
|
||||
|
||||
Build the pinned MsQuic source into:
|
||||
dependency/<arch>/third_party/msquic/v<version>
|
||||
|
||||
Options:
|
||||
--arch x86|arm Dependency architecture (default: native host)
|
||||
--version VERSION Supported pinned version without leading v (default: 2.5.9)
|
||||
--jobs N Parallel build jobs (default: nproc)
|
||||
--clean Recreate the MsQuic build and staging directories
|
||||
-h, --help Show this help
|
||||
|
||||
Environment:
|
||||
CMVR_CMAKE Absolute CMake executable override
|
||||
CMVR_MSQUIC_TOOLCHAIN_FILE CMake toolchain file for cross-compilation
|
||||
CC, CXX Native compiler overrides
|
||||
|
||||
The first run needs network access to clone the official MsQuic tag and its
|
||||
QuicTLS submodule. No sudo or system MsQuic installation is used.
|
||||
EOF
|
||||
}
|
||||
|
||||
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
|
||||
repo_root="$(cd -- "${script_dir}/.." && pwd -P)"
|
||||
version="2.5.9"
|
||||
arch=""
|
||||
jobs=""
|
||||
clean_build=false
|
||||
|
||||
while (($# > 0)); do
|
||||
case "$1" in
|
||||
--arch)
|
||||
[[ $# -ge 2 ]] || { echo "missing value for --arch" >&2; exit 2; }
|
||||
arch="$2"
|
||||
shift 2
|
||||
;;
|
||||
--version)
|
||||
[[ $# -ge 2 ]] || { echo "missing value for --version" >&2; exit 2; }
|
||||
version="${2#v}"
|
||||
shift 2
|
||||
;;
|
||||
--jobs)
|
||||
[[ $# -ge 2 ]] || { echo "missing value for --jobs" >&2; exit 2; }
|
||||
jobs="$2"
|
||||
shift 2
|
||||
;;
|
||||
--clean)
|
||||
clean_build=true
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "unknown option: $1" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
host_machine="$(uname -m)"
|
||||
case "${host_machine}" in
|
||||
x86_64|amd64)
|
||||
native_arch="x86"
|
||||
;;
|
||||
aarch64|arm64|armv8*)
|
||||
native_arch="arm"
|
||||
;;
|
||||
*)
|
||||
echo "unsupported host architecture: ${host_machine}" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
arch="${arch:-${native_arch}}"
|
||||
if [[ "${arch}" != "x86" && "${arch}" != "arm" ]]; then
|
||||
echo "--arch must be x86 or arm" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ ! "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "--version must use the form MAJOR.MINOR.PATCH" >&2
|
||||
exit 2
|
||||
fi
|
||||
case "${version}" in
|
||||
2.5.9)
|
||||
expected_source_commit="87b53085d76bd7920d490a6f226c9999b6614d14"
|
||||
;;
|
||||
*)
|
||||
echo "unsupported MsQuic version: ${version}" >&2
|
||||
echo "add its reviewed tag and commit to script/build_msquic.sh first" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ -z "${jobs}" ]]; then
|
||||
jobs="$(nproc 2>/dev/null || getconf _NPROCESSORS_ONLN || echo 2)"
|
||||
fi
|
||||
if [[ ! "${jobs}" =~ ^[1-9][0-9]*$ ]]; then
|
||||
echo "--jobs must be a positive integer" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
toolchain_args=()
|
||||
if [[ "${arch}" != "${native_arch}" ]]; then
|
||||
if [[ -z "${CMVR_MSQUIC_TOOLCHAIN_FILE:-}" ]]; then
|
||||
echo "cross-building ${arch} on ${host_machine} requires" >&2
|
||||
echo "CMVR_MSQUIC_TOOLCHAIN_FILE=/absolute/path/to/toolchain.cmake" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ ! -f "${CMVR_MSQUIC_TOOLCHAIN_FILE}" ]]; then
|
||||
echo "toolchain file does not exist: ${CMVR_MSQUIC_TOOLCHAIN_FILE}" >&2
|
||||
exit 2
|
||||
fi
|
||||
toolchain_file="$(realpath "${CMVR_MSQUIC_TOOLCHAIN_FILE}")"
|
||||
toolchain_args+=("-DCMAKE_TOOLCHAIN_FILE=${toolchain_file}")
|
||||
fi
|
||||
|
||||
tag="v${version}"
|
||||
source_dir="${repo_root}/build/third_party/msquic-src/${tag}"
|
||||
build_dir="${repo_root}/build/third_party/msquic-build/${arch}-${tag}"
|
||||
stage_dir="${repo_root}/build/third_party/msquic-stage/${arch}-${tag}"
|
||||
stage_prefix="${stage_dir}/prefix"
|
||||
install_root="${repo_root}/dependency/${arch}/third_party/msquic/${tag}"
|
||||
backup_root="${install_root}.previous"
|
||||
|
||||
for guarded_path in \
|
||||
"${source_dir}" "${build_dir}" "${stage_dir}" \
|
||||
"${install_root}" "${backup_root}"; do
|
||||
case "${guarded_path}" in
|
||||
"${repo_root}"/build/third_party/*|\
|
||||
"${repo_root}"/dependency/"${arch}"/third_party/msquic/"${tag}"|\
|
||||
"${repo_root}"/dependency/"${arch}"/third_party/msquic/"${tag}".previous)
|
||||
;;
|
||||
*)
|
||||
echo "refusing unsafe path: ${guarded_path}" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -n "${CMVR_CMAKE:-}" ]]; then
|
||||
cmake_bin="$(realpath "${CMVR_CMAKE}")"
|
||||
else
|
||||
bundled_cmake="${repo_root}/dependency/${arch}/third_party/cmake/v3.30.3/cmake-3.30.3-linux-$(
|
||||
[[ "${arch}" == "x86" ]] && echo x86_64 || echo aarch64
|
||||
)/bin/cmake"
|
||||
if [[ "${arch}" == "${native_arch}" && -x "${bundled_cmake}" ]]; then
|
||||
cmake_bin="${bundled_cmake}"
|
||||
else
|
||||
cmake_bin="$(command -v cmake)"
|
||||
fi
|
||||
fi
|
||||
[[ -x "${cmake_bin}" ]] || {
|
||||
echo "CMake executable is unavailable: ${cmake_bin}" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
git_bin="/usr/bin/git"
|
||||
[[ -x "${git_bin}" ]] || git_bin="$(command -v git)"
|
||||
[[ -x "${git_bin}" ]] || { echo "git is required" >&2; exit 2; }
|
||||
|
||||
clean_path="$(dirname "${cmake_bin}"):/usr/local/bin:/usr/bin:/bin"
|
||||
export PATH="${clean_path}"
|
||||
unset CONDA_PREFIX CONDA_DEFAULT_ENV CMAKE_PREFIX_PATH PKG_CONFIG_PATH \
|
||||
OPENSSL_ROOT_DIR OPENSSL_DIR LD_LIBRARY_PATH LIBRARY_PATH CPATH \
|
||||
C_INCLUDE_PATH CPLUS_INCLUDE_PATH CFLAGS CXXFLAGS CPPFLAGS LDFLAGS \
|
||||
PERL5LIB PERL5OPT
|
||||
|
||||
if [[ "${arch}" == "${native_arch}" ]]; then
|
||||
export CC="${CC:-/usr/bin/cc}"
|
||||
export CXX="${CXX:-/usr/bin/c++}"
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "${source_dir}")" "$(dirname "${install_root}")"
|
||||
if [[ ! -d "${source_dir}/.git" ]]; then
|
||||
"${git_bin}" clone \
|
||||
--branch "${tag}" \
|
||||
--depth 1 \
|
||||
https://github.com/microsoft/msquic.git \
|
||||
"${source_dir}"
|
||||
fi
|
||||
|
||||
source_commit="$("${git_bin}" -C "${source_dir}" rev-parse HEAD)"
|
||||
tag_commit="$("${git_bin}" -C "${source_dir}" rev-list -n 1 "${tag}")"
|
||||
if [[ "${source_commit}" != "${tag_commit}" ]]; then
|
||||
echo "${source_dir} is not checked out at ${tag}" >&2
|
||||
echo "remove that cache directory and rerun the script" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ "${source_commit}" != "${expected_source_commit}" ]]; then
|
||||
echo "${tag} resolved to an unexpected source commit" >&2
|
||||
echo "expected: ${expected_source_commit}" >&2
|
||||
echo "actual: ${source_commit}" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
unexpected_initialized_submodules="$(
|
||||
"${git_bin}" -C "${source_dir}" submodule status |
|
||||
awk '$2 != "submodules/quictls" && substr($1, 1, 1) != "-" {
|
||||
print $2 " (" $1 ")"
|
||||
}'
|
||||
)"
|
||||
if [[ -n "${unexpected_initialized_submodules}" ]]; then
|
||||
echo "MsQuic source cache contains initialized non-QuicTLS submodules:" >&2
|
||||
echo "${unexpected_initialized_submodules}" >&2
|
||||
echo "deinitialize those submodules or use a clean source cache before building" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
"${git_bin}" -C "${source_dir}" submodule sync -- submodules/quictls
|
||||
"${git_bin}" -C "${source_dir}" submodule update \
|
||||
--init --depth 1 -- submodules/quictls
|
||||
|
||||
source_changes="$("${git_bin}" -C "${source_dir}" status \
|
||||
--porcelain --untracked-files=all --ignore-submodules=all)"
|
||||
if [[ -n "${source_changes}" ]]; then
|
||||
echo "MsQuic source cache contains local changes:" >&2
|
||||
echo "${source_changes}" >&2
|
||||
echo "use a clean source cache before building" >&2
|
||||
exit 2
|
||||
fi
|
||||
quictls_dir="${source_dir}/submodules/quictls"
|
||||
expected_quictls_commit="$("${git_bin}" -C "${source_dir}" \
|
||||
rev-parse HEAD:submodules/quictls)"
|
||||
actual_quictls_commit="$("${git_bin}" -C "${quictls_dir}" rev-parse HEAD)"
|
||||
quictls_changes="$("${git_bin}" -C "${quictls_dir}" status \
|
||||
--porcelain --untracked-files=all)"
|
||||
if [[ "${actual_quictls_commit}" != "${expected_quictls_commit}" ||
|
||||
-n "${quictls_changes}" ]]; then
|
||||
echo "QuicTLS source cache is not at the clean pinned commit" >&2
|
||||
echo "expected: ${expected_quictls_commit}" >&2
|
||||
echo "actual: ${actual_quictls_commit}" >&2
|
||||
[[ -z "${quictls_changes}" ]] || echo "${quictls_changes}" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [[ "${clean_build}" == true ]]; then
|
||||
"${cmake_bin}" -E remove_directory "${build_dir}"
|
||||
fi
|
||||
"${cmake_bin}" -E remove_directory "${stage_dir}"
|
||||
"${cmake_bin}" -E make_directory "${build_dir}" "${stage_prefix}"
|
||||
|
||||
toolchain_fingerprint="native"
|
||||
if [[ ${#toolchain_args[@]} -ne 0 ]]; then
|
||||
toolchain_fingerprint="$(
|
||||
sha256sum "${toolchain_file}" | awk '{print $1}'
|
||||
)"
|
||||
fi
|
||||
build_recipe_version="5"
|
||||
build_fingerprint="$(
|
||||
printf '%s' \
|
||||
"${source_commit}|${build_recipe_version}|${arch}|" \
|
||||
"${CC:-toolchain}|${CXX:-toolchain}|" \
|
||||
"${toolchain_fingerprint}|${cmake_bin}"
|
||||
)"
|
||||
fingerprint_file="${build_dir}/cmvr-msquic-build.fingerprint"
|
||||
if [[ -f "${build_dir}/CMakeCache.txt" ]]; then
|
||||
if [[ ! -f "${fingerprint_file}" ]]; then
|
||||
echo "existing MsQuic build cache predates compiler fingerprinting" >&2
|
||||
echo "rerun with --clean" >&2
|
||||
exit 2
|
||||
fi
|
||||
existing_fingerprint="$(<"${fingerprint_file}")"
|
||||
if [[ "${existing_fingerprint}" != "${build_fingerprint}" ]]; then
|
||||
echo "MsQuic compiler/toolchain fingerprint changed" >&2
|
||||
echo "rerun with --clean" >&2
|
||||
exit 2
|
||||
fi
|
||||
fi
|
||||
printf '%s\n' "${build_fingerprint}" >"${fingerprint_file}"
|
||||
|
||||
prefix_map_flags="\
|
||||
-ffile-prefix-map=${repo_root}=. -fmacro-prefix-map=${repo_root}=."
|
||||
"${cmake_bin}" \
|
||||
-S "${source_dir}" \
|
||||
-B "${build_dir}" \
|
||||
-G "Unix Makefiles" \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
"-DCMAKE_INSTALL_PREFIX=${stage_prefix}" \
|
||||
"-DCMAKE_MODULE_PATH=${repo_root}/cmake/msquic" \
|
||||
"-DCMVR_MSQUIC_PROCESSOR_COUNT=${jobs}" \
|
||||
"-DCMAKE_C_FLAGS=${prefix_map_flags}" \
|
||||
"-DCMAKE_CXX_FLAGS=${prefix_map_flags}" \
|
||||
-DQUIC_BUILD_SHARED=ON \
|
||||
-DQUIC_BUILD_TEST=OFF \
|
||||
-DQUIC_BUILD_TOOLS=OFF \
|
||||
-DQUIC_BUILD_PERF=OFF \
|
||||
-DQUIC_ENABLE_LOGGING=OFF \
|
||||
-DQUIC_TLS_LIB=quictls \
|
||||
-DQUIC_USE_SYSTEM_LIBCRYPTO=OFF \
|
||||
-DNUMA:STRING=FALSE \
|
||||
"${toolchain_args[@]}"
|
||||
|
||||
# QuicTLS derives MODULESDIR from its temporary --prefix and compiles that
|
||||
# absolute path into libcrypto. Dynamic providers are disabled above
|
||||
# (no-shared, no-legacy and no-fips), so keep the unused fallback path stable
|
||||
# instead of leaking the build workspace into the shipped MsQuic runtime.
|
||||
openssl_makefile_target="_deps/opensslquic-build/submodules/quictls/Makefile"
|
||||
openssl_build_rules="_deps/opensslquic-build/CMakeFiles/OpenSSL_Target.dir/build.make"
|
||||
make_bin="$(command -v make)"
|
||||
[[ -x "${make_bin}" ]] || {
|
||||
echo "GNU Make is required to configure the bundled QuicTLS source" >&2
|
||||
exit 2
|
||||
}
|
||||
"${make_bin}" -C "${build_dir}" -f "${openssl_build_rules}" \
|
||||
"${openssl_makefile_target}"
|
||||
openssl_makefile="${build_dir}/${openssl_makefile_target}"
|
||||
test -f "${openssl_makefile}"
|
||||
if ! grep -Fx 'MODULESDIR=/usr/lib/ssl/ossl-modules' \
|
||||
"${openssl_makefile}" >/dev/null; then
|
||||
sed -i -E \
|
||||
's|^MODULESDIR=.*$|MODULESDIR=/usr/lib/ssl/ossl-modules|' \
|
||||
"${openssl_makefile}"
|
||||
fi
|
||||
grep -Fx 'MODULESDIR=/usr/lib/ssl/ossl-modules' \
|
||||
"${openssl_makefile}" >/dev/null
|
||||
|
||||
"${cmake_bin}" --build "${build_dir}" --parallel "${jobs}"
|
||||
"${cmake_bin}" --install "${build_dir}"
|
||||
|
||||
# Keep only the public Linux API and shared runtime that cmvr-es consumes.
|
||||
find "${stage_prefix}/include" -maxdepth 1 -type f \
|
||||
! -name msquic.h \
|
||||
! -name msquic_posix.h \
|
||||
! -name quic_sal_stub.h \
|
||||
-delete
|
||||
"${cmake_bin}" -E rm -f "${stage_prefix}/lib/libmsquic_platform.a"
|
||||
"${cmake_bin}" -E remove_directory "${stage_prefix}/share"
|
||||
"${cmake_bin}" -E make_directory "${stage_prefix}/share/licenses/msquic"
|
||||
"${cmake_bin}" -E copy "${source_dir}/LICENSE" \
|
||||
"${stage_prefix}/share/licenses/msquic/LICENSE"
|
||||
"${cmake_bin}" -E copy "${source_dir}/THIRD-PARTY-NOTICES" \
|
||||
"${stage_prefix}/share/licenses/msquic/THIRD-PARTY-NOTICES"
|
||||
|
||||
cat >"${stage_prefix}/BUILD-INFO.txt" <<EOF
|
||||
msquic_version=${version}
|
||||
source_tag=${tag}
|
||||
source_commit=${source_commit}
|
||||
dependency_arch=${arch}
|
||||
tls_backend=quictls-static
|
||||
dynamic_tls_providers=false
|
||||
system_libcrypto=false
|
||||
numa=false
|
||||
EOF
|
||||
|
||||
test -f "${stage_prefix}/include/msquic.h"
|
||||
test -f "${stage_prefix}/include/msquic_posix.h"
|
||||
test -f "${stage_prefix}/include/quic_sal_stub.h"
|
||||
test -f "${stage_prefix}/lib/libmsquic.so.${version}"
|
||||
test -L "${stage_prefix}/lib/libmsquic.so"
|
||||
|
||||
if command -v readelf >/dev/null 2>&1; then
|
||||
if readelf -d "${stage_prefix}/lib/libmsquic.so.${version}" |
|
||||
grep -E 'NEEDED.*lib(ssl|crypto|numa)' >/dev/null; then
|
||||
echo "MsQuic unexpectedly depends on system TLS or NUMA libraries" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
if grep -R -F --exclude='libmsquic.so*' \
|
||||
"${repo_root}" "${stage_prefix}" >/dev/null 2>&1; then
|
||||
echo "staged MsQuic metadata contains a non-relocatable workspace path" >&2
|
||||
exit 1
|
||||
fi
|
||||
if command -v strings >/dev/null 2>&1 &&
|
||||
strings "${stage_prefix}/lib/libmsquic.so.${version}" |
|
||||
grep -F "${repo_root}" >/dev/null; then
|
||||
echo "staged MsQuic runtime contains a non-relocatable workspace path" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
swap_in_progress=false
|
||||
restore_install_on_exit() {
|
||||
if [[ "${swap_in_progress}" != true ]]; then
|
||||
return
|
||||
fi
|
||||
if [[ -e "${install_root}" ]]; then
|
||||
"${cmake_bin}" -E remove_directory "${backup_root}" || true
|
||||
elif [[ -e "${backup_root}" ]]; then
|
||||
mv "${backup_root}" "${install_root}" || true
|
||||
fi
|
||||
}
|
||||
trap restore_install_on_exit EXIT
|
||||
|
||||
if [[ -e "${backup_root}" ]]; then
|
||||
if [[ ! -e "${install_root}" ]]; then
|
||||
mv "${backup_root}" "${install_root}"
|
||||
else
|
||||
echo "stale MsQuic backup requires manual inspection:" >&2
|
||||
echo " ${backup_root}" >&2
|
||||
exit 2
|
||||
fi
|
||||
fi
|
||||
if [[ -e "${install_root}" ]]; then
|
||||
swap_in_progress=true
|
||||
mv "${install_root}" "${backup_root}"
|
||||
fi
|
||||
mv "${stage_prefix}" "${install_root}"
|
||||
swap_in_progress=false
|
||||
"${cmake_bin}" -E remove_directory "${backup_root}"
|
||||
trap - EXIT
|
||||
|
||||
echo "MsQuic ${tag} installed to:"
|
||||
echo " ${install_root}"
|
||||
echo "Configure cmvr-es with:"
|
||||
echo " -DCMVR_ENABLE_MSQUIC_BACKEND=ON -DCMVR_REQUIRE_MSQUIC=ON"
|
||||
53
test/CMakeLists.txt
Normal file
53
test/CMakeLists.txt
Normal file
@ -0,0 +1,53 @@
|
||||
get_property(_cmvr_quic_test_library_dirs DIRECTORY PROPERTY LINK_DIRECTORIES)
|
||||
list(PREPEND _cmvr_quic_test_library_dirs
|
||||
"${CMAKE_BINARY_DIR}"
|
||||
"${CMAKE_BINARY_DIR}/cmvr_compiler_runtime")
|
||||
list(REMOVE_DUPLICATES _cmvr_quic_test_library_dirs)
|
||||
list(JOIN _cmvr_quic_test_library_dirs ":" CMVR_QUIC_TEST_LIBRARY_PATH)
|
||||
|
||||
add_subdirectory(quic_gateway)
|
||||
|
||||
if(NOT BUILD_TESTING)
|
||||
return()
|
||||
endif()
|
||||
|
||||
find_program(CMVR_OPENSSL_EXECUTABLE NAMES openssl)
|
||||
if(NOT CMVR_OPENSSL_EXECUTABLE)
|
||||
message(WARNING
|
||||
"The openssl command was not found; QUIC loopback integration "
|
||||
"tests that require a temporary TLS certificate are skipped")
|
||||
return()
|
||||
endif()
|
||||
|
||||
set(CMVR_QUIC_TEST_CERTIFICATE_DIR
|
||||
"${CMAKE_CURRENT_BINARY_DIR}/certs")
|
||||
set(CMVR_QUIC_TEST_CERTIFICATE
|
||||
"${CMVR_QUIC_TEST_CERTIFICATE_DIR}/server.crt")
|
||||
set(CMVR_QUIC_TEST_PRIVATE_KEY
|
||||
"${CMVR_QUIC_TEST_CERTIFICATE_DIR}/server.key")
|
||||
|
||||
add_custom_command(
|
||||
OUTPUT
|
||||
"${CMVR_QUIC_TEST_CERTIFICATE}"
|
||||
"${CMVR_QUIC_TEST_PRIVATE_KEY}"
|
||||
COMMAND "${CMAKE_COMMAND}" -E make_directory
|
||||
"${CMVR_QUIC_TEST_CERTIFICATE_DIR}"
|
||||
COMMAND "${CMVR_OPENSSL_EXECUTABLE}" req
|
||||
-x509
|
||||
-newkey rsa:2048
|
||||
-nodes
|
||||
-keyout "${CMVR_QUIC_TEST_PRIVATE_KEY}"
|
||||
-out "${CMVR_QUIC_TEST_CERTIFICATE}"
|
||||
-days 7
|
||||
-subj "/CN=127.0.0.1"
|
||||
-addext "subjectAltName=IP:127.0.0.1"
|
||||
VERBATIM
|
||||
COMMENT "Generating a development-only loopback QUIC certificate")
|
||||
add_custom_target(cmvr_quic_test_certificate
|
||||
DEPENDS
|
||||
"${CMVR_QUIC_TEST_CERTIFICATE}"
|
||||
"${CMVR_QUIC_TEST_PRIVATE_KEY}")
|
||||
|
||||
if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/e2e/CMakeLists.txt")
|
||||
add_subdirectory(e2e)
|
||||
endif()
|
||||
18
test/README.md
Normal file
18
test/README.md
Normal file
@ -0,0 +1,18 @@
|
||||
# 测试工具与真实 QUIC 联调
|
||||
|
||||
该目录放置跨模块测试和开发工具,不包含生产平台 Gateway。
|
||||
|
||||
```text
|
||||
test/
|
||||
├── quic_gateway/ # 本机真实 MsQuic Server、故障注入和媒体重组
|
||||
└── e2e/ # 生产 QUIC client 与完整 cmvr_es 进程联调
|
||||
```
|
||||
|
||||
默认在 `BUILD_TESTING=ON` 且仓库内 MsQuic 可用时构建。构建树会生成一套
|
||||
仅用于 loopback 测试的短期自签名证书,不会修改
|
||||
`output/bin/config/`,也不会访问物理设备。
|
||||
|
||||
详细命令和验收字段见
|
||||
[`quic_gateway/README.md`](quic_gateway/README.md)。`test/` 中的程序只用于
|
||||
开发、CI 和协议联调;生产平台仍应独立实现鉴权、节点状态持久化、媒体转发和
|
||||
浏览器接入。
|
||||
102
test/e2e/CMakeLists.txt
Normal file
102
test/e2e/CMakeLists.txt
Normal file
@ -0,0 +1,102 @@
|
||||
if(NOT BUILD_TESTING)
|
||||
return()
|
||||
endif()
|
||||
|
||||
if(NOT TARGET cmvr_es::quic_test_gateway)
|
||||
message(STATUS
|
||||
"Skipping real QUIC E2E target: cmvr_es::quic_test_gateway is unavailable")
|
||||
return()
|
||||
endif()
|
||||
|
||||
if(NOT TARGET cmvr_es::quic_edge_service OR
|
||||
NOT TARGET cmvr_es::media_source_hub)
|
||||
message(FATAL_ERROR
|
||||
"Real QUIC E2E requires the production QUIC service and MediaSourceHub")
|
||||
endif()
|
||||
|
||||
add_executable(cmvr_quic_msquic_e2e_test
|
||||
quic_msquic_e2e_test.cpp
|
||||
)
|
||||
target_compile_features(cmvr_quic_msquic_e2e_test PRIVATE cxx_std_17)
|
||||
target_link_libraries(cmvr_quic_msquic_e2e_test
|
||||
PRIVATE
|
||||
cmvr_es::quic_test_gateway
|
||||
cmvr_es::quic_edge_service
|
||||
cmvr_es::media_source_hub
|
||||
)
|
||||
|
||||
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
|
||||
target_compile_options(cmvr_quic_msquic_e2e_test
|
||||
PRIVATE -Wall -Wextra -Wpedantic)
|
||||
endif()
|
||||
|
||||
# The parent build may generate a short-lived test certificate and pass these
|
||||
# cache variables. Keeping the executable argv-driven also permits a manual run
|
||||
# without embedding machine-specific paths in the binary.
|
||||
set(CMVR_QUIC_E2E_CERTIFICATE_FILE "" CACHE FILEPATH
|
||||
"PEM certificate passed to the real MsQuic E2E test")
|
||||
set(CMVR_QUIC_E2E_PRIVATE_KEY_FILE "" CACHE FILEPATH
|
||||
"Unencrypted PEM private key passed to the real MsQuic E2E test")
|
||||
|
||||
set(_cmvr_quic_e2e_certificate "${CMVR_QUIC_E2E_CERTIFICATE_FILE}")
|
||||
set(_cmvr_quic_e2e_private_key "${CMVR_QUIC_E2E_PRIVATE_KEY_FILE}")
|
||||
if(NOT _cmvr_quic_e2e_certificate AND
|
||||
DEFINED CMVR_QUIC_TEST_CERTIFICATE)
|
||||
set(_cmvr_quic_e2e_certificate "${CMVR_QUIC_TEST_CERTIFICATE}")
|
||||
endif()
|
||||
if(NOT _cmvr_quic_e2e_private_key AND
|
||||
DEFINED CMVR_QUIC_TEST_PRIVATE_KEY)
|
||||
set(_cmvr_quic_e2e_private_key "${CMVR_QUIC_TEST_PRIVATE_KEY}")
|
||||
endif()
|
||||
|
||||
if(TARGET cmvr_quic_test_certificate)
|
||||
add_dependencies(cmvr_quic_msquic_e2e_test
|
||||
cmvr_quic_test_certificate)
|
||||
endif()
|
||||
|
||||
if(_cmvr_quic_e2e_certificate AND _cmvr_quic_e2e_private_key)
|
||||
add_test(
|
||||
NAME cmvr_quic_msquic_e2e_test
|
||||
COMMAND cmvr_quic_msquic_e2e_test
|
||||
--cert "${_cmvr_quic_e2e_certificate}"
|
||||
--key "${_cmvr_quic_e2e_private_key}"
|
||||
)
|
||||
set_tests_properties(cmvr_quic_msquic_e2e_test PROPERTIES
|
||||
LABELS "quic;e2e;msquic"
|
||||
RUN_SERIAL TRUE
|
||||
TIMEOUT 30
|
||||
ENVIRONMENT
|
||||
"LD_LIBRARY_PATH=${CMVR_QUIC_TEST_LIBRARY_PATH}"
|
||||
)
|
||||
else()
|
||||
message(STATUS
|
||||
"cmvr_quic_msquic_e2e_test built but not registered with CTest; "
|
||||
"set CMVR_QUIC_E2E_CERTIFICATE_FILE and "
|
||||
"CMVR_QUIC_E2E_PRIVATE_KEY_FILE")
|
||||
endif()
|
||||
|
||||
find_package(Python3 3.10 QUIET COMPONENTS Interpreter)
|
||||
if(Python3_Interpreter_FOUND AND
|
||||
TARGET cmvr_es AND
|
||||
TARGET cmvr_quic_test_gateway AND
|
||||
_cmvr_quic_e2e_certificate AND
|
||||
_cmvr_quic_e2e_private_key)
|
||||
add_test(
|
||||
NAME cmvr_es_quic_process_smoke_test
|
||||
COMMAND "${Python3_EXECUTABLE}"
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/run_cmvr_es_quic_smoke.py"
|
||||
--gateway "$<TARGET_FILE:cmvr_quic_test_gateway>"
|
||||
--cmvr-es "$<TARGET_FILE:cmvr_es>"
|
||||
--cert "${_cmvr_quic_e2e_certificate}"
|
||||
--key "${_cmvr_quic_e2e_private_key}")
|
||||
set_tests_properties(cmvr_es_quic_process_smoke_test PROPERTIES
|
||||
LABELS "quic;e2e;msquic;process"
|
||||
RUN_SERIAL TRUE
|
||||
TIMEOUT 30
|
||||
ENVIRONMENT
|
||||
"LD_LIBRARY_PATH=${CMVR_QUIC_TEST_LIBRARY_PATH}")
|
||||
elseif(NOT Python3_Interpreter_FOUND)
|
||||
message(STATUS
|
||||
"Python3 was not found; the full cmvr_es QUIC process smoke test "
|
||||
"is skipped")
|
||||
endif()
|
||||
122
test/e2e/README.md
Normal file
122
test/e2e/README.md
Normal file
@ -0,0 +1,122 @@
|
||||
# QUIC 真实端到端测试
|
||||
|
||||
本目录验证生产 QUIC client 代码与
|
||||
[`test/quic_gateway/`](../quic_gateway/) 中真实 MsQuic Server 的互操作,不使用
|
||||
fake transport,也不要求连接物理设备。
|
||||
|
||||
## 测试矩阵
|
||||
|
||||
| CTest 名称 | 进程边界 | 覆盖内容 |
|
||||
| --- | --- | --- |
|
||||
| `cmvr_quic_msquic_e2e_test` | 测试进程内同时运行 Gateway 和生产 `QuicEdgeService` | TLS/ALPN、注册、DeviceManager 合成快照、至少两次心跳 ACK、H.264/AAC descriptor 精确字段、真实 DATAGRAM、分片、序列号、flags、长度与载荷哈希 |
|
||||
| `cmvr_es_quic_process_smoke_test` | 分别启动测试 Gateway 和真实 `cmvr_es` 子进程 | 临时配置加载、`QuicEdgeTask` 工厂和生命周期、节点注册、IP、禁用设备过滤、已启用设备创建失败上报、本地心跳周期、至少两次心跳 ACK、SIGTERM 安全退出 |
|
||||
|
||||
第一项向生产 `MediaSourceHub` 注册两个有界 synthetic source:
|
||||
|
||||
- 2500 字节的 H.264 Annex B IDR 视频帧,用于覆盖 DATAGRAM 分片;
|
||||
- 带 ADTS header 的 AAC-LC 48 kHz 双声道音频帧。
|
||||
|
||||
测试先发送 discovery 帧触发可靠 descriptor,确认 Gateway 已安装两个轨道后,
|
||||
再发送 priming 和 validation 帧,因此不会把 reliable stream 与 DATAGRAM 的
|
||||
跨通道乱序误报成失败。最终 validation 帧按轨道精确校验 sequence、字节数、
|
||||
flags 和 FNV-1a 载荷哈希。
|
||||
|
||||
它验证的是媒体传输和协议封装,不验证摄像头、麦克风或编码器驱动。第二项生成
|
||||
一棵临时 Proto Text 配置树,配置两个禁用设备和一个无配置文件的已启用设备,
|
||||
只启用 QUIC 任务;它会验证禁用设备不会出现在 heartbeat 中、已启用设备的创建
|
||||
失败仍会作为异常行上报,并让 Gateway 返回零周期以保留边缘配置;
|
||||
测试结束后临时
|
||||
目录自动清理,不会修改 `cmvr-es/config/`、`output/bin/config/` 或
|
||||
`output/bin/model/`。
|
||||
|
||||
## 构建并运行
|
||||
|
||||
先按根目录 README 构建固定版本的仓库内 MsQuic:
|
||||
|
||||
```bash
|
||||
script/build_msquic.sh \
|
||||
--arch x86 \
|
||||
--version 2.5.9 \
|
||||
--jobs "$(nproc)" \
|
||||
--clean
|
||||
```
|
||||
|
||||
再配置真实后端和测试:
|
||||
|
||||
```bash
|
||||
cmake -S . -B build-quic \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DCMVR_ARCH=x86 \
|
||||
-DCMVR_ENABLE_MSQUIC_BACKEND=ON \
|
||||
-DCMVR_REQUIRE_MSQUIC=ON \
|
||||
-DCMVR_ALLOW_SYSTEM_MSQUIC=OFF \
|
||||
-DCMVR_MSQUIC_VERSION=2.5.9 \
|
||||
-DBUILD_TESTING=ON \
|
||||
-DCMVR_BUILD_QUIC_TEST_GATEWAY=ON \
|
||||
-DCMVR_INSTALL_DEFAULT_RUNTIME_ASSETS=OFF
|
||||
|
||||
cmake --build build-quic -j"$(nproc)"
|
||||
ctest \
|
||||
--test-dir build-quic \
|
||||
--output-on-failure \
|
||||
-R 'cmvr_quic_msquic_e2e_test|cmvr_es_quic_process_smoke_test'
|
||||
```
|
||||
|
||||
要求:
|
||||
|
||||
- `dependency/x86/third_party/msquic/v2.5.9` 已完整生成;
|
||||
- 本机可执行 `openssl`,CMake 用它在
|
||||
`build-quic/test/certs/` 生成 7 天有效的 loopback 证书;
|
||||
- 完整进程冒烟需要 Python 3.10 或更高版本;
|
||||
- 运行环境允许在 `127.0.0.1` 创建 UDP listener 和 QUIC 连接。
|
||||
|
||||
测试被标记为 `RUN_SERIAL`,避免端口、证书和进程日志互相干扰。也可按 label
|
||||
运行:
|
||||
|
||||
```bash
|
||||
ctest --test-dir build-quic --output-on-failure -L 'quic|e2e'
|
||||
```
|
||||
|
||||
CTest 会为构建树补齐仓库内共享库搜索路径。人工启动 Gateway 时应使用 CMake
|
||||
生成的
|
||||
`build-quic/test/quic_gateway/run_cmvr_quic_test_gateway` 包装器,而不是直接
|
||||
执行裸二进制;参数和 summary 字段见
|
||||
[`test/quic_gateway/README.md`](../quic_gateway/README.md)。
|
||||
|
||||
## 通过条件
|
||||
|
||||
真实媒体 E2E 至少要求:
|
||||
|
||||
- 一次注册被接受,且至少两个心跳被精确 ACK;
|
||||
- 一个媒体 session 和两个轨道描述到达,且 codec、payload format、分辨率或
|
||||
采样参数、generation 和 codec config 与源描述完全一致;
|
||||
- 视频和音频 validation 帧均完成重组,最大 sequence、track ID、长度、flags
|
||||
和载荷哈希与发送值完全一致;
|
||||
- Edge 与 Gateway 的 session ID 一致;
|
||||
- `protocol_violations == 0`,两端均无运行时错误;
|
||||
- client、连接、listener 和后台队列能够有序停止。
|
||||
|
||||
完整进程冒烟至少要求:
|
||||
|
||||
- `cmvr_es` 日志出现 `[QuicEdgeTask] Started`;
|
||||
- 没有使用不可用占位后端;
|
||||
- Gateway 接受 `cmvr-process-smoke` 节点注册;
|
||||
- 收到并回复至少两个心跳;
|
||||
- 心跳中的 Manager 元数据正确,两个禁用设备均未上报,已启用设备的创建失败
|
||||
以 `ERROR` 行上报;
|
||||
- Gateway 和 `cmvr_es` 都以退出码 0 结束。
|
||||
|
||||
失败时进程冒烟会输出 Gateway 与 `cmvr_es` 的完整临时日志,便于区分依赖加载、
|
||||
UDP 监听、TLS、协议或生命周期问题。
|
||||
|
||||
## 不覆盖的能力
|
||||
|
||||
这些测试不替代:
|
||||
|
||||
- 真机摄像头、麦克风和厂商 SDK 验证;
|
||||
- 真实编码器码流质量和长时间压力测试;
|
||||
- 丢包、抖动、NAT、防火墙和弱网测试;
|
||||
- Java 生产 Gateway、鉴权、状态持久化和浏览器转发;
|
||||
- mTLS、生产 CA、业务 token 或设备 ACL。
|
||||
|
||||
测试证书和 `allow_insecure` 只用于本机 loopback,不得用于生产环境。
|
||||
804
test/e2e/quic_msquic_e2e_test.cpp
Normal file
804
test/e2e/quic_msquic_e2e_test.cpp
Normal file
@ -0,0 +1,804 @@
|
||||
#include "quic_test_gateway.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cctype>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <iostream>
|
||||
#include <limits>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "common/media/media_frame.h"
|
||||
#include "manager/media_source_hub/include/media_source_hub.h"
|
||||
#include "service/quic_edge/include/quic_edge_service.h"
|
||||
#include "service/quic_edge/include/quic_transport.h"
|
||||
|
||||
namespace {
|
||||
|
||||
using Clock = std::chrono::steady_clock;
|
||||
using cmvr::media::Codec;
|
||||
using cmvr::media::MediaFrame;
|
||||
using cmvr::media::MediaFramePtr;
|
||||
using cmvr::media::MediaKind;
|
||||
using cmvr::media::MediaSourceHub;
|
||||
using cmvr::media::PayloadFormat;
|
||||
using cmvr::media::TrackDescriptor;
|
||||
using cmvr::media::TrackDescriptorPtr;
|
||||
using cmvr::quic_edge::QuicEdgeService;
|
||||
using cmvr::test::quic_gateway::GatewayOptions;
|
||||
using cmvr::test::quic_gateway::QuicTestGateway;
|
||||
|
||||
constexpr auto kOverallTimeout = std::chrono::seconds(15);
|
||||
constexpr auto kPollInterval = std::chrono::milliseconds(10);
|
||||
constexpr std::uint64_t kFnv1a64OffsetBasis = 14695981039346656037ULL;
|
||||
constexpr std::uint64_t kFnv1a64Prime = 1099511628211ULL;
|
||||
|
||||
struct Arguments {
|
||||
std::string certificate_file;
|
||||
std::string private_key_file;
|
||||
};
|
||||
|
||||
bool parseArguments(int argc, char* argv[], Arguments* arguments)
|
||||
{
|
||||
if (!arguments) return false;
|
||||
for (int index = 1; index < argc; ++index) {
|
||||
const std::string option = argv[index];
|
||||
if ((option != "--cert" && option != "--key") ||
|
||||
index + 1 >= argc) {
|
||||
return false;
|
||||
}
|
||||
const std::string value = argv[++index];
|
||||
if (value.empty()) return false;
|
||||
if (option == "--cert") {
|
||||
arguments->certificate_file = value;
|
||||
} else {
|
||||
arguments->private_key_file = value;
|
||||
}
|
||||
}
|
||||
return !arguments->certificate_file.empty() &&
|
||||
!arguments->private_key_file.empty();
|
||||
}
|
||||
|
||||
std::optional<std::uint64_t> jsonUnsigned(
|
||||
const std::string& json, const std::string& key)
|
||||
{
|
||||
const std::string marker = "\"" + key + "\":";
|
||||
std::size_t position = json.find(marker);
|
||||
if (position == std::string::npos) return std::nullopt;
|
||||
position += marker.size();
|
||||
while (position < json.size() &&
|
||||
std::isspace(static_cast<unsigned char>(json[position]))) {
|
||||
++position;
|
||||
}
|
||||
if (position == json.size() ||
|
||||
!std::isdigit(static_cast<unsigned char>(json[position]))) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::uint64_t value = 0U;
|
||||
while (position < json.size() &&
|
||||
std::isdigit(static_cast<unsigned char>(json[position]))) {
|
||||
const std::uint64_t digit =
|
||||
static_cast<std::uint64_t>(json[position] - '0');
|
||||
if (value >
|
||||
(std::numeric_limits<std::uint64_t>::max() - digit) / 10U) {
|
||||
return std::nullopt;
|
||||
}
|
||||
value = value * 10U + digit;
|
||||
++position;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
std::optional<std::string> jsonString(
|
||||
const std::string& json, const std::string& key)
|
||||
{
|
||||
const std::string marker = "\"" + key + "\":\"";
|
||||
std::size_t position = json.find(marker);
|
||||
if (position == std::string::npos) return std::nullopt;
|
||||
position += marker.size();
|
||||
|
||||
std::string value;
|
||||
bool escaped = false;
|
||||
for (; position < json.size(); ++position) {
|
||||
const char current = json[position];
|
||||
if (escaped) {
|
||||
value.push_back(current);
|
||||
escaped = false;
|
||||
} else if (current == '\\') {
|
||||
escaped = true;
|
||||
} else if (current == '"') {
|
||||
return value;
|
||||
} else {
|
||||
value.push_back(current);
|
||||
}
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
bool countAtLeast(const std::string& summary,
|
||||
const std::string& key,
|
||||
const std::uint64_t minimum)
|
||||
{
|
||||
const auto value = jsonUnsigned(summary, key);
|
||||
return value && *value >= minimum;
|
||||
}
|
||||
|
||||
bool containsJsonFragment(const std::string& summary,
|
||||
const std::string& fragment)
|
||||
{
|
||||
return summary.find(fragment) != std::string::npos;
|
||||
}
|
||||
|
||||
template <typename Predicate>
|
||||
bool waitUntil(const Clock::time_point deadline, Predicate&& predicate)
|
||||
{
|
||||
while (Clock::now() < deadline) {
|
||||
if (predicate()) return true;
|
||||
std::this_thread::sleep_for(kPollInterval);
|
||||
}
|
||||
return predicate();
|
||||
}
|
||||
|
||||
std::uint64_t monotonicNanoseconds()
|
||||
{
|
||||
return static_cast<std::uint64_t>(
|
||||
std::chrono::duration_cast<std::chrono::nanoseconds>(
|
||||
Clock::now().time_since_epoch())
|
||||
.count());
|
||||
}
|
||||
|
||||
std::uint64_t fnv1a64(const std::uint8_t* data, const std::size_t size)
|
||||
{
|
||||
std::uint64_t hash = kFnv1a64OffsetBasis;
|
||||
for (std::size_t index = 0; index < size; ++index) {
|
||||
hash ^= data[index];
|
||||
hash *= kFnv1a64Prime;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
class SyntheticSource {
|
||||
public:
|
||||
explicit SyntheticSource(TrackDescriptorPtr descriptor)
|
||||
: descriptor_(std::move(descriptor))
|
||||
{
|
||||
}
|
||||
|
||||
MediaSourceHub::SourceCallbacks callbacks(const bool video)
|
||||
{
|
||||
MediaSourceHub::SourceCallbacks callbacks;
|
||||
callbacks.start = [this](
|
||||
const MediaSourceHub::FrameSink& sink,
|
||||
const MediaSourceHub::CancelPredicate& cancelled) {
|
||||
if (!sink || (cancelled && cancelled())) return false;
|
||||
std::lock_guard lock(mutex_);
|
||||
sink_ = sink;
|
||||
running_ = true;
|
||||
return true;
|
||||
};
|
||||
callbacks.stop = [this]() {
|
||||
std::lock_guard lock(mutex_);
|
||||
running_ = false;
|
||||
sink_ = {};
|
||||
};
|
||||
if (video) {
|
||||
callbacks.request_key_frame = [this]() {
|
||||
std::lock_guard lock(mutex_);
|
||||
++key_frame_requests_;
|
||||
return running_;
|
||||
};
|
||||
}
|
||||
return callbacks;
|
||||
}
|
||||
|
||||
bool publish(std::vector<std::uint8_t> payload,
|
||||
const std::uint64_t sequence,
|
||||
const bool key_frame,
|
||||
const bool discontinuity = false)
|
||||
{
|
||||
MediaSourceHub::FrameSink sink;
|
||||
{
|
||||
std::lock_guard lock(mutex_);
|
||||
if (!running_ || !sink_) return false;
|
||||
sink = sink_;
|
||||
}
|
||||
|
||||
MediaFrame::Config frame;
|
||||
frame.descriptor = descriptor_;
|
||||
frame.payload = std::move(payload);
|
||||
frame.sequence = sequence;
|
||||
frame.source_frame_number = sequence;
|
||||
frame.pts = static_cast<std::int64_t>(sequence);
|
||||
frame.dts = frame.pts;
|
||||
frame.duration = 1;
|
||||
frame.capture_time_ns = monotonicNanoseconds();
|
||||
frame.key_frame = key_frame;
|
||||
frame.discontinuity = discontinuity;
|
||||
sink(cmvr::media::makeMediaFrame(std::move(frame)));
|
||||
return true;
|
||||
}
|
||||
|
||||
std::uint64_t keyFrameRequests() const
|
||||
{
|
||||
std::lock_guard lock(mutex_);
|
||||
return key_frame_requests_;
|
||||
}
|
||||
|
||||
private:
|
||||
TrackDescriptorPtr descriptor_;
|
||||
mutable std::mutex mutex_;
|
||||
MediaSourceHub::FrameSink sink_;
|
||||
bool running_{false};
|
||||
std::uint64_t key_frame_requests_{0U};
|
||||
};
|
||||
|
||||
TrackDescriptorPtr makeVideoDescriptor()
|
||||
{
|
||||
TrackDescriptor::Config descriptor;
|
||||
descriptor.id = "synthetic-camera/video/color";
|
||||
descriptor.source_id = "synthetic-camera";
|
||||
descriptor.kind = MediaKind::VIDEO;
|
||||
descriptor.codec = Codec::H264;
|
||||
descriptor.payload_format = PayloadFormat::ANNEX_B;
|
||||
descriptor.time_base = {1, 90000};
|
||||
descriptor.width = 640U;
|
||||
descriptor.height = 360U;
|
||||
descriptor.nominal_rate = 30U;
|
||||
descriptor.generation = 0x0000000100000001ULL;
|
||||
descriptor.codec_config = {
|
||||
0x00U, 0x00U, 0x00U, 0x01U, 0x67U, 0x42U, 0x00U, 0x1EU};
|
||||
return cmvr::media::makeTrackDescriptor(std::move(descriptor));
|
||||
}
|
||||
|
||||
TrackDescriptorPtr makeAudioDescriptor()
|
||||
{
|
||||
TrackDescriptor::Config descriptor;
|
||||
descriptor.id = "synthetic-microphone/audio/main";
|
||||
descriptor.source_id = "synthetic-microphone";
|
||||
descriptor.kind = MediaKind::AUDIO;
|
||||
descriptor.codec = Codec::AAC;
|
||||
descriptor.payload_format = PayloadFormat::AAC_ADTS;
|
||||
descriptor.time_base = {1, 48000};
|
||||
descriptor.sample_rate = 48000U;
|
||||
descriptor.channels = 2U;
|
||||
descriptor.nominal_rate = 50U;
|
||||
descriptor.generation = 0x0000000100000002ULL;
|
||||
descriptor.codec_config = {0x11U, 0x90U};
|
||||
return cmvr::media::makeTrackDescriptor(std::move(descriptor));
|
||||
}
|
||||
|
||||
std::vector<std::uint8_t> h264Payload(const std::uint8_t fill)
|
||||
{
|
||||
std::vector<std::uint8_t> payload(2500U, fill);
|
||||
payload[0] = 0x00U;
|
||||
payload[1] = 0x00U;
|
||||
payload[2] = 0x00U;
|
||||
payload[3] = 0x01U;
|
||||
payload[4] = 0x65U; // IDR slice.
|
||||
return payload;
|
||||
}
|
||||
|
||||
std::vector<std::uint8_t> aacPayload(const std::uint8_t fill)
|
||||
{
|
||||
std::vector<std::uint8_t> payload(512U, fill);
|
||||
// AAC-LC, 48 kHz, stereo ADTS header for a synthetic access unit.
|
||||
const std::uint16_t frame_length =
|
||||
static_cast<std::uint16_t>(payload.size());
|
||||
payload[0] = 0xFFU;
|
||||
payload[1] = 0xF1U;
|
||||
payload[2] = 0x4CU;
|
||||
payload[3] = static_cast<std::uint8_t>(
|
||||
0x80U | ((frame_length >> 11U) & 0x03U));
|
||||
payload[4] = static_cast<std::uint8_t>((frame_length >> 3U) & 0xFFU);
|
||||
payload[5] = static_cast<std::uint8_t>(
|
||||
((frame_length & 0x07U) << 5U) | 0x1FU);
|
||||
payload[6] = 0xFCU;
|
||||
return payload;
|
||||
}
|
||||
|
||||
cmvr::config::QuicEdgeConfig makeEdgeConfig(const std::uint16_t port)
|
||||
{
|
||||
cmvr::config::QuicEdgeConfig config;
|
||||
config.set_id("quic-real-e2e");
|
||||
config.set_enable(true);
|
||||
config.set_server_host("127.0.0.1");
|
||||
config.set_server_port(port);
|
||||
config.set_alpn("cmvr-quic-edge/1");
|
||||
config.set_node_id("cmvr-real-e2e-node");
|
||||
config.set_software_version("e2e");
|
||||
config.set_grpc_endpoint_host("127.0.0.1");
|
||||
config.set_grpc_endpoint_port(50052U);
|
||||
config.set_grpc_endpoint_tls(false);
|
||||
config.set_include_loopback_interfaces(true);
|
||||
config.set_heartbeat_interval_ms(250U);
|
||||
config.set_control_response_timeout_ms(1500U);
|
||||
config.mutable_tls()->set_allow_insecure(true);
|
||||
config.mutable_reconnect()->set_initial_delay_ms(50U);
|
||||
config.mutable_reconnect()->set_maximum_delay_ms(500U);
|
||||
config.mutable_reconnect()->set_multiplier(2.0);
|
||||
config.mutable_reconnect()->set_jitter_percent(0U);
|
||||
config.mutable_reconnect()->set_connect_timeout_ms(2000U);
|
||||
config.set_maximum_datagram_bytes(1200U);
|
||||
config.set_maximum_control_frame_bytes(1024U * 1024U);
|
||||
config.set_maximum_frame_bytes(16U * 1024U);
|
||||
config.set_datagram_send_queue_depth(128U);
|
||||
config.set_media_poll_interval_ms(1U);
|
||||
|
||||
auto* video = config.add_tracks();
|
||||
video->set_track_id(1U);
|
||||
video->set_source_kind(
|
||||
cmvr::config::QuicEdgeTrackConfig::SOURCE_KIND_CAMERA);
|
||||
video->set_device_id("synthetic-camera");
|
||||
video->set_source_track_id("synthetic-camera/video/color");
|
||||
video->set_enable(true);
|
||||
|
||||
auto* audio = config.add_tracks();
|
||||
audio->set_track_id(2U);
|
||||
audio->set_source_kind(
|
||||
cmvr::config::QuicEdgeTrackConfig::SOURCE_KIND_MICROPHONE);
|
||||
audio->set_device_id("synthetic-microphone");
|
||||
audio->set_source_track_id("synthetic-microphone/audio/main");
|
||||
audio->set_enable(true);
|
||||
return config;
|
||||
}
|
||||
|
||||
cmvr::device::DeviceManagerSnapshot makeDeviceManagerSnapshot()
|
||||
{
|
||||
cmvr::device::DeviceManagerSnapshot snapshot;
|
||||
snapshot.name = "cmvr-real-e2e-manager";
|
||||
snapshot.version = "e2e";
|
||||
snapshot.description = "synthetic DeviceManager heartbeat snapshot";
|
||||
|
||||
cmvr::device::ManagedDeviceSnapshot video;
|
||||
video.id = "synthetic-camera";
|
||||
video.kind = cmvr::device::DeviceKind::Camera;
|
||||
video.type_name = "SyntheticCamera";
|
||||
video.enabled = true;
|
||||
video.state = cmvr::device::ManagedDeviceState::Running;
|
||||
video.health.state = cmvr::device::DeviceHealthState::Healthy;
|
||||
video.status_updated_at_unix_ms = 1001U;
|
||||
snapshot.devices.push_back(video);
|
||||
|
||||
cmvr::device::ManagedDeviceSnapshot audio;
|
||||
audio.id = "synthetic-microphone";
|
||||
audio.kind = cmvr::device::DeviceKind::Microphone;
|
||||
audio.type_name = "SyntheticMicrophone";
|
||||
audio.enabled = true;
|
||||
audio.state = cmvr::device::ManagedDeviceState::Error;
|
||||
audio.health.state = cmvr::device::DeviceHealthState::Fault;
|
||||
audio.abnormal = true;
|
||||
audio.error_message = "synthetic health fault";
|
||||
audio.status_updated_at_unix_ms = 1002U;
|
||||
snapshot.devices.push_back(audio);
|
||||
|
||||
cmvr::device::ManagedDeviceSnapshot disabled;
|
||||
disabled.id = "synthetic-disabled-arm";
|
||||
disabled.kind = cmvr::device::DeviceKind::Arm;
|
||||
disabled.type_name = "DEVICE_TYPE_ROBOT_ARM";
|
||||
disabled.enabled = false;
|
||||
disabled.state = cmvr::device::ManagedDeviceState::Disabled;
|
||||
disabled.health.state = cmvr::device::DeviceHealthState::Unknown;
|
||||
disabled.status_updated_at_unix_ms = 1003U;
|
||||
snapshot.devices.push_back(disabled);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
class GatewayStopGuard {
|
||||
public:
|
||||
explicit GatewayStopGuard(QuicTestGateway* gateway) : gateway_(gateway) {}
|
||||
~GatewayStopGuard()
|
||||
{
|
||||
if (gateway_) gateway_->stop();
|
||||
}
|
||||
|
||||
private:
|
||||
QuicTestGateway* gateway_;
|
||||
};
|
||||
|
||||
class ServiceStopGuard {
|
||||
public:
|
||||
explicit ServiceStopGuard(QuicEdgeService* service) : service_(service) {}
|
||||
~ServiceStopGuard()
|
||||
{
|
||||
if (service_) service_->stop();
|
||||
}
|
||||
|
||||
private:
|
||||
QuicEdgeService* service_;
|
||||
};
|
||||
|
||||
int run(const Arguments& arguments)
|
||||
{
|
||||
if (!cmvr::quic_edge::hasCompiledMsQuicSupport()) {
|
||||
std::cerr << "production QUIC transport was not compiled with MsQuic\n";
|
||||
return 1;
|
||||
}
|
||||
|
||||
GatewayOptions gateway_options;
|
||||
gateway_options.bind_address = "127.0.0.1";
|
||||
gateway_options.port = 0U;
|
||||
gateway_options.certificate_file = arguments.certificate_file;
|
||||
gateway_options.private_key_file = arguments.private_key_file;
|
||||
gateway_options.heartbeat_interval_ms = 250U;
|
||||
gateway_options.maximum_reassembly_bytes = 4U * 1024U * 1024U;
|
||||
gateway_options.maximum_reassembly_frames = 32U;
|
||||
gateway_options.maximum_frame_bytes = 16U * 1024U;
|
||||
gateway_options.maximum_work_queue_bytes = 4U * 1024U * 1024U;
|
||||
gateway_options.reassembly_timeout_ms = 1000U;
|
||||
|
||||
QuicTestGateway gateway(std::move(gateway_options));
|
||||
std::string error;
|
||||
if (!gateway.start(&error)) {
|
||||
std::cerr << "gateway start failed: " << error << '\n';
|
||||
return 1;
|
||||
}
|
||||
GatewayStopGuard gateway_guard(&gateway);
|
||||
if (gateway.boundPort() == 0U) {
|
||||
std::cerr << "gateway did not publish an ephemeral UDP port\n";
|
||||
return 1;
|
||||
}
|
||||
|
||||
const TrackDescriptorPtr video_descriptor = makeVideoDescriptor();
|
||||
const TrackDescriptorPtr audio_descriptor = makeAudioDescriptor();
|
||||
SyntheticSource video(video_descriptor);
|
||||
SyntheticSource audio(audio_descriptor);
|
||||
MediaSourceHub hub;
|
||||
if (!hub.registerSource(
|
||||
video_descriptor, video.callbacks(true), 8U) ||
|
||||
!hub.registerSource(
|
||||
audio_descriptor, audio.callbacks(false), 8U)) {
|
||||
std::cerr << "failed to register synthetic MediaSourceHub tracks\n";
|
||||
return 1;
|
||||
}
|
||||
|
||||
auto config = makeEdgeConfig(gateway.boundPort());
|
||||
std::string validation_error;
|
||||
if (!QuicEdgeService::validateConfig(config, &validation_error)) {
|
||||
std::cerr << "invalid E2E edge config: " << validation_error << '\n';
|
||||
return 1;
|
||||
}
|
||||
|
||||
auto transport = cmvr::quic_edge::createDefaultQuicTransport(
|
||||
config.datagram_send_queue_depth());
|
||||
const auto device_manager_snapshot = makeDeviceManagerSnapshot();
|
||||
QuicEdgeService service(
|
||||
config, std::move(transport), hub,
|
||||
[device_manager_snapshot]() { return device_manager_snapshot; });
|
||||
ServiceStopGuard service_guard(&service);
|
||||
if (!service.initialize(&error)) {
|
||||
std::cerr << "edge initialize failed: " << error << '\n';
|
||||
return 1;
|
||||
}
|
||||
if (!service.start(&error)) {
|
||||
std::cerr << "edge start failed: " << error << '\n';
|
||||
return 1;
|
||||
}
|
||||
|
||||
const Clock::time_point deadline = Clock::now() + kOverallTimeout;
|
||||
const bool sources_ready = waitUntil(deadline, [&]() {
|
||||
return gateway.hasRuntimeFailure() ||
|
||||
(hub.subscriberCount(video_descriptor->id) == 1U &&
|
||||
hub.subscriberCount(audio_descriptor->id) == 1U &&
|
||||
service.status().registered);
|
||||
});
|
||||
if (!sources_ready || gateway.hasRuntimeFailure() ||
|
||||
hub.subscriberCount(video_descriptor->id) != 1U ||
|
||||
hub.subscriberCount(audio_descriptor->id) != 1U) {
|
||||
std::cerr << "registration/source subscription timeout; edge_error="
|
||||
<< service.lastError() << " gateway_error="
|
||||
<< gateway.lastError() << " summary="
|
||||
<< gateway.summaryJson() << '\n';
|
||||
return 1;
|
||||
}
|
||||
|
||||
// A first frame causes the edge to announce its track. Its DATAGRAM may
|
||||
// legally overtake the reliable descriptor and be discarded by the
|
||||
// receiver, so these frames are discovery traffic only.
|
||||
if (!video.publish(h264Payload(0x31U), 1U, true, true) ||
|
||||
!audio.publish(aacPayload(0x41U), 1U, false, true)) {
|
||||
std::cerr << "failed to publish discovery synthetic frames\n";
|
||||
return 1;
|
||||
}
|
||||
|
||||
const bool descriptors_ready = waitUntil(deadline, [&]() {
|
||||
const std::string summary = gateway.summaryJson();
|
||||
return gateway.hasRuntimeFailure() ||
|
||||
(countAtLeast(summary, "media_sessions_opened", 1U) &&
|
||||
countAtLeast(summary, "track_descriptors_received", 2U) &&
|
||||
jsonUnsigned(summary, "video_track_id").value_or(0U) == 1U &&
|
||||
jsonUnsigned(summary, "audio_track_id").value_or(0U) == 2U &&
|
||||
service.stats().frames_queued >= 2U);
|
||||
});
|
||||
if (!descriptors_ready || gateway.hasRuntimeFailure()) {
|
||||
std::cerr << "media descriptor discovery timeout; edge_error="
|
||||
<< service.lastError() << " gateway_error="
|
||||
<< gateway.lastError() << " summary="
|
||||
<< gateway.summaryJson() << '\n';
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Once the descriptors are installed, establish one completed frame per
|
||||
// track. Independent baselines and exact sequence observations prevent
|
||||
// delayed discovery traffic on one track from masking a missing frame on
|
||||
// the other.
|
||||
const std::string discovery_summary = gateway.summaryJson();
|
||||
const std::uint64_t baseline_video_frames =
|
||||
jsonUnsigned(
|
||||
discovery_summary, "video_frames_completed").value_or(0U);
|
||||
const std::uint64_t baseline_audio_frames =
|
||||
jsonUnsigned(
|
||||
discovery_summary, "audio_frames_completed").value_or(0U);
|
||||
|
||||
if (!video.publish(h264Payload(0x32U), 2U, true) ||
|
||||
!audio.publish(aacPayload(0x42U), 2U, false)) {
|
||||
std::cerr << "failed to publish priming synthetic frames\n";
|
||||
return 1;
|
||||
}
|
||||
|
||||
const bool priming_complete = waitUntil(deadline, [&]() {
|
||||
const std::string summary = gateway.summaryJson();
|
||||
return gateway.hasRuntimeFailure() ||
|
||||
(countAtLeast(
|
||||
summary, "video_frames_completed",
|
||||
baseline_video_frames + 1U) &&
|
||||
countAtLeast(
|
||||
summary, "audio_frames_completed",
|
||||
baseline_audio_frames + 1U) &&
|
||||
jsonUnsigned(
|
||||
summary, "maximum_video_frame_sequence").value_or(0U) >=
|
||||
2U &&
|
||||
jsonUnsigned(
|
||||
summary, "maximum_audio_frame_sequence").value_or(0U) >=
|
||||
2U &&
|
||||
service.stats().frames_queued >= 4U);
|
||||
});
|
||||
if (!priming_complete || gateway.hasRuntimeFailure()) {
|
||||
std::cerr << "media priming timeout; edge_error="
|
||||
<< service.lastError() << " gateway_error="
|
||||
<< gateway.lastError() << " summary="
|
||||
<< gateway.summaryJson() << '\n';
|
||||
return 1;
|
||||
}
|
||||
|
||||
const std::string primed_summary = gateway.summaryJson();
|
||||
const std::uint64_t primed_video_frames =
|
||||
jsonUnsigned(
|
||||
primed_summary, "video_frames_completed").value_or(0U);
|
||||
const std::uint64_t primed_audio_frames =
|
||||
jsonUnsigned(
|
||||
primed_summary, "audio_frames_completed").value_or(0U);
|
||||
const std::vector<std::uint8_t> expected_video = h264Payload(0x33U);
|
||||
const std::vector<std::uint8_t> expected_audio = aacPayload(0x43U);
|
||||
|
||||
if (!video.publish(expected_video, 3U, true) ||
|
||||
!audio.publish(expected_audio, 3U, false)) {
|
||||
std::cerr << "failed to publish validation synthetic frames\n";
|
||||
return 1;
|
||||
}
|
||||
|
||||
const bool completed = waitUntil(deadline, [&]() {
|
||||
const std::string summary = gateway.summaryJson();
|
||||
return gateway.hasRuntimeFailure() ||
|
||||
(service.stats().registrations_accepted >= 1U &&
|
||||
service.stats().heartbeats_acknowledged >= 2U &&
|
||||
service.stats().media_sessions_opened >= 1U &&
|
||||
service.stats().frames_queued >= 6U &&
|
||||
service.stats().datagrams_queued >= 6U &&
|
||||
countAtLeast(summary, "registrations_accepted", 1U) &&
|
||||
countAtLeast(summary, "heartbeats_received", 2U) &&
|
||||
countAtLeast(summary, "heartbeat_acks_sent", 2U) &&
|
||||
countAtLeast(summary, "datagrams_received", 6U) &&
|
||||
countAtLeast(
|
||||
summary, "video_frames_completed",
|
||||
primed_video_frames + 1U) &&
|
||||
countAtLeast(
|
||||
summary, "audio_frames_completed",
|
||||
primed_audio_frames + 1U) &&
|
||||
jsonUnsigned(
|
||||
summary, "maximum_video_frame_sequence").value_or(0U) ==
|
||||
3U &&
|
||||
jsonUnsigned(
|
||||
summary, "maximum_audio_frame_sequence").value_or(0U) ==
|
||||
3U &&
|
||||
jsonUnsigned(
|
||||
summary, "maximum_video_frame_hash").value_or(0U) ==
|
||||
fnv1a64(expected_video.data(), expected_video.size()) &&
|
||||
jsonUnsigned(
|
||||
summary, "maximum_audio_frame_hash").value_or(0U) ==
|
||||
fnv1a64(expected_audio.data(), expected_audio.size()));
|
||||
});
|
||||
if (!completed || gateway.hasRuntimeFailure()) {
|
||||
std::cerr << "real QUIC completion timeout; edge_state="
|
||||
<< cmvr::quic_edge::toString(service.state())
|
||||
<< " edge_error=" << service.lastError()
|
||||
<< " gateway_error=" << gateway.lastError()
|
||||
<< " summary=" << gateway.summaryJson() << '\n';
|
||||
return 1;
|
||||
}
|
||||
|
||||
const auto edge_status = service.status();
|
||||
if (!edge_status.registered || edge_status.session_id.empty() ||
|
||||
edge_status.active_media_tracks != 2U ||
|
||||
video.keyFrameRequests() == 0U) {
|
||||
std::cerr << "edge did not retain the expected session/media state\n";
|
||||
return 1;
|
||||
}
|
||||
|
||||
const std::string live_summary = gateway.summaryJson();
|
||||
const auto gateway_session =
|
||||
jsonString(live_summary, "last_session_id");
|
||||
if (!gateway_session || *gateway_session != edge_status.session_id ||
|
||||
jsonString(live_summary, "last_grpc_endpoint_host")
|
||||
.value_or("") != "127.0.0.1" ||
|
||||
jsonUnsigned(live_summary, "last_grpc_endpoint_port")
|
||||
.value_or(0U) != 50052U ||
|
||||
jsonString(live_summary, "last_observed_source_ip")
|
||||
.value_or("") != "127.0.0.1" ||
|
||||
!countAtLeast(live_summary, "registration_interface_count", 1U) ||
|
||||
!countAtLeast(live_summary, "heartbeat_interface_count", 1U) ||
|
||||
jsonString(live_summary, "heartbeat_device_manager_name")
|
||||
.value_or("") != "cmvr-real-e2e-manager" ||
|
||||
jsonString(live_summary, "heartbeat_device_manager_version")
|
||||
.value_or("") != "e2e" ||
|
||||
jsonUnsigned(live_summary, "heartbeat_device_count")
|
||||
.value_or(0U) != 2U ||
|
||||
jsonUnsigned(live_summary, "heartbeat_enabled_device_count")
|
||||
.value_or(0U) != 2U ||
|
||||
jsonUnsigned(live_summary, "heartbeat_disabled_device_count")
|
||||
.value_or(0U) != 0U ||
|
||||
jsonUnsigned(live_summary, "heartbeat_error_device_count")
|
||||
.value_or(0U) != 1U ||
|
||||
jsonUnsigned(live_summary, "heartbeat_unknown_health_device_count")
|
||||
.value_or(0U) != 0U ||
|
||||
!containsJsonFragment(
|
||||
live_summary,
|
||||
R"({"device_id":"synthetic-camera","kind":5,"type_name":"SyntheticCamera","enabled":true,"manager_state":5,"health":1,"has_error":false,"error_message":"","status_updated_at_unix_ms":1001})") ||
|
||||
containsJsonFragment(
|
||||
live_summary,
|
||||
R"("device_id":"synthetic-disabled-arm")") ||
|
||||
!containsJsonFragment(
|
||||
live_summary,
|
||||
R"({"device_id":"synthetic-microphone","kind":9,"type_name":"SyntheticMicrophone","enabled":true,"manager_state":7,"health":3,"has_error":true,"error_message":"synthetic health fault","status_updated_at_unix_ms":1002})") ||
|
||||
!countAtLeast(live_summary, "track_descriptors_received", 2U) ||
|
||||
jsonUnsigned(live_summary, "video_track_id").value_or(0U) != 1U ||
|
||||
jsonString(live_summary, "video_device_id").value_or("") !=
|
||||
"synthetic-camera" ||
|
||||
jsonString(live_summary, "video_source_track_id").value_or("") !=
|
||||
video_descriptor->id ||
|
||||
jsonString(live_summary, "video_codec").value_or("") != "h264" ||
|
||||
jsonString(live_summary, "video_payload_format").value_or("") !=
|
||||
"annex_b" ||
|
||||
jsonUnsigned(live_summary, "video_codec_generation").value_or(0U) !=
|
||||
video_descriptor->generation ||
|
||||
jsonUnsigned(
|
||||
live_summary, "video_codec_generation_token").value_or(0U) !=
|
||||
cmvr::quic_edge::descriptorGenerationToken(
|
||||
video_descriptor->generation) ||
|
||||
jsonUnsigned(live_summary, "video_width").value_or(0U) != 640U ||
|
||||
jsonUnsigned(live_summary, "video_height").value_or(0U) != 360U ||
|
||||
jsonUnsigned(
|
||||
live_summary, "video_frames_per_second").value_or(0U) != 30U ||
|
||||
jsonUnsigned(
|
||||
live_summary, "video_codec_config_bytes").value_or(0U) !=
|
||||
video_descriptor->codec_config.size() ||
|
||||
jsonUnsigned(
|
||||
live_summary, "video_codec_config_hash").value_or(0U) !=
|
||||
fnv1a64(
|
||||
video_descriptor->codec_config.data(),
|
||||
video_descriptor->codec_config.size()) ||
|
||||
jsonUnsigned(live_summary, "audio_track_id").value_or(0U) != 2U ||
|
||||
jsonString(live_summary, "audio_device_id").value_or("") !=
|
||||
"synthetic-microphone" ||
|
||||
jsonString(live_summary, "audio_source_track_id").value_or("") !=
|
||||
audio_descriptor->id ||
|
||||
jsonString(live_summary, "audio_codec").value_or("") != "aac" ||
|
||||
jsonString(live_summary, "audio_payload_format").value_or("") !=
|
||||
"aac_adts" ||
|
||||
jsonUnsigned(live_summary, "audio_codec_generation").value_or(0U) !=
|
||||
audio_descriptor->generation ||
|
||||
jsonUnsigned(
|
||||
live_summary, "audio_codec_generation_token").value_or(0U) !=
|
||||
cmvr::quic_edge::descriptorGenerationToken(
|
||||
audio_descriptor->generation) ||
|
||||
jsonUnsigned(live_summary, "audio_sample_rate").value_or(0U) !=
|
||||
48000U ||
|
||||
jsonUnsigned(live_summary, "audio_channels").value_or(0U) != 2U ||
|
||||
jsonUnsigned(
|
||||
live_summary, "audio_codec_config_bytes").value_or(0U) !=
|
||||
audio_descriptor->codec_config.size() ||
|
||||
jsonUnsigned(
|
||||
live_summary, "audio_codec_config_hash").value_or(0U) !=
|
||||
fnv1a64(
|
||||
audio_descriptor->codec_config.data(),
|
||||
audio_descriptor->codec_config.size()) ||
|
||||
jsonUnsigned(
|
||||
live_summary, "maximum_video_frame_sequence").value_or(0U) !=
|
||||
3U ||
|
||||
jsonUnsigned(
|
||||
live_summary, "maximum_video_frame_track_id").value_or(0U) !=
|
||||
1U ||
|
||||
jsonUnsigned(
|
||||
live_summary, "maximum_video_frame_bytes").value_or(0U) !=
|
||||
expected_video.size() ||
|
||||
jsonUnsigned(
|
||||
live_summary, "maximum_video_frame_hash").value_or(0U) !=
|
||||
fnv1a64(expected_video.data(), expected_video.size()) ||
|
||||
jsonUnsigned(
|
||||
live_summary, "maximum_video_frame_flags").value_or(0U) !=
|
||||
cmvr::quic_edge::DATAGRAM_FLAG_KEY_FRAME ||
|
||||
jsonUnsigned(
|
||||
live_summary,
|
||||
"maximum_video_capture_timestamp_us").value_or(0U) == 0U ||
|
||||
jsonUnsigned(
|
||||
live_summary, "maximum_audio_frame_sequence").value_or(0U) !=
|
||||
3U ||
|
||||
jsonUnsigned(
|
||||
live_summary, "maximum_audio_frame_track_id").value_or(0U) !=
|
||||
2U ||
|
||||
jsonUnsigned(
|
||||
live_summary, "maximum_audio_frame_bytes").value_or(0U) !=
|
||||
expected_audio.size() ||
|
||||
jsonUnsigned(
|
||||
live_summary, "maximum_audio_frame_hash").value_or(0U) !=
|
||||
fnv1a64(expected_audio.data(), expected_audio.size()) ||
|
||||
jsonUnsigned(
|
||||
live_summary, "maximum_audio_frame_flags").value_or(1U) !=
|
||||
cmvr::quic_edge::DATAGRAM_FLAG_NONE ||
|
||||
jsonUnsigned(
|
||||
live_summary,
|
||||
"maximum_audio_capture_timestamp_us").value_or(0U) == 0U ||
|
||||
!countAtLeast(live_summary, "video_frames_completed", 2U) ||
|
||||
!countAtLeast(live_summary, "audio_frames_completed", 2U) ||
|
||||
!countAtLeast(live_summary, "frame_bytes_completed", 1U) ||
|
||||
jsonUnsigned(live_summary, "protocol_violations").value_or(1U) != 0U) {
|
||||
std::cerr << "gateway summary validation failed: "
|
||||
<< live_summary << '\n';
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Close the client first so the gateway can synchronously drain and join
|
||||
// its bounded worker without retaining a live connection context.
|
||||
service.stop();
|
||||
gateway.stop();
|
||||
if (gateway.hasRuntimeFailure()) {
|
||||
std::cerr << "gateway shutdown failed: " << gateway.lastError() << '\n';
|
||||
return 1;
|
||||
}
|
||||
std::string restart_error;
|
||||
if (gateway.start(&restart_error) || restart_error.empty()) {
|
||||
std::cerr << "single-use gateway unexpectedly restarted\n";
|
||||
return 1;
|
||||
}
|
||||
|
||||
std::cout << "cmvr_quic_msquic_e2e_test: PASS "
|
||||
<< gateway.summaryJson() << '\n';
|
||||
return 0;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char* argv[])
|
||||
{
|
||||
Arguments arguments;
|
||||
if (!parseArguments(argc, argv, &arguments)) {
|
||||
std::cerr << "Usage: " << argv[0]
|
||||
<< " --cert SERVER_CERT.pem --key SERVER_KEY.pem\n";
|
||||
return 2;
|
||||
}
|
||||
try {
|
||||
return run(arguments);
|
||||
} catch (const std::exception& error) {
|
||||
std::cerr << "unexpected E2E exception: " << error.what() << '\n';
|
||||
return 1;
|
||||
} catch (...) {
|
||||
std::cerr << "unexpected non-standard E2E exception\n";
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
341
test/e2e/run_cmvr_es_quic_smoke.py
Normal file
341
test/e2e/run_cmvr_es_quic_smoke.py
Normal file
@ -0,0 +1,341 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run a no-hardware cmvr_es process against the local MsQuic test gateway."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import signal
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--gateway", type=Path, required=True)
|
||||
parser.add_argument("--cmvr-es", type=Path, required=True)
|
||||
parser.add_argument("--cert", type=Path, required=True)
|
||||
parser.add_argument("--key", type=Path, required=True)
|
||||
parser.add_argument("--timeout-seconds", type=float, default=15.0)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def wait_for_json(
|
||||
path: Path, process: subprocess.Popen[str], deadline: float
|
||||
) -> dict[str, object]:
|
||||
last_error: Exception | None = None
|
||||
while time.monotonic() < deadline:
|
||||
if process.poll() is not None:
|
||||
raise RuntimeError(
|
||||
f"gateway exited before publishing {path.name}: "
|
||||
f"returncode={process.returncode}"
|
||||
)
|
||||
if path.is_file():
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as error:
|
||||
last_error = error
|
||||
time.sleep(0.02)
|
||||
raise TimeoutError(
|
||||
f"timed out waiting for {path}"
|
||||
+ (f": {last_error}" if last_error else "")
|
||||
)
|
||||
|
||||
|
||||
def stop_process(process: subprocess.Popen[str] | None) -> int | None:
|
||||
if process is None:
|
||||
return None
|
||||
if process.poll() is not None:
|
||||
return process.returncode
|
||||
process.send_signal(signal.SIGTERM)
|
||||
try:
|
||||
process.wait(timeout=3.0)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait(timeout=3.0)
|
||||
return process.returncode
|
||||
|
||||
|
||||
def write_runtime_config(config_dir: Path, port: int) -> Path:
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
(config_dir / "cmvr_es.pb.txt").write_text(
|
||||
"""
|
||||
cmvr_es {
|
||||
logger_config_file: "logger.pb.txt"
|
||||
device_manager_config_file: "device_manager.pb.txt"
|
||||
task_manager_config_file: "task_manager.pb.txt"
|
||||
}
|
||||
""".lstrip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(config_dir / "logger.pb.txt").write_text(
|
||||
"""
|
||||
logger {
|
||||
minimum_level: LOG_LEVEL_INFO
|
||||
routes { level: LOG_LEVEL_INFO terminal: true }
|
||||
routes { level: LOG_LEVEL_WARNING terminal: true }
|
||||
routes { level: LOG_LEVEL_ERROR terminal: true }
|
||||
routes { level: LOG_LEVEL_FATAL terminal: true }
|
||||
max_file_size_mb: 1
|
||||
flush_interval_seconds: 1
|
||||
format {
|
||||
show_time: false
|
||||
show_level: true
|
||||
show_thread_id: false
|
||||
show_source_location: true
|
||||
}
|
||||
}
|
||||
""".lstrip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(config_dir / "device_manager.pb.txt").write_text(
|
||||
"""
|
||||
device_manager {
|
||||
name: "cmvr-quic-process-smoke"
|
||||
version: "test"
|
||||
description: "no-hardware QUIC process smoke test"
|
||||
devices {
|
||||
id: "disabled-smoke-camera"
|
||||
type: DEVICE_TYPE_CAMERA
|
||||
enable: false
|
||||
}
|
||||
devices {
|
||||
id: "disabled-smoke-microphone"
|
||||
type: DEVICE_TYPE_MICROPHONE
|
||||
enable: false
|
||||
}
|
||||
devices {
|
||||
id: "enabled-missing-camera"
|
||||
type: DEVICE_TYPE_CAMERA
|
||||
enable: true
|
||||
}
|
||||
}
|
||||
""".lstrip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(config_dir / "task_manager.pb.txt").write_text(
|
||||
"""
|
||||
task_manager {
|
||||
tasks {
|
||||
id: "quic_edge"
|
||||
type: TASK_TYPE_QUIC_EDGE
|
||||
run_mode: TASK_RUN_MODE_BLOCKING_SERVICE
|
||||
config_file: "quic_edge.pb.txt"
|
||||
enable: true
|
||||
}
|
||||
}
|
||||
""".lstrip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(config_dir / "quic_edge.pb.txt").write_text(
|
||||
f"""
|
||||
quic_edge {{
|
||||
id: "quic_edge"
|
||||
enable: true
|
||||
server_host: "127.0.0.1"
|
||||
server_port: {port}
|
||||
alpn: "cmvr-quic-edge/1"
|
||||
node_id: "cmvr-process-smoke"
|
||||
software_version: "test"
|
||||
grpc_endpoint_host: "127.0.0.1"
|
||||
grpc_endpoint_port: 50052
|
||||
grpc_endpoint_tls: false
|
||||
include_loopback_interfaces: true
|
||||
heartbeat_interval_ms: 250
|
||||
control_response_timeout_ms: 1000
|
||||
tls {{ allow_insecure: true }}
|
||||
reconnect {{
|
||||
initial_delay_ms: 50
|
||||
maximum_delay_ms: 250
|
||||
multiplier: 2.0
|
||||
jitter_percent: 0
|
||||
connect_timeout_ms: 2000
|
||||
}}
|
||||
maximum_datagram_bytes: 1200
|
||||
maximum_control_frame_bytes: 1048576
|
||||
maximum_frame_bytes: 16384
|
||||
datagram_send_queue_depth: 64
|
||||
media_poll_interval_ms: 2
|
||||
}}
|
||||
""".lstrip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return config_dir / "cmvr_es.pb.txt"
|
||||
|
||||
|
||||
def require_count(summary: dict[str, object], key: str, minimum: int) -> None:
|
||||
value = summary.get(key)
|
||||
if not isinstance(value, int) or value < minimum:
|
||||
raise RuntimeError(
|
||||
f"gateway summary {key}={value!r}, expected at least {minimum}"
|
||||
)
|
||||
|
||||
|
||||
def run() -> int:
|
||||
args = parse_args()
|
||||
for path in (args.gateway, args.cmvr_es, args.cert, args.key):
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(path)
|
||||
|
||||
deadline = time.monotonic() + args.timeout_seconds
|
||||
gateway_process: subprocess.Popen[str] | None = None
|
||||
edge_process: subprocess.Popen[str] | None = None
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="cmvr-es-quic-smoke-") as temp:
|
||||
temp_dir = Path(temp)
|
||||
ready_file = temp_dir / "ready.json"
|
||||
summary_file = temp_dir / "summary.json"
|
||||
gateway_log = temp_dir / "gateway.log"
|
||||
edge_log = temp_dir / "cmvr_es.log"
|
||||
|
||||
try:
|
||||
with gateway_log.open("w", encoding="utf-8") as gateway_output:
|
||||
gateway_process = subprocess.Popen(
|
||||
[
|
||||
str(args.gateway),
|
||||
"--bind",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
"0",
|
||||
"--cert",
|
||||
str(args.cert),
|
||||
"--key",
|
||||
str(args.key),
|
||||
"--heartbeat-interval-ms",
|
||||
# A zero response keeps the edge-side value below,
|
||||
# proving heartbeat_interval_ms is configurable.
|
||||
"0",
|
||||
"--ready-file",
|
||||
str(ready_file),
|
||||
"--summary-file",
|
||||
str(summary_file),
|
||||
"--exit-after-heartbeats",
|
||||
# The production client keeps only one heartbeat
|
||||
# outstanding. Receiving heartbeat 3 therefore proves
|
||||
# that ACKs 1 and 2 were processed by cmvr_es.
|
||||
"3",
|
||||
],
|
||||
stdout=gateway_output,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
)
|
||||
ready = wait_for_json(ready_file, gateway_process, deadline)
|
||||
port = ready.get("port")
|
||||
if not isinstance(port, int) or not 0 < port <= 65535:
|
||||
raise RuntimeError(f"invalid gateway ready payload: {ready}")
|
||||
|
||||
root_config = write_runtime_config(temp_dir / "config", port)
|
||||
with edge_log.open("w", encoding="utf-8") as edge_output:
|
||||
edge_process = subprocess.Popen(
|
||||
[str(args.cmvr_es), str(root_config)],
|
||||
stdout=edge_output,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
)
|
||||
remaining = max(0.1, deadline - time.monotonic())
|
||||
gateway_returncode = gateway_process.wait(timeout=remaining)
|
||||
if gateway_returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"gateway exited with {gateway_returncode}"
|
||||
)
|
||||
|
||||
edge_returncode = stop_process(edge_process)
|
||||
edge_process = None
|
||||
if edge_returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"cmvr_es exited with {edge_returncode}"
|
||||
)
|
||||
|
||||
summary = json.loads(summary_file.read_text(encoding="utf-8"))
|
||||
if summary.get("runtime_failed") is not False:
|
||||
raise RuntimeError(f"gateway runtime failure: {summary}")
|
||||
require_count(summary, "registrations_accepted", 1)
|
||||
require_count(summary, "heartbeats_received", 3)
|
||||
require_count(summary, "heartbeat_acks_sent", 3)
|
||||
require_count(summary, "registration_interface_count", 1)
|
||||
require_count(summary, "heartbeat_interface_count", 1)
|
||||
if summary.get("heartbeat_has_device_manager") is not True:
|
||||
raise RuntimeError(
|
||||
f"DeviceManager snapshot was not received: {summary}"
|
||||
)
|
||||
if (
|
||||
summary.get("heartbeat_device_manager_name")
|
||||
!= "cmvr-quic-process-smoke"
|
||||
or summary.get("heartbeat_device_manager_version") != "test"
|
||||
or summary.get("heartbeat_device_count") != 1
|
||||
or summary.get("heartbeat_enabled_device_count") != 1
|
||||
or summary.get("heartbeat_disabled_device_count") != 0
|
||||
or summary.get("heartbeat_error_device_count") != 1
|
||||
or summary.get("heartbeat_unknown_health_device_count") != 1
|
||||
):
|
||||
raise RuntimeError(
|
||||
f"unexpected DeviceManager heartbeat snapshot: {summary}"
|
||||
)
|
||||
devices = summary.get("heartbeat_devices")
|
||||
if not isinstance(devices, list) or len(devices) != 1:
|
||||
raise RuntimeError(
|
||||
f"heartbeat device rows are missing: {summary}"
|
||||
)
|
||||
row = devices[0]
|
||||
if (
|
||||
not isinstance(row, dict)
|
||||
or row.get("device_id") != "enabled-missing-camera"
|
||||
or row.get("kind") != 5
|
||||
or row.get("type_name") != "Camera"
|
||||
or row.get("enabled") is not True
|
||||
or row.get("manager_state") != 7
|
||||
or row.get("health") != 0
|
||||
or row.get("has_error") is not True
|
||||
or row.get("error_message") != "device creation failed"
|
||||
or not isinstance(
|
||||
row.get("status_updated_at_unix_ms"), int
|
||||
)
|
||||
or row["status_updated_at_unix_ms"] <= 0
|
||||
):
|
||||
raise RuntimeError(
|
||||
f"unexpected heartbeat device row: {row!r}"
|
||||
)
|
||||
if summary.get("protocol_violations") != 0:
|
||||
raise RuntimeError(f"protocol violation: {summary}")
|
||||
if summary.get("last_node_id") != "cmvr-process-smoke":
|
||||
raise RuntimeError(f"unexpected registered node: {summary}")
|
||||
if (
|
||||
summary.get("last_grpc_endpoint_host") != "127.0.0.1"
|
||||
or summary.get("last_grpc_endpoint_port") != 50052
|
||||
or summary.get("last_observed_source_ip") != "127.0.0.1"
|
||||
):
|
||||
raise RuntimeError(
|
||||
f"IP/gRPC endpoint report was not observed: {summary}"
|
||||
)
|
||||
|
||||
edge_output_text = edge_log.read_text(encoding="utf-8")
|
||||
if "[QuicEdgeTask] Started" not in edge_output_text:
|
||||
raise RuntimeError(
|
||||
"cmvr_es did not start the QUIC task:\n" + edge_output_text
|
||||
)
|
||||
if "CMVR_HAS_MSQUIC is not compiled" in edge_output_text:
|
||||
raise RuntimeError(
|
||||
"cmvr_es used the unavailable QUIC stub:\n"
|
||||
+ edge_output_text
|
||||
)
|
||||
|
||||
print(
|
||||
"cmvr_es_quic_process_smoke_test: PASS "
|
||||
+ json.dumps(summary, sort_keys=True)
|
||||
)
|
||||
return 0
|
||||
except Exception:
|
||||
if gateway_log.is_file():
|
||||
print("gateway log:\n" + gateway_log.read_text(encoding="utf-8"))
|
||||
if edge_log.is_file():
|
||||
print("cmvr_es log:\n" + edge_log.read_text(encoding="utf-8"))
|
||||
raise
|
||||
finally:
|
||||
stop_process(edge_process)
|
||||
stop_process(gateway_process)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(run())
|
||||
79
test/quic_gateway/CMakeLists.txt
Normal file
79
test/quic_gateway/CMakeLists.txt
Normal file
@ -0,0 +1,79 @@
|
||||
if(NOT TARGET MsQuic::msquic)
|
||||
message(FATAL_ERROR
|
||||
"cmvr_quic_test_gateway requires the repository-local MsQuic target")
|
||||
endif()
|
||||
if(NOT TARGET cmvr_es::proto)
|
||||
message(FATAL_ERROR "cmvr_quic_test_gateway requires cmvr_es::proto")
|
||||
endif()
|
||||
if(NOT TARGET cmvr_es::quic_edge_service)
|
||||
message(FATAL_ERROR
|
||||
"cmvr_quic_test_gateway requires cmvr_es::quic_edge_service")
|
||||
endif()
|
||||
|
||||
find_package(Threads REQUIRED)
|
||||
|
||||
add_library(cmvr_quic_test_gateway_lib STATIC
|
||||
src/quic_test_gateway.cpp
|
||||
src/media_reassembler.cpp
|
||||
)
|
||||
add_library(cmvr_es::quic_test_gateway ALIAS cmvr_quic_test_gateway_lib)
|
||||
target_compile_features(cmvr_quic_test_gateway_lib PUBLIC cxx_std_17)
|
||||
target_include_directories(cmvr_quic_test_gateway_lib
|
||||
PUBLIC
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/include
|
||||
PRIVATE
|
||||
${PROJECT_SOURCE_DIR}/cmvr-es
|
||||
)
|
||||
target_link_libraries(cmvr_quic_test_gateway_lib
|
||||
PUBLIC
|
||||
MsQuic::msquic
|
||||
cmvr_es::proto
|
||||
cmvr_es::quic_edge_service
|
||||
Threads::Threads
|
||||
)
|
||||
|
||||
add_executable(cmvr_quic_test_gateway src/main.cpp)
|
||||
target_compile_features(cmvr_quic_test_gateway PRIVATE cxx_std_17)
|
||||
target_link_libraries(cmvr_quic_test_gateway
|
||||
PRIVATE cmvr_es::quic_test_gateway)
|
||||
|
||||
configure_file(
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/run_gateway.sh.in"
|
||||
"${CMAKE_CURRENT_BINARY_DIR}/run_cmvr_quic_test_gateway"
|
||||
@ONLY)
|
||||
file(CHMOD "${CMAKE_CURRENT_BINARY_DIR}/run_cmvr_quic_test_gateway"
|
||||
PERMISSIONS
|
||||
OWNER_READ OWNER_WRITE OWNER_EXECUTE
|
||||
GROUP_READ GROUP_EXECUTE
|
||||
WORLD_READ WORLD_EXECUTE)
|
||||
|
||||
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
|
||||
target_compile_options(cmvr_quic_test_gateway_lib
|
||||
PRIVATE -Wall -Wextra -Wpedantic)
|
||||
target_compile_options(cmvr_quic_test_gateway
|
||||
PRIVATE -Wall -Wextra -Wpedantic)
|
||||
endif()
|
||||
|
||||
if(BUILD_TESTING)
|
||||
add_executable(cmvr_quic_media_reassembler_test
|
||||
tests/media_reassembler_test.cpp)
|
||||
target_compile_features(cmvr_quic_media_reassembler_test PRIVATE cxx_std_17)
|
||||
target_link_libraries(cmvr_quic_media_reassembler_test
|
||||
PRIVATE cmvr_es::quic_test_gateway)
|
||||
add_test(
|
||||
NAME cmvr_quic_media_reassembler_test
|
||||
COMMAND cmvr_quic_media_reassembler_test)
|
||||
set_tests_properties(cmvr_quic_media_reassembler_test
|
||||
PROPERTIES
|
||||
LABELS "quic;unit"
|
||||
TIMEOUT 10
|
||||
ENVIRONMENT
|
||||
"LD_LIBRARY_PATH=${CMVR_QUIC_TEST_LIBRARY_PATH}")
|
||||
endif()
|
||||
|
||||
option(CMVR_INSTALL_QUIC_TEST_GATEWAY
|
||||
"Install the development-only QUIC test gateway into output/bin"
|
||||
OFF)
|
||||
if(CMVR_INSTALL_QUIC_TEST_GATEWAY)
|
||||
install(TARGETS cmvr_quic_test_gateway RUNTIME DESTINATION bin)
|
||||
endif()
|
||||
305
test/quic_gateway/README.md
Normal file
305
test/quic_gateway/README.md
Normal file
@ -0,0 +1,305 @@
|
||||
# CMVR QUIC test Gateway
|
||||
|
||||
`cmvr_quic_test_gateway` 是开发和验收工具,不是平台端生产 Gateway。它在本机
|
||||
监听真实的 MsQuic/TLS/UDP 连接,用来验证 `cmvr_es` 的 QUIC edge v1 客户端。
|
||||
|
||||
测试链路:
|
||||
|
||||
```text
|
||||
output/bin/cmvr_es
|
||||
├── reliable QUIC stream ──> cmvr_quic_test_gateway
|
||||
│ register / heartbeat / media metadata
|
||||
└── QUIC DATAGRAM ─────────> bounded media reassembler
|
||||
```
|
||||
|
||||
Gateway 使用生产代码中的:
|
||||
|
||||
- `EdgeControlEnvelope` protobuf;
|
||||
- `ControlFrameEncoder` / `ControlFrameDecoder`;
|
||||
- `DatagramPacketizer::decodeHeader`;
|
||||
- `CMQD` v1 常量和类型。
|
||||
|
||||
它不会实现 WebTransport、WebRTC、HTTP API、节点数据库或浏览器播放。
|
||||
|
||||
## 构建
|
||||
|
||||
该目录的 `CMakeLists.txt` 预期由项目根 CMake 在
|
||||
`CMVR_BUILD_QUIC_TEST_GATEWAY=ON && CMVR_HAS_MSQUIC` 时加入;该开关默认跟随
|
||||
`BUILD_TESTING`。依赖目标为:
|
||||
|
||||
- `MsQuic::msquic`
|
||||
- `cmvr_es::proto`
|
||||
- `cmvr_es::quic_edge_service`
|
||||
- `Threads::Threads`
|
||||
|
||||
示例:
|
||||
|
||||
```bash
|
||||
cmake -S . -B build \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DCMVR_ARCH=x86 \
|
||||
-DCMVR_ENABLE_MSQUIC_BACKEND=ON \
|
||||
-DCMVR_REQUIRE_MSQUIC=ON \
|
||||
-DCMVR_ALLOW_SYSTEM_MSQUIC=OFF \
|
||||
-DBUILD_TESTING=ON
|
||||
|
||||
cmake --build build --target cmvr_quic_test_gateway -j2
|
||||
```
|
||||
|
||||
默认只生成 build-tree 测试程序,不污染部署目录。如确实希望一同安装:
|
||||
|
||||
```bash
|
||||
cmake -S . -B build \
|
||||
-DCMVR_INSTALL_QUIC_TEST_GATEWAY=ON
|
||||
cmake --build build --target cmvr_quic_test_gateway -j2
|
||||
cmake --install build
|
||||
```
|
||||
|
||||
安装位置是 `output/bin/cmvr_quic_test_gateway`,可复用
|
||||
`output/lib/libmsquic.so` 和现有相对 RUNPATH。
|
||||
|
||||
## 生成本地测试证书
|
||||
|
||||
QUIC 即使使用“不校验证书”的 client 模式,Server 仍必须提供 TLS 证书。
|
||||
不要提交真实私钥。下面命令只在 build 目录生成 loopback 测试证书:
|
||||
|
||||
```bash
|
||||
mkdir -p build/test/quic_gateway/certs
|
||||
|
||||
openssl req -x509 -newkey rsa:2048 -nodes \
|
||||
-keyout build/test/quic_gateway/certs/server.key \
|
||||
-out build/test/quic_gateway/certs/server.crt \
|
||||
-days 7 \
|
||||
-subj "/CN=127.0.0.1" \
|
||||
-addext "subjectAltName=IP:127.0.0.1"
|
||||
```
|
||||
|
||||
MsQuic/OpenSSL 当前要求这里的 PEM 私钥不带密码。
|
||||
|
||||
## 启动
|
||||
|
||||
固定端口适合人工联调。构建树中请使用 CMake 生成的启动包装器;它会补齐
|
||||
仓库内 gRPC 等传递动态库的搜索路径:
|
||||
|
||||
```bash
|
||||
build/test/quic_gateway/run_cmvr_quic_test_gateway \
|
||||
--bind 127.0.0.1 \
|
||||
--port 4433 \
|
||||
--cert build/test/quic_gateway/certs/server.crt \
|
||||
--key build/test/quic_gateway/certs/server.key \
|
||||
--scenario normal \
|
||||
--summary-file build/test/quic_gateway/summary.json
|
||||
```
|
||||
|
||||
自动化测试应使用 `--port 0` 避免端口冲突。Gateway 会查询 Listener 实际端口,
|
||||
在 stdout 和 `--ready-file` 写入一个 JSON 对象:
|
||||
|
||||
```bash
|
||||
build/test/quic_gateway/run_cmvr_quic_test_gateway \
|
||||
--bind 127.0.0.1 \
|
||||
--port 0 \
|
||||
--cert build/test/quic_gateway/certs/server.crt \
|
||||
--key build/test/quic_gateway/certs/server.key \
|
||||
--ready-file build/test/quic_gateway/ready.json \
|
||||
--summary-file build/test/quic_gateway/summary.json \
|
||||
--exit-after-heartbeats 3
|
||||
```
|
||||
|
||||
Ready 文件示例:
|
||||
|
||||
```json
|
||||
{"event":"ready","bind":"127.0.0.1:52319","port":52319,"alpn":"cmvr-quic-edge/1","scenario":"normal","datagram_enabled":true}
|
||||
```
|
||||
|
||||
自动 runner 读取 `port`,将它写入临时 `quic_edge_task.pb.txt` 后再启动
|
||||
`cmvr_es`。不要直接改唯一一份 `output/bin/config/`;普通安装默认会覆盖它。
|
||||
只更新程序与运行库时,应在配置构建目录时设置
|
||||
`-DCMVR_INSTALL_DEFAULT_RUNTIME_ASSETS=OFF`。
|
||||
|
||||
## cmvr_es 本机配置
|
||||
|
||||
第一轮只测试注册、IP 上报和心跳,保持设备和 `tracks` 关闭:
|
||||
|
||||
```protobuf
|
||||
quic_edge {
|
||||
id: "quic_edge"
|
||||
enable: true
|
||||
server_host: "127.0.0.1"
|
||||
server_port: 4433
|
||||
alpn: "cmvr-quic-edge/1"
|
||||
node_id: "local-quic-test"
|
||||
software_version: "test"
|
||||
|
||||
grpc_endpoint_host: "127.0.0.1"
|
||||
grpc_endpoint_port: 50052
|
||||
grpc_endpoint_tls: false
|
||||
include_loopback_interfaces: true
|
||||
|
||||
heartbeat_interval_ms: 1000
|
||||
control_response_timeout_ms: 1000
|
||||
|
||||
tls {
|
||||
allow_insecure: true
|
||||
}
|
||||
|
||||
reconnect {
|
||||
initial_delay_ms: 100
|
||||
maximum_delay_ms: 1000
|
||||
multiplier: 2.0
|
||||
jitter_percent: 0
|
||||
connect_timeout_ms: 3000
|
||||
}
|
||||
|
||||
maximum_datagram_bytes: 1200
|
||||
maximum_control_frame_bytes: 1048576
|
||||
maximum_frame_bytes: 524288
|
||||
datagram_send_queue_depth: 512
|
||||
media_poll_interval_ms: 2
|
||||
}
|
||||
```
|
||||
|
||||
TaskManager 中的 `quic_edge` entry 也必须 `enable: true`。然后运行一份独立的
|
||||
临时配置树:
|
||||
|
||||
```bash
|
||||
output/bin/cmvr_es /tmp/cmvr-quic-e2e/config/cmvr_es.pb.txt
|
||||
```
|
||||
|
||||
## 场景
|
||||
|
||||
`--scenario` 支持:
|
||||
|
||||
当前 CTest 自动回归仅运行 `normal` 场景;其余场景保留为手工故障注入和验收入口,
|
||||
尚未纳入自动回归。
|
||||
|
||||
| 名称 | 行为 | 预期 Edge 行为 |
|
||||
| --- | --- | --- |
|
||||
| `normal` | 接受注册并精确 ACK 心跳 | 保持同一连接 |
|
||||
| `reject-registration` | 返回 `accepted=false` | 退避后重新注册 |
|
||||
| `drop-heartbeat-ack` | 接收但不回复心跳 | response timeout 后重连 |
|
||||
| `wrong-ack-session` | ACK 使用错误 session ID | 判定协议错误并重连 |
|
||||
| `fatal-protocol-error` | 注册成功后发送 fatal `ProtocolError` | 重连 |
|
||||
| `nonfatal-protocol-error` | 注册成功后发送 nonfatal `ProtocolError` | 保持连接并继续心跳 |
|
||||
| `datagram-disabled` | 不协商 QUIC DATAGRAM | 注册和心跳正常,媒体不发送 |
|
||||
|
||||
可以用以下条件让程序自动成功退出:
|
||||
|
||||
- `--exit-after-registrations N`
|
||||
- `--exit-after-heartbeats N`
|
||||
- `--run-for-ms N`
|
||||
|
||||
收到 `SIGINT` 或 `SIGTERM` 时,Gateway 会停止 Listener、关闭活动连接、排空工作
|
||||
队列并写 summary。
|
||||
|
||||
## 协议验证
|
||||
|
||||
每个连接独立维护:
|
||||
|
||||
- control framing buffer;
|
||||
- Edge 和 Gateway 各自的严格递增 `message_sequence`;
|
||||
- node、boot、registration session;
|
||||
- media session epoch;
|
||||
- track descriptor generation token;
|
||||
- DATAGRAM 重组缓存。
|
||||
|
||||
Gateway 要求:
|
||||
|
||||
1. `NodeRegisterRequest` 是第一条应用消息;
|
||||
2. protocol version 为 1;
|
||||
3. 后续 envelope sequence 严格增加;
|
||||
4. heartbeat 的 node、boot 和 session 与注册一致;
|
||||
5. media session 先于 descriptor;
|
||||
6. DATAGRAM 的 epoch、track、kind 和 generation token 与可靠元数据一致。
|
||||
|
||||
`observed_source_ip` 从 MsQuic peer address 获取,不从 Edge 上报字段复制。
|
||||
Heartbeat 中存在 `device_manager` 时,Gateway 会记录 Manager 元数据以及设备
|
||||
总数、启用数、禁用数、确认异常数和未知健康数;该字段是 v1 的兼容性追加项,
|
||||
并在最终 summary 的 `heartbeat_devices` 数组中保留最近一次完整设备行。测试
|
||||
Gateway 仍接受没有该字段的旧 Edge。当前 Edge 只发送已启用设备,因此正常情况
|
||||
下禁用数为 0;该统计仍用于兼容旧发送端和发现协议违规。
|
||||
summary 还会保留每类媒体最近一次 descriptor,以及已完成帧中的最大
|
||||
`frame_sequence`、长度、flags、采集时间戳和 FNV-1a 64 位载荷哈希。这里的哈希
|
||||
只用于测试中精确比对字节,不用于安全认证。真实 E2E 会先发送 discovery 帧并等
|
||||
descriptor 到达,再发送 priming/validation 帧,从而避免 reliable stream 与
|
||||
DATAGRAM 跨通道乱序造成偶发误判。
|
||||
|
||||
## DATAGRAM 安全边界
|
||||
|
||||
MsQuic callback 只复制收到的数据并进入有界队列;控制消息使用高优先级队列,
|
||||
不会被媒体洪峰长期阻塞。后台线程完成 protobuf 处理和媒体重组。
|
||||
|
||||
重组 key 是:
|
||||
|
||||
```text
|
||||
(session_epoch, track_id, frame_sequence)
|
||||
```
|
||||
|
||||
接收器限制:
|
||||
|
||||
- 总缓存字节;
|
||||
- 在途帧数;
|
||||
- 单帧大小;
|
||||
- 单帧最多 8192 个分片;
|
||||
- 不完整帧超时;
|
||||
- fragment index 冲突;
|
||||
- byte range 重叠;
|
||||
- 新 session epoch 清理旧分片。
|
||||
|
||||
相关 CLI:
|
||||
|
||||
```text
|
||||
--max-reassembly-bytes
|
||||
--max-reassembly-frames
|
||||
--max-frame-bytes
|
||||
--max-work-queue-bytes
|
||||
--reassembly-timeout-ms
|
||||
```
|
||||
|
||||
## 输出与判定
|
||||
|
||||
stdout 每行都是一个 JSON 对象或 JSON-compatible 单行事件。最终 summary 包含:
|
||||
|
||||
- connection/register/heartbeat 计数;
|
||||
- 注册和最近一次心跳上报的网卡数量、gRPC endpoint 与对端源 IP;
|
||||
- 最近一次 DeviceManager 名称/版本/描述,以及已上报设备总数、启用数、兼容性
|
||||
禁用数、确认异常数、未知健康数和 `heartbeat_devices` 完整设备行;
|
||||
- ACK、故障场景和协议错误计数;
|
||||
- media session/descriptor/DATAGRAM 计数;
|
||||
- 完整帧(含视频/音频分项)、无效包、队列丢包;
|
||||
- 超时、容量淘汰和 session 清理的不完整帧。
|
||||
|
||||
自动化脚本至少应检查:
|
||||
|
||||
```text
|
||||
runtime_failed == false
|
||||
registrations_accepted >= 1 # normal 场景
|
||||
heartbeats_received >= 1
|
||||
heartbeat_acks_sent >= 1
|
||||
protocol_violations == 0
|
||||
```
|
||||
|
||||
媒体场景还应检查:
|
||||
|
||||
```text
|
||||
media_sessions_opened >= 1
|
||||
track_descriptors_received >= 1
|
||||
datagrams_received >= 1
|
||||
frames_completed >= 1
|
||||
```
|
||||
|
||||
## 无设备主机的限制
|
||||
|
||||
关闭全部设备时,实际 `output/bin/cmvr_es` 可以完整验证 TLS、ALPN、控制 stream、
|
||||
注册、IP 上报、心跳和重连,但不会产生音视频。
|
||||
|
||||
要在无硬件环境验证媒体 DATAGRAM,测试侧还需要 synthetic MediaSourceHub producer
|
||||
或测试专用 fake camera/microphone。该 Gateway 已具备媒体接收和重组能力,但不会
|
||||
伪造 Edge 发出的媒体。
|
||||
|
||||
## 安全说明
|
||||
|
||||
- 仅监听 loopback 是默认值;
|
||||
- `allow_insecure` 仅限本机开发;
|
||||
- 不要将测试 Gateway 暴露到不受信任网络;
|
||||
- 不要复用或提交生产证书、生产私钥;
|
||||
- 该工具没有生产级认证、授权、持久化和多租户隔离。
|
||||
103
test/quic_gateway/include/media_reassembler.h
Normal file
103
test/quic_gateway/include/media_reassembler.h
Normal file
@ -0,0 +1,103 @@
|
||||
#ifndef CMVR_ES_TEST_QUIC_GATEWAY_MEDIA_REASSEMBLER_H
|
||||
#define CMVR_ES_TEST_QUIC_GATEWAY_MEDIA_REASSEMBLER_H
|
||||
|
||||
#include <chrono>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
#include "service/quic_edge/include/quic_edge_types.h"
|
||||
|
||||
namespace cmvr::test::quic_gateway {
|
||||
|
||||
struct ReassembledFrame {
|
||||
cmvr::quic_edge::DatagramHeader header;
|
||||
std::vector<std::uint8_t> payload;
|
||||
};
|
||||
|
||||
struct ReassemblyResult {
|
||||
enum class Status {
|
||||
ACCEPTED,
|
||||
DUPLICATE,
|
||||
COMPLETED,
|
||||
INVALID,
|
||||
CAPACITY_DROPPED,
|
||||
};
|
||||
|
||||
Status status{Status::INVALID};
|
||||
std::optional<ReassembledFrame> completed_frame;
|
||||
std::uint64_t expired_frames{0};
|
||||
std::uint64_t evicted_frames{0};
|
||||
};
|
||||
|
||||
class MediaReassembler {
|
||||
public:
|
||||
MediaReassembler(std::size_t maximum_bytes,
|
||||
std::size_t maximum_frames,
|
||||
std::size_t maximum_frame_bytes,
|
||||
std::chrono::milliseconds timeout);
|
||||
|
||||
std::uint64_t reset(std::uint64_t session_epoch);
|
||||
|
||||
ReassemblyResult accept(
|
||||
const cmvr::quic_edge::DatagramHeader& header,
|
||||
const std::uint8_t* payload,
|
||||
std::size_t payload_size,
|
||||
std::chrono::steady_clock::time_point now);
|
||||
|
||||
std::uint64_t clear();
|
||||
std::size_t bufferedBytes() const { return buffered_bytes_; }
|
||||
std::size_t inFlightFrames() const { return frames_.size(); }
|
||||
std::uint64_t sessionEpoch() const { return session_epoch_; }
|
||||
|
||||
private:
|
||||
struct FrameKey {
|
||||
std::uint64_t session_epoch{0};
|
||||
std::uint32_t track_id{0};
|
||||
std::uint64_t frame_sequence{0};
|
||||
|
||||
bool operator==(const FrameKey& other) const
|
||||
{
|
||||
return session_epoch == other.session_epoch &&
|
||||
track_id == other.track_id &&
|
||||
frame_sequence == other.frame_sequence;
|
||||
}
|
||||
};
|
||||
|
||||
struct FrameKeyHash {
|
||||
std::size_t operator()(const FrameKey& key) const;
|
||||
};
|
||||
|
||||
struct Fragment {
|
||||
std::uint32_t offset{0};
|
||||
std::vector<std::uint8_t> payload;
|
||||
};
|
||||
|
||||
struct PartialFrame {
|
||||
cmvr::quic_edge::DatagramHeader first_header;
|
||||
std::unordered_map<std::uint16_t, Fragment> fragments;
|
||||
std::size_t received_bytes{0};
|
||||
std::chrono::steady_clock::time_point created_at;
|
||||
std::chrono::steady_clock::time_point updated_at;
|
||||
};
|
||||
|
||||
std::uint64_t expire(std::chrono::steady_clock::time_point now);
|
||||
std::uint64_t evictOldest();
|
||||
std::uint64_t evictOldestExcept(const FrameKey& protected_key);
|
||||
void eraseFrame(
|
||||
std::unordered_map<FrameKey, PartialFrame, FrameKeyHash>::iterator it);
|
||||
|
||||
std::size_t maximum_bytes_;
|
||||
std::size_t maximum_frames_;
|
||||
std::size_t maximum_frame_bytes_;
|
||||
std::chrono::milliseconds timeout_;
|
||||
std::uint64_t session_epoch_{0};
|
||||
std::size_t buffered_bytes_{0};
|
||||
std::unordered_map<FrameKey, PartialFrame, FrameKeyHash> frames_;
|
||||
};
|
||||
|
||||
} // namespace cmvr::test::quic_gateway
|
||||
|
||||
#endif // CMVR_ES_TEST_QUIC_GATEWAY_MEDIA_REASSEMBLER_H
|
||||
72
test/quic_gateway/include/quic_test_gateway.h
Normal file
72
test/quic_gateway/include/quic_test_gateway.h
Normal file
@ -0,0 +1,72 @@
|
||||
#ifndef CMVR_ES_TEST_QUIC_GATEWAY_QUIC_TEST_GATEWAY_H
|
||||
#define CMVR_ES_TEST_QUIC_GATEWAY_QUIC_TEST_GATEWAY_H
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
namespace cmvr::test::quic_gateway {
|
||||
|
||||
enum class Scenario {
|
||||
NORMAL,
|
||||
REJECT_REGISTRATION,
|
||||
DROP_HEARTBEAT_ACK,
|
||||
WRONG_ACK_SESSION,
|
||||
FATAL_PROTOCOL_ERROR,
|
||||
NONFATAL_PROTOCOL_ERROR,
|
||||
DATAGRAM_DISABLED,
|
||||
};
|
||||
|
||||
const char* scenarioName(Scenario scenario);
|
||||
bool parseScenario(const std::string& text, Scenario* scenario);
|
||||
|
||||
struct GatewayOptions {
|
||||
std::string bind_address{"127.0.0.1"};
|
||||
std::uint16_t port{4433};
|
||||
std::string alpn{"cmvr-quic-edge/1"};
|
||||
std::string certificate_file;
|
||||
std::string private_key_file;
|
||||
std::string ready_file;
|
||||
std::string summary_file;
|
||||
Scenario scenario{Scenario::NORMAL};
|
||||
|
||||
std::uint32_t heartbeat_interval_ms{0};
|
||||
std::size_t maximum_control_frame_bytes{1024U * 1024U};
|
||||
std::size_t maximum_reassembly_bytes{32U * 1024U * 1024U};
|
||||
std::size_t maximum_reassembly_frames{128U};
|
||||
std::size_t maximum_frame_bytes{8U * 1024U * 1024U};
|
||||
std::size_t maximum_work_queue_bytes{8U * 1024U * 1024U};
|
||||
std::uint32_t reassembly_timeout_ms{2000U};
|
||||
|
||||
// Zero disables the corresponding automatic completion condition.
|
||||
std::uint64_t exit_after_registrations{0};
|
||||
std::uint64_t exit_after_heartbeats{0};
|
||||
std::uint64_t run_for_ms{0};
|
||||
};
|
||||
|
||||
class QuicTestGateway {
|
||||
public:
|
||||
explicit QuicTestGateway(GatewayOptions options);
|
||||
~QuicTestGateway();
|
||||
|
||||
QuicTestGateway(const QuicTestGateway&) = delete;
|
||||
QuicTestGateway& operator=(const QuicTestGateway&) = delete;
|
||||
|
||||
bool start(std::string* error);
|
||||
void stop();
|
||||
|
||||
std::uint16_t boundPort() const;
|
||||
bool completionReached() const;
|
||||
bool hasRuntimeFailure() const;
|
||||
std::string lastError() const;
|
||||
std::string summaryJson() const;
|
||||
|
||||
private:
|
||||
class Impl;
|
||||
std::unique_ptr<Impl> impl_;
|
||||
};
|
||||
|
||||
} // namespace cmvr::test::quic_gateway
|
||||
|
||||
#endif // CMVR_ES_TEST_QUIC_GATEWAY_QUIC_TEST_GATEWAY_H
|
||||
10
test/quic_gateway/run_gateway.sh.in
Normal file
10
test/quic_gateway/run_gateway.sh.in
Normal file
@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
cmvr_quic_library_path="@CMVR_QUIC_TEST_LIBRARY_PATH@"
|
||||
if [[ -n "${LD_LIBRARY_PATH:-}" ]]; then
|
||||
cmvr_quic_library_path="${cmvr_quic_library_path}:${LD_LIBRARY_PATH}"
|
||||
fi
|
||||
export LD_LIBRARY_PATH="${cmvr_quic_library_path}"
|
||||
|
||||
exec "@CMAKE_CURRENT_BINARY_DIR@/cmvr_quic_test_gateway" "$@"
|
||||
263
test/quic_gateway/src/main.cpp
Normal file
263
test/quic_gateway/src/main.cpp
Normal file
@ -0,0 +1,263 @@
|
||||
#include "quic_test_gateway.h"
|
||||
|
||||
#include <chrono>
|
||||
#include <csignal>
|
||||
#include <cstdint>
|
||||
#include <iostream>
|
||||
#include <limits>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <utility>
|
||||
|
||||
namespace {
|
||||
|
||||
volatile std::sig_atomic_t g_stop_requested = 0;
|
||||
|
||||
void handleSignal(int)
|
||||
{
|
||||
g_stop_requested = 1;
|
||||
}
|
||||
|
||||
void printUsage(const char* program)
|
||||
{
|
||||
std::cout
|
||||
<< "Usage: " << program << " [options]\n"
|
||||
<< "\nRequired:\n"
|
||||
<< " --cert PATH PEM server certificate\n"
|
||||
<< " --key PATH Unencrypted PEM private key\n"
|
||||
<< "\nEndpoint:\n"
|
||||
<< " --bind ADDRESS Numeric bind address (default 127.0.0.1)\n"
|
||||
<< " --port PORT UDP port; 0 selects a free port (default 4433)\n"
|
||||
<< " --alpn VALUE ALPN (default cmvr-quic-edge/1)\n"
|
||||
<< "\nBehavior:\n"
|
||||
<< " --scenario NAME normal, reject-registration,\n"
|
||||
<< " drop-heartbeat-ack, wrong-ack-session,\n"
|
||||
<< " fatal-protocol-error,\n"
|
||||
<< " nonfatal-protocol-error,\n"
|
||||
<< " datagram-disabled\n"
|
||||
<< " --heartbeat-interval-ms N Registration response override; 0 keeps edge value\n"
|
||||
<< " --exit-after-registrations N Exit after N registration requests\n"
|
||||
<< " --exit-after-heartbeats N Exit after N heartbeat requests\n"
|
||||
<< " --run-for-ms N Exit after N milliseconds\n"
|
||||
<< "\nOutput:\n"
|
||||
<< " --ready-file PATH Write one ready JSON object\n"
|
||||
<< " --summary-file PATH Write final summary JSON\n"
|
||||
<< "\nSafety limits:\n"
|
||||
<< " --max-control-bytes N Maximum protobuf control payload\n"
|
||||
<< " --max-reassembly-bytes N Total buffered media payload bytes\n"
|
||||
<< " --max-reassembly-frames N Maximum incomplete media frames\n"
|
||||
<< " --max-frame-bytes N Maximum declared complete frame size\n"
|
||||
<< " --max-work-queue-bytes N Separate bound for control and DATAGRAM queues\n"
|
||||
<< " --reassembly-timeout-ms N Incomplete-frame timeout\n"
|
||||
<< "\nOther:\n"
|
||||
<< " -h, --help Show this help\n";
|
||||
}
|
||||
|
||||
bool parseUnsigned(const std::string& text,
|
||||
const std::uint64_t maximum,
|
||||
std::uint64_t* value)
|
||||
{
|
||||
if (!value || text.empty() || text.front() == '-') return false;
|
||||
std::size_t consumed = 0U;
|
||||
try {
|
||||
const unsigned long long parsed =
|
||||
std::stoull(text, &consumed, 10);
|
||||
if (consumed != text.size() || parsed > maximum) return false;
|
||||
*value = static_cast<std::uint64_t>(parsed);
|
||||
return true;
|
||||
} catch (...) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool requireValue(int argc,
|
||||
char* argv[],
|
||||
int* index,
|
||||
std::string* value,
|
||||
std::string* error)
|
||||
{
|
||||
if (!index || !value || *index + 1 >= argc) {
|
||||
if (error) {
|
||||
*error = std::string("missing value for ") +
|
||||
(index ? argv[*index] : "option");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
value->assign(argv[++(*index)]);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool parseArguments(int argc,
|
||||
char* argv[],
|
||||
cmvr::test::quic_gateway::GatewayOptions* options,
|
||||
bool* show_help,
|
||||
std::string* error)
|
||||
{
|
||||
if (!options || !show_help) return false;
|
||||
*show_help = false;
|
||||
for (int index = 1; index < argc; ++index) {
|
||||
const std::string argument = argv[index];
|
||||
if (argument == "-h" || argument == "--help") {
|
||||
*show_help = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string value;
|
||||
if (!requireValue(argc, argv, &index, &value, error)) return false;
|
||||
std::uint64_t number = 0U;
|
||||
|
||||
if (argument == "--cert") {
|
||||
options->certificate_file = value;
|
||||
} else if (argument == "--key") {
|
||||
options->private_key_file = value;
|
||||
} else if (argument == "--bind") {
|
||||
options->bind_address = value;
|
||||
} else if (argument == "--alpn") {
|
||||
options->alpn = value;
|
||||
} else if (argument == "--ready-file") {
|
||||
options->ready_file = value;
|
||||
} else if (argument == "--summary-file") {
|
||||
options->summary_file = value;
|
||||
} else if (argument == "--scenario") {
|
||||
if (!cmvr::test::quic_gateway::parseScenario(
|
||||
value, &options->scenario)) {
|
||||
if (error) *error = "unknown scenario: " + value;
|
||||
return false;
|
||||
}
|
||||
} else if (argument == "--port") {
|
||||
if (!parseUnsigned(value, 65535U, &number)) {
|
||||
if (error) *error = "invalid UDP port: " + value;
|
||||
return false;
|
||||
}
|
||||
options->port = static_cast<std::uint16_t>(number);
|
||||
} else if (argument == "--heartbeat-interval-ms") {
|
||||
if (!parseUnsigned(
|
||||
value, std::numeric_limits<std::uint32_t>::max(),
|
||||
&number)) {
|
||||
if (error) *error = "invalid heartbeat interval: " + value;
|
||||
return false;
|
||||
}
|
||||
options->heartbeat_interval_ms =
|
||||
static_cast<std::uint32_t>(number);
|
||||
} else if (argument == "--exit-after-registrations") {
|
||||
if (!parseUnsigned(
|
||||
value, std::numeric_limits<std::uint64_t>::max(),
|
||||
&options->exit_after_registrations)) {
|
||||
if (error) *error = "invalid registration count: " + value;
|
||||
return false;
|
||||
}
|
||||
} else if (argument == "--exit-after-heartbeats") {
|
||||
if (!parseUnsigned(
|
||||
value, std::numeric_limits<std::uint64_t>::max(),
|
||||
&options->exit_after_heartbeats)) {
|
||||
if (error) *error = "invalid heartbeat count: " + value;
|
||||
return false;
|
||||
}
|
||||
} else if (argument == "--run-for-ms") {
|
||||
if (!parseUnsigned(
|
||||
value, std::numeric_limits<std::uint64_t>::max(),
|
||||
&options->run_for_ms)) {
|
||||
if (error) *error = "invalid run duration: " + value;
|
||||
return false;
|
||||
}
|
||||
} else if (argument == "--max-control-bytes") {
|
||||
if (!parseUnsigned(
|
||||
value, std::numeric_limits<std::size_t>::max(),
|
||||
&number) || number == 0U) {
|
||||
if (error) *error = "invalid control limit: " + value;
|
||||
return false;
|
||||
}
|
||||
options->maximum_control_frame_bytes =
|
||||
static_cast<std::size_t>(number);
|
||||
} else if (argument == "--max-reassembly-bytes") {
|
||||
if (!parseUnsigned(
|
||||
value, std::numeric_limits<std::size_t>::max(),
|
||||
&number) || number == 0U) {
|
||||
if (error) *error = "invalid reassembly byte limit: " + value;
|
||||
return false;
|
||||
}
|
||||
options->maximum_reassembly_bytes =
|
||||
static_cast<std::size_t>(number);
|
||||
} else if (argument == "--max-reassembly-frames") {
|
||||
if (!parseUnsigned(
|
||||
value, std::numeric_limits<std::size_t>::max(),
|
||||
&number) || number == 0U) {
|
||||
if (error) *error = "invalid reassembly frame limit: " + value;
|
||||
return false;
|
||||
}
|
||||
options->maximum_reassembly_frames =
|
||||
static_cast<std::size_t>(number);
|
||||
} else if (argument == "--max-frame-bytes") {
|
||||
if (!parseUnsigned(
|
||||
value, std::numeric_limits<std::size_t>::max(),
|
||||
&number) || number == 0U) {
|
||||
if (error) *error = "invalid frame limit: " + value;
|
||||
return false;
|
||||
}
|
||||
options->maximum_frame_bytes = static_cast<std::size_t>(number);
|
||||
} else if (argument == "--max-work-queue-bytes") {
|
||||
if (!parseUnsigned(
|
||||
value, std::numeric_limits<std::size_t>::max(),
|
||||
&number) || number == 0U) {
|
||||
if (error) *error = "invalid work queue limit: " + value;
|
||||
return false;
|
||||
}
|
||||
options->maximum_work_queue_bytes =
|
||||
static_cast<std::size_t>(number);
|
||||
} else if (argument == "--reassembly-timeout-ms") {
|
||||
if (!parseUnsigned(
|
||||
value, std::numeric_limits<std::uint32_t>::max(),
|
||||
&number) || number == 0U) {
|
||||
if (error) *error = "invalid reassembly timeout: " + value;
|
||||
return false;
|
||||
}
|
||||
options->reassembly_timeout_ms =
|
||||
static_cast<std::uint32_t>(number);
|
||||
} else {
|
||||
if (error) *error = "unknown option: " + argument;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char* argv[])
|
||||
{
|
||||
cmvr::test::quic_gateway::GatewayOptions options;
|
||||
bool show_help = false;
|
||||
std::string error;
|
||||
if (!parseArguments(argc, argv, &options, &show_help, &error)) {
|
||||
std::cerr << error << "\n\n";
|
||||
printUsage(argv[0]);
|
||||
return 2;
|
||||
}
|
||||
if (show_help) {
|
||||
printUsage(argv[0]);
|
||||
return 0;
|
||||
}
|
||||
|
||||
std::signal(SIGINT, handleSignal);
|
||||
std::signal(SIGTERM, handleSignal);
|
||||
|
||||
cmvr::test::quic_gateway::QuicTestGateway gateway(std::move(options));
|
||||
if (!gateway.start(&error)) {
|
||||
std::cerr << "Failed to start QUIC test gateway: " << error << '\n';
|
||||
return 2;
|
||||
}
|
||||
|
||||
while (g_stop_requested == 0 &&
|
||||
!gateway.completionReached() &&
|
||||
!gateway.hasRuntimeFailure()) {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(20));
|
||||
}
|
||||
|
||||
gateway.stop();
|
||||
if (gateway.hasRuntimeFailure()) {
|
||||
std::cerr << "QUIC test gateway failed: "
|
||||
<< gateway.lastError() << '\n';
|
||||
return 3;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
258
test/quic_gateway/src/media_reassembler.cpp
Normal file
258
test/quic_gateway/src/media_reassembler.cpp
Normal file
@ -0,0 +1,258 @@
|
||||
#include "media_reassembler.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <utility>
|
||||
|
||||
namespace cmvr::test::quic_gateway {
|
||||
namespace {
|
||||
|
||||
constexpr std::size_t kMaximumFragmentsPerFrame = 8192U;
|
||||
|
||||
bool rangesOverlap(const std::uint32_t left_offset,
|
||||
const std::size_t left_size,
|
||||
const std::uint32_t right_offset,
|
||||
const std::size_t right_size)
|
||||
{
|
||||
const std::uint64_t left_end =
|
||||
static_cast<std::uint64_t>(left_offset) + left_size;
|
||||
const std::uint64_t right_end =
|
||||
static_cast<std::uint64_t>(right_offset) + right_size;
|
||||
return static_cast<std::uint64_t>(left_offset) < right_end &&
|
||||
static_cast<std::uint64_t>(right_offset) < left_end;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
MediaReassembler::MediaReassembler(
|
||||
const std::size_t maximum_bytes,
|
||||
const std::size_t maximum_frames,
|
||||
const std::size_t maximum_frame_bytes,
|
||||
const std::chrono::milliseconds timeout)
|
||||
: maximum_bytes_(std::max<std::size_t>(1U, maximum_bytes)),
|
||||
maximum_frames_(std::max<std::size_t>(1U, maximum_frames)),
|
||||
maximum_frame_bytes_(std::max<std::size_t>(1U, maximum_frame_bytes)),
|
||||
timeout_(std::max(std::chrono::milliseconds(1), timeout))
|
||||
{
|
||||
}
|
||||
|
||||
std::size_t MediaReassembler::FrameKeyHash::operator()(
|
||||
const FrameKey& key) const
|
||||
{
|
||||
std::size_t value = std::hash<std::uint64_t>{}(key.session_epoch);
|
||||
value ^= std::hash<std::uint32_t>{}(key.track_id) +
|
||||
0x9e3779b9U + (value << 6U) + (value >> 2U);
|
||||
value ^= std::hash<std::uint64_t>{}(key.frame_sequence) +
|
||||
0x9e3779b9U + (value << 6U) + (value >> 2U);
|
||||
return value;
|
||||
}
|
||||
|
||||
std::uint64_t MediaReassembler::reset(const std::uint64_t session_epoch)
|
||||
{
|
||||
const std::uint64_t dropped = clear();
|
||||
session_epoch_ = session_epoch;
|
||||
return dropped;
|
||||
}
|
||||
|
||||
ReassemblyResult MediaReassembler::accept(
|
||||
const cmvr::quic_edge::DatagramHeader& header,
|
||||
const std::uint8_t* payload,
|
||||
const std::size_t payload_size,
|
||||
const std::chrono::steady_clock::time_point now)
|
||||
{
|
||||
ReassemblyResult result;
|
||||
result.expired_frames = expire(now);
|
||||
|
||||
if (!payload || payload_size == 0U ||
|
||||
header.session_epoch == 0U ||
|
||||
header.session_epoch != session_epoch_ ||
|
||||
header.payload_size != payload_size ||
|
||||
header.frame_size == 0U ||
|
||||
header.frame_size > maximum_frame_bytes_ ||
|
||||
header.fragment_count == 0U ||
|
||||
header.fragment_count > kMaximumFragmentsPerFrame ||
|
||||
header.fragment_index >= header.fragment_count ||
|
||||
header.fragment_offset > header.frame_size ||
|
||||
payload_size > header.frame_size - header.fragment_offset) {
|
||||
result.status = ReassemblyResult::Status::INVALID;
|
||||
return result;
|
||||
}
|
||||
|
||||
const FrameKey key{
|
||||
header.session_epoch, header.track_id, header.frame_sequence};
|
||||
auto frame_it = frames_.find(key);
|
||||
if (frame_it == frames_.end()) {
|
||||
while (frames_.size() >= maximum_frames_) {
|
||||
result.evicted_frames += evictOldest();
|
||||
}
|
||||
PartialFrame frame;
|
||||
frame.first_header = header;
|
||||
frame.created_at = now;
|
||||
frame.updated_at = now;
|
||||
try {
|
||||
frame_it = frames_.emplace(key, std::move(frame)).first;
|
||||
} catch (...) {
|
||||
result.status = ReassemblyResult::Status::CAPACITY_DROPPED;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
PartialFrame& frame = frame_it->second;
|
||||
if (frame.first_header.fragment_count != header.fragment_count ||
|
||||
frame.first_header.frame_size != header.frame_size ||
|
||||
frame.first_header.kind != header.kind ||
|
||||
frame.first_header.codec_generation != header.codec_generation ||
|
||||
frame.first_header.flags != header.flags ||
|
||||
frame.first_header.capture_timestamp_us !=
|
||||
header.capture_timestamp_us) {
|
||||
eraseFrame(frame_it);
|
||||
result.status = ReassemblyResult::Status::INVALID;
|
||||
return result;
|
||||
}
|
||||
|
||||
const auto duplicate = frame.fragments.find(header.fragment_index);
|
||||
if (duplicate != frame.fragments.end()) {
|
||||
const Fragment& existing = duplicate->second;
|
||||
const bool identical =
|
||||
existing.offset == header.fragment_offset &&
|
||||
existing.payload.size() == payload_size &&
|
||||
std::equal(existing.payload.begin(), existing.payload.end(), payload);
|
||||
result.status = identical
|
||||
? ReassemblyResult::Status::DUPLICATE
|
||||
: ReassemblyResult::Status::INVALID;
|
||||
if (!identical) eraseFrame(frame_it);
|
||||
return result;
|
||||
}
|
||||
|
||||
for (const auto& [index, existing] : frame.fragments) {
|
||||
(void)index;
|
||||
if (rangesOverlap(header.fragment_offset, payload_size,
|
||||
existing.offset, existing.payload.size())) {
|
||||
eraseFrame(frame_it);
|
||||
result.status = ReassemblyResult::Status::INVALID;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
if (payload_size > maximum_bytes_) {
|
||||
eraseFrame(frame_it);
|
||||
result.status = ReassemblyResult::Status::CAPACITY_DROPPED;
|
||||
return result;
|
||||
}
|
||||
while (buffered_bytes_ > maximum_bytes_ - payload_size) {
|
||||
const std::uint64_t evicted = evictOldestExcept(key);
|
||||
if (evicted == 0U) {
|
||||
frame_it = frames_.find(key);
|
||||
if (frame_it != frames_.end()) eraseFrame(frame_it);
|
||||
result.status = ReassemblyResult::Status::CAPACITY_DROPPED;
|
||||
return result;
|
||||
}
|
||||
result.evicted_frames += evicted;
|
||||
}
|
||||
|
||||
Fragment fragment;
|
||||
fragment.offset = header.fragment_offset;
|
||||
try {
|
||||
fragment.payload.assign(payload, payload + payload_size);
|
||||
frame.fragments.emplace(header.fragment_index, std::move(fragment));
|
||||
} catch (...) {
|
||||
eraseFrame(frame_it);
|
||||
result.status = ReassemblyResult::Status::CAPACITY_DROPPED;
|
||||
return result;
|
||||
}
|
||||
frame.received_bytes += payload_size;
|
||||
frame.updated_at = now;
|
||||
buffered_bytes_ += payload_size;
|
||||
|
||||
if (frame.fragments.size() != header.fragment_count ||
|
||||
frame.received_bytes != header.frame_size) {
|
||||
result.status = ReassemblyResult::Status::ACCEPTED;
|
||||
return result;
|
||||
}
|
||||
|
||||
ReassembledFrame complete;
|
||||
complete.header = frame.first_header;
|
||||
try {
|
||||
complete.payload.resize(header.frame_size);
|
||||
} catch (...) {
|
||||
eraseFrame(frame_it);
|
||||
result.status = ReassemblyResult::Status::CAPACITY_DROPPED;
|
||||
return result;
|
||||
}
|
||||
for (const auto& [index, value] : frame.fragments) {
|
||||
(void)index;
|
||||
if (value.offset > complete.payload.size() ||
|
||||
value.payload.size() > complete.payload.size() - value.offset) {
|
||||
eraseFrame(frame_it);
|
||||
result.status = ReassemblyResult::Status::INVALID;
|
||||
return result;
|
||||
}
|
||||
std::copy(value.payload.begin(), value.payload.end(),
|
||||
complete.payload.begin() + value.offset);
|
||||
}
|
||||
|
||||
eraseFrame(frame_it);
|
||||
result.status = ReassemblyResult::Status::COMPLETED;
|
||||
result.completed_frame = std::move(complete);
|
||||
return result;
|
||||
}
|
||||
|
||||
std::uint64_t MediaReassembler::clear()
|
||||
{
|
||||
const std::uint64_t dropped = frames_.size();
|
||||
frames_.clear();
|
||||
buffered_bytes_ = 0U;
|
||||
return dropped;
|
||||
}
|
||||
|
||||
std::uint64_t MediaReassembler::expire(
|
||||
const std::chrono::steady_clock::time_point now)
|
||||
{
|
||||
std::uint64_t expired = 0U;
|
||||
for (auto it = frames_.begin(); it != frames_.end();) {
|
||||
if (now - it->second.updated_at >= timeout_) {
|
||||
buffered_bytes_ -= it->second.received_bytes;
|
||||
it = frames_.erase(it);
|
||||
++expired;
|
||||
} else {
|
||||
++it;
|
||||
}
|
||||
}
|
||||
return expired;
|
||||
}
|
||||
|
||||
std::uint64_t MediaReassembler::evictOldest()
|
||||
{
|
||||
if (frames_.empty()) return 0U;
|
||||
auto oldest = frames_.begin();
|
||||
for (auto it = std::next(frames_.begin()); it != frames_.end(); ++it) {
|
||||
if (it->second.updated_at < oldest->second.updated_at) oldest = it;
|
||||
}
|
||||
eraseFrame(oldest);
|
||||
return 1U;
|
||||
}
|
||||
|
||||
std::uint64_t MediaReassembler::evictOldestExcept(
|
||||
const FrameKey& protected_key)
|
||||
{
|
||||
auto oldest = frames_.end();
|
||||
for (auto it = frames_.begin(); it != frames_.end(); ++it) {
|
||||
if (it->first == protected_key) continue;
|
||||
if (oldest == frames_.end() ||
|
||||
it->second.updated_at < oldest->second.updated_at) {
|
||||
oldest = it;
|
||||
}
|
||||
}
|
||||
if (oldest == frames_.end()) return 0U;
|
||||
eraseFrame(oldest);
|
||||
return 1U;
|
||||
}
|
||||
|
||||
void MediaReassembler::eraseFrame(
|
||||
std::unordered_map<FrameKey, PartialFrame, FrameKeyHash>::iterator it)
|
||||
{
|
||||
if (it == frames_.end()) return;
|
||||
buffered_bytes_ -= it->second.received_bytes;
|
||||
frames_.erase(it);
|
||||
}
|
||||
|
||||
} // namespace cmvr::test::quic_gateway
|
||||
2131
test/quic_gateway/src/quic_test_gateway.cpp
Normal file
2131
test/quic_gateway/src/quic_test_gateway.cpp
Normal file
File diff suppressed because it is too large
Load Diff
204
test/quic_gateway/tests/media_reassembler_test.cpp
Normal file
204
test/quic_gateway/tests/media_reassembler_test.cpp
Normal file
@ -0,0 +1,204 @@
|
||||
#include "media_reassembler.h"
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <iostream>
|
||||
#include <string>
|
||||
|
||||
namespace {
|
||||
|
||||
#define CHECK_TRUE(expression) \
|
||||
do { \
|
||||
if (!(expression)) { \
|
||||
std::cerr << "CHECK failed at line " << __LINE__ << ": " \
|
||||
<< #expression << '\n'; \
|
||||
return false; \
|
||||
} \
|
||||
} while (false)
|
||||
|
||||
using cmvr::test::quic_gateway::MediaReassembler;
|
||||
using cmvr::test::quic_gateway::ReassemblyResult;
|
||||
using Clock = std::chrono::steady_clock;
|
||||
|
||||
cmvr::quic_edge::DatagramHeader header(
|
||||
const std::uint16_t fragment_index,
|
||||
const std::uint32_t offset,
|
||||
const std::uint16_t payload_size,
|
||||
const std::uint64_t frame_sequence = 1U)
|
||||
{
|
||||
cmvr::quic_edge::DatagramHeader value;
|
||||
value.kind = cmvr::media::MediaKind::VIDEO;
|
||||
value.fragment_index = fragment_index;
|
||||
value.fragment_count = 2U;
|
||||
value.payload_size = payload_size;
|
||||
value.track_id = 7U;
|
||||
value.codec_generation = 3U;
|
||||
value.session_epoch = 11U;
|
||||
value.packet_sequence = fragment_index;
|
||||
value.frame_sequence = frame_sequence;
|
||||
value.capture_timestamp_us = 100U;
|
||||
value.frame_size = 6U;
|
||||
value.fragment_offset = offset;
|
||||
return value;
|
||||
}
|
||||
|
||||
bool completesOutOfOrderAndDetectsDuplicates()
|
||||
{
|
||||
MediaReassembler reassembler(
|
||||
1024U, 8U, 1024U, std::chrono::milliseconds(100));
|
||||
CHECK_TRUE(reassembler.reset(11U) == 0U);
|
||||
const auto now = Clock::now();
|
||||
const std::string second = "def";
|
||||
auto result = reassembler.accept(
|
||||
header(1U, 3U, 3U),
|
||||
reinterpret_cast<const std::uint8_t*>(second.data()),
|
||||
second.size(), now);
|
||||
CHECK_TRUE(result.status == ReassemblyResult::Status::ACCEPTED);
|
||||
|
||||
result = reassembler.accept(
|
||||
header(1U, 3U, 3U),
|
||||
reinterpret_cast<const std::uint8_t*>(second.data()),
|
||||
second.size(), now);
|
||||
CHECK_TRUE(result.status == ReassemblyResult::Status::DUPLICATE);
|
||||
|
||||
const std::string first = "abc";
|
||||
result = reassembler.accept(
|
||||
header(0U, 0U, 3U),
|
||||
reinterpret_cast<const std::uint8_t*>(first.data()),
|
||||
first.size(), now);
|
||||
CHECK_TRUE(result.status == ReassemblyResult::Status::COMPLETED);
|
||||
CHECK_TRUE(result.completed_frame.has_value());
|
||||
CHECK_TRUE(std::string(
|
||||
result.completed_frame->payload.begin(),
|
||||
result.completed_frame->payload.end()) == "abcdef");
|
||||
CHECK_TRUE(reassembler.inFlightFrames() == 0U);
|
||||
CHECK_TRUE(reassembler.bufferedBytes() == 0U);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool rejectsOverlappingFragments()
|
||||
{
|
||||
MediaReassembler reassembler(
|
||||
1024U, 8U, 1024U, std::chrono::milliseconds(100));
|
||||
reassembler.reset(11U);
|
||||
const auto now = Clock::now();
|
||||
const std::string first = "abcd";
|
||||
auto first_header = header(0U, 0U, 4U);
|
||||
auto result = reassembler.accept(
|
||||
first_header,
|
||||
reinterpret_cast<const std::uint8_t*>(first.data()),
|
||||
first.size(), now);
|
||||
CHECK_TRUE(result.status == ReassemblyResult::Status::ACCEPTED);
|
||||
|
||||
const std::string second = "def";
|
||||
result = reassembler.accept(
|
||||
header(1U, 3U, 3U),
|
||||
reinterpret_cast<const std::uint8_t*>(second.data()),
|
||||
second.size(), now);
|
||||
CHECK_TRUE(result.status == ReassemblyResult::Status::INVALID);
|
||||
CHECK_TRUE(reassembler.inFlightFrames() == 0U);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool rejectsConflictingFlags()
|
||||
{
|
||||
MediaReassembler reassembler(
|
||||
1024U, 8U, 1024U, std::chrono::milliseconds(100));
|
||||
reassembler.reset(11U);
|
||||
const auto now = Clock::now();
|
||||
const std::string payload = "abc";
|
||||
|
||||
auto first_header = header(0U, 0U, 3U);
|
||||
first_header.flags = 1U;
|
||||
auto result = reassembler.accept(
|
||||
first_header,
|
||||
reinterpret_cast<const std::uint8_t*>(payload.data()),
|
||||
payload.size(), now);
|
||||
CHECK_TRUE(result.status == ReassemblyResult::Status::ACCEPTED);
|
||||
|
||||
auto second_header = header(1U, 3U, 3U);
|
||||
second_header.flags = 0U;
|
||||
result = reassembler.accept(
|
||||
second_header,
|
||||
reinterpret_cast<const std::uint8_t*>(payload.data()),
|
||||
payload.size(), now);
|
||||
CHECK_TRUE(result.status == ReassemblyResult::Status::INVALID);
|
||||
CHECK_TRUE(reassembler.inFlightFrames() == 0U);
|
||||
CHECK_TRUE(reassembler.bufferedBytes() == 0U);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool rejectsConflictingCaptureTimestamp()
|
||||
{
|
||||
MediaReassembler reassembler(
|
||||
1024U, 8U, 1024U, std::chrono::milliseconds(100));
|
||||
reassembler.reset(11U);
|
||||
const auto now = Clock::now();
|
||||
const std::string payload = "abc";
|
||||
|
||||
auto result = reassembler.accept(
|
||||
header(0U, 0U, 3U),
|
||||
reinterpret_cast<const std::uint8_t*>(payload.data()),
|
||||
payload.size(), now);
|
||||
CHECK_TRUE(result.status == ReassemblyResult::Status::ACCEPTED);
|
||||
|
||||
auto second_header = header(1U, 3U, 3U);
|
||||
second_header.capture_timestamp_us = 101U;
|
||||
result = reassembler.accept(
|
||||
second_header,
|
||||
reinterpret_cast<const std::uint8_t*>(payload.data()),
|
||||
payload.size(), now);
|
||||
CHECK_TRUE(result.status == ReassemblyResult::Status::INVALID);
|
||||
CHECK_TRUE(reassembler.inFlightFrames() == 0U);
|
||||
CHECK_TRUE(reassembler.bufferedBytes() == 0U);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool expiresAndBoundsIncompleteFrames()
|
||||
{
|
||||
MediaReassembler expiring(
|
||||
1024U, 8U, 1024U, std::chrono::milliseconds(10));
|
||||
expiring.reset(11U);
|
||||
const auto now = Clock::now();
|
||||
const std::string payload = "abc";
|
||||
auto result = expiring.accept(
|
||||
header(0U, 0U, 3U, 1U),
|
||||
reinterpret_cast<const std::uint8_t*>(payload.data()),
|
||||
payload.size(), now);
|
||||
CHECK_TRUE(result.status == ReassemblyResult::Status::ACCEPTED);
|
||||
result = expiring.accept(
|
||||
header(0U, 0U, 3U, 2U),
|
||||
reinterpret_cast<const std::uint8_t*>(payload.data()),
|
||||
payload.size(), now + std::chrono::milliseconds(11));
|
||||
CHECK_TRUE(result.expired_frames == 1U);
|
||||
|
||||
MediaReassembler bounded(
|
||||
4U, 8U, 1024U, std::chrono::milliseconds(100));
|
||||
bounded.reset(11U);
|
||||
result = bounded.accept(
|
||||
header(0U, 0U, 3U),
|
||||
reinterpret_cast<const std::uint8_t*>(payload.data()),
|
||||
payload.size(), now);
|
||||
CHECK_TRUE(result.status == ReassemblyResult::Status::ACCEPTED);
|
||||
result = bounded.accept(
|
||||
header(1U, 3U, 3U),
|
||||
reinterpret_cast<const std::uint8_t*>(payload.data()),
|
||||
payload.size(), now);
|
||||
CHECK_TRUE(
|
||||
result.status == ReassemblyResult::Status::CAPACITY_DROPPED);
|
||||
CHECK_TRUE(bounded.inFlightFrames() == 0U);
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main()
|
||||
{
|
||||
if (!completesOutOfOrderAndDetectsDuplicates()) return 1;
|
||||
if (!rejectsOverlappingFragments()) return 1;
|
||||
if (!rejectsConflictingFlags()) return 1;
|
||||
if (!rejectsConflictingCaptureTimestamp()) return 1;
|
||||
if (!expiresAndBoundsIncompleteFrames()) return 1;
|
||||
std::cout << "cmvr_quic_media_reassembler_test passed\n";
|
||||
return 0;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user