diff --git a/.gitignore b/.gitignore index 48482deb..db7a09e0 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,8 @@ /cmake-build-debug /cmake-build-sigma-debuggit /build +/build-* /log /third_party/osqp/ /third_party/OsqpEigen/ -/output \ No newline at end of file +/output diff --git a/CMakeLists.txt b/CMakeLists.txt index 3d0ebabb..0c99cba1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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,10 +197,28 @@ target_link_libraries(cmvr_es PRIVATE ) install(TARGETS cmvr_es RUNTIME DESTINATION bin) -install(CODE [[ - file(REMOVE_RECURSE - "${CMAKE_INSTALL_PREFIX}/bin/config" - "${CMAKE_INSTALL_PREFIX}/bin/model") -]]) -install(DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/cmvr-es/config DESTINATION bin) -install(DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/model 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" + "${CMAKE_INSTALL_PREFIX}/bin/model") + ]]) + 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() diff --git a/README.md b/README.md index 9c7680d7..6fe8983c 100644 --- a/README.md +++ b/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//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//third_party/` 查找。MsQuic 也安装到该依赖树,但由 [`script/build_msquic.sh`](script/build_msquic.sh) 从固定版本源码构建。完整清单和版本以 [`request.txt`](request.txt) 与 [`FindExternalLib.cmake`](cmake/FindExternalLib.cmake) 为准。 ### 4.3 仓库依赖注意事项 当前依赖管理方式有两个需要特别注意的历史问题: -1. 三方依赖的主要来源是仓库中的 `dependency//third_party/`,不是 Git submodule。 +1. 三方依赖的主要来源是仓库中的 `dependency//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 -/ -├── 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//`,CMake 会自动搜索。配置阶段应看到 `MsQuic found`;若看到 `building QUIC edge in unavailable/stub mode`,则当前构建不能启动 QUIC 任务。 +`FindMsQuic.cmake` 默认只接受 +`dependency//third_party/msquic/v`,不会静默链接开发 +主机上的系统 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` 使用绝对路径; -- `/include/msquic.h` 存在; -- `/lib/libmsquic.so`、`lib64/` 或 `bin/` 中存在库; -- 重新配置时清理了错误的 CMake cache。 +- 已成功执行 + `script/build_msquic.sh --arch --version 2.5.9`; +- `CMVR_ARCH` 与依赖目录架构一致; +- `CMVR_MSQUIC_VERSION=2.5.9`; +- `dependency//third_party/msquic/v2.5.9/include/msquic.h` 存在; +- `dependency//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 初始化失败 diff --git a/cmake/FindMsQuic.cmake b/cmake/FindMsQuic.cmake index 4af6ec6f..9e7cddbe 100644 --- a/cmake/FindMsQuic.cmake +++ b/cmake/FindMsQuic.cmake @@ -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//third_party/msquic// +# Default package layout: +# dependency//third_party/msquic/v/ +# +# 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}) +# 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 + PATHS "${_cmvr_msquic_root}" + PATH_SUFFIXES include + NO_DEFAULT_PATH + NO_CACHE) + find_library(_cmvr_msquic_root_library + NAMES msquic libmsquic + 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() -find_path(MsQuic_INCLUDE_DIR - NAMES msquic.h - HINTS ${_cmvr_msquic_hints} - PATH_SUFFIXES include) - -find_library(MsQuic_LIBRARY - NAMES msquic libmsquic - HINTS ${_cmvr_msquic_hints} - PATH_SUFFIXES lib lib64 bin) +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) diff --git a/cmake/msquic/ProcessorCount.cmake b/cmake/msquic/ProcessorCount.cmake new file mode 100644 index 00000000..de672dc4 --- /dev/null +++ b/cmake/msquic/ProcessorCount.cmake @@ -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() diff --git a/cmvr-es/config/manager/task_manager.pb.txt b/cmvr-es/config/manager/task_manager.pb.txt index 3a749976..43bb2269 100644 --- a/cmvr-es/config/manager/task_manager.pb.txt +++ b/cmvr-es/config/manager/task_manager.pb.txt @@ -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 } } diff --git a/cmvr-es/config/tasks/quic_edge_task/quic_edge_task.pb.txt b/cmvr-es/config/tasks/quic_edge_task/quic_edge_task.pb.txt index 6a958bad..fc1bc166 100644 --- a/cmvr-es/config/tasks/quic_edge_task/quic_edge_task.pb.txt +++ b/cmvr-es/config/tasks/quic_edge_task/quic_edge_task.pb.txt @@ -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 diff --git a/cmvr-es/devices/README.md b/cmvr-es/devices/README.md index 23b14e70..aaf43066 100644 --- a/cmvr-es/devices/README.md +++ b/cmvr-es/devices/README.md @@ -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 生命周期。 diff --git a/cmvr-es/devices/abstract_device.h b/cmvr-es/devices/abstract_device.h index f104a120..73768977 100644 --- a/cmvr-es/devices/abstract_device.h +++ b/cmvr-es/devices/abstract_device.h @@ -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_; // 设备名称 }; diff --git a/cmvr-es/devices/camera/abstract_camera.h b/cmvr-es/devices/camera/abstract_camera.h index dfde90cc..93fd5213 100644 --- a/cmvr-es/devices/camera/abstract_camera.h +++ b/cmvr-es/devices/camera/abstract_camera.h @@ -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) {} diff --git a/cmvr-es/devices/device_types.h b/cmvr-es/devices/device_types.h index c5c08b9e..bac91403 100644 --- a/cmvr-es/devices/device_types.h +++ b/cmvr-es/devices/device_types.h @@ -1,7 +1,9 @@ #ifndef CMVR_ES_DEVICE_TYPES_H #define CMVR_ES_DEVICE_TYPES_H +#include #include +#include 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 devices; + }; + } // namespace cmvr::device #endif // CMVR_ES_DEVICE_TYPES_H diff --git a/cmvr-es/devices/dexhand/abstract_dexhand.h b/cmvr-es/devices/dexhand/abstract_dexhand.h index 1cf8b9c7..9cdb53fb 100644 --- a/cmvr-es/devices/dexhand/abstract_dexhand.h +++ b/cmvr-es/devices/dexhand/abstract_dexhand.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(); diff --git a/cmvr-es/devices/microphone/abstract_microphone.h b/cmvr-es/devices/microphone/abstract_microphone.h index 619acf99..b4770343 100644 --- a/cmvr-es/devices/microphone/abstract_microphone.h +++ b/cmvr-es/devices/microphone/abstract_microphone.h @@ -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() {} diff --git a/cmvr-es/manager/README.md b/cmvr-es/manager/README.md index 21e3ac31..7ef1429b 100644 --- a/cmvr-es/manager/README.md +++ b/cmvr-es/manager/README.md @@ -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()` 类型不匹配或 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 失败; - 设备依赖顺序; diff --git a/cmvr-es/manager/device_manager/CMakeLists.txt b/cmvr-es/manager/device_manager/CMakeLists.txt index d1547b13..13be7073 100644 --- a/cmvr-es/manager/device_manager/CMakeLists.txt +++ b/cmvr-es/manager/device_manager/CMakeLists.txt @@ -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() diff --git a/cmvr-es/manager/device_manager/include/device_manager.h b/cmvr-es/manager/device_manager/include/device_manager.h index 323a66b6..c7d9befe 100644 --- a/cmvr-es/manager/device_manager/include/device_manager.h +++ b/cmvr-es/manager/device_manager/include/device_manager.h @@ -7,9 +7,12 @@ #include #include +#include #include -#include #include +#include +#include + #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& device); void registerDevice(const std::string& device_id, const std::shared_ptr& device); std::shared_ptr 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 instance_; config::DeviceManagerConfig cfg_; + mutable std::shared_mutex devices_mutex_; + std::mutex lifecycle_mutex_; std::unordered_map devices_; + std::unordered_map device_statuses_; std::unique_ptr 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 diff --git a/cmvr-es/manager/device_manager/src/device_manager.cpp b/cmvr-es/manager/device_manager/src/device_manager.cpp index ffbac7fb..1b5b2918 100644 --- a/cmvr-es/manager/device_manager/src/device_manager.cpp +++ b/cmvr-es/manager/device_manager/src/device_manager.cpp @@ -5,6 +5,12 @@ #include "../include/device_manager.h" +#include +#include +#include +#include +#include + #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::chrono::duration_cast( + 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(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_(); +} + +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>> devices; + { + std::shared_lock lock(devices_mutex_); + devices.reserve(devices_.size()); + for (const auto& [id, record] : devices_) { + devices.emplace_back(id, record.device); } - if (record.device->start()) { - CMVR_LOG(INFO) << "[DeviceManager]: Start 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]: 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() { - for (auto& [id, record] : devices_) { - if (!record.device) { - CMVR_LOG(WARNING) << "[DeviceManager]: Null pointer for device " << id; - continue; +void DeviceManager::stop_devices_() +{ + std::vector>> devices; + { + std::shared_lock lock(devices_mutex_); + devices.reserve(devices_.size()); + for (const auto& [id, record] : devices_) { + devices.emplace_back(id, record.device); } - 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 std::shared_ptr DeviceManager::getDevice(const std::string& device_id) { - auto it = devices_.find(device_id); - if (it == devices_.end()) { + std::shared_ptr 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(it->second.device); + auto ptr = std::dynamic_pointer_cast(device); if (!ptr) { CMVR_LOG(WARNING) << "[DeviceManager]: Device ID " << device_id << " type mismatch."; return nullptr; @@ -157,16 +335,24 @@ std::shared_ptr DeviceManager::getDevice(const std::string& device_i std::shared_ptr DeviceManager::getDeviceBase(const std::string& device_id) { - auto it = devices_.find(device_id); - if (it == devices_.end()) { + std::shared_ptr 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>& 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& 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; + 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; } - DeviceRecord record; - record.id = device_id; - record.kind = device->kind(); - record.type_name = device->typeName(); - record.device = device; - devices_.emplace(record.id, std::move(record)); + + 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 device; + }; + + std::vector 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(lhs.kind) < + static_cast(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 = ""; + } + ++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); + } } } diff --git a/cmvr-es/manager/device_manager/tests/device_manager_snapshot_test.cpp b/cmvr-es/manager/device_manager/tests/device_manager_snapshot_test.cpp new file mode 100644 index 00000000..05d5bf8c --- /dev/null +++ b/cmvr-es/manager/device_manager/tests/device_manager_snapshot_test.cpp @@ -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 +#include +#include +#include +#include +#include + +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&) override {} + void setTactilePollingRegions( + const std::vector&) override {} + std::vector 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 start_calls{0}; + std::atomic stop_calls{0}; + std::atomic 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("z_healthy"); + auto degraded = std::make_shared("a_degraded"); + degraded->health = { + DeviceHealthState::Degraded, std::string(700, 'd')}; + auto start_fail = std::make_shared("m_start_fail"); + start_fail->start_result = false; + auto stop_fail = std::make_shared("n_stop_fail"); + stop_fail->stop_result = false; + auto health_throw = std::make_shared("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("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 done{false}; + std::atomic 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( + "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; +} diff --git a/cmvr-es/service/README.md b/cmvr-es/service/README.md index 6c6800a2..0ed71894 100644 --- a/cmvr-es/service/README.md +++ b/cmvr-es/service/README.md @@ -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 字节序; - 分片边界和超大帧; diff --git a/cmvr-es/service/quic_edge/CMakeLists.txt b/cmvr-es/service/quic_edge/CMakeLists.txt index 7d104ab6..b2882fda 100644 --- a/cmvr-es/service/quic_edge/CMakeLists.txt +++ b/cmvr-es/service/quic_edge/CMakeLists.txt @@ -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() diff --git a/cmvr-es/service/quic_edge/include/quic_edge_service.h b/cmvr-es/service/quic_edge/include/quic_edge_service.h index d56055fc..dce5dc3e 100644 --- a/cmvr-es/service/quic_edge/include/quic_edge_service.h +++ b/cmvr-es/service/quic_edge/include/quic_edge_service.h @@ -14,6 +14,7 @@ #include #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; + explicit QuicEdgeService(config::QuicEdgeConfig config); QuicEdgeService(config::QuicEdgeConfig config, std::unique_ptr 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_; diff --git a/cmvr-es/service/quic_edge/src/quic_edge_device_adapter.cpp b/cmvr-es/service/quic_edge/src/quic_edge_device_adapter.cpp index f17556a1..07804418 100644 --- a/cmvr-es/service/quic_edge/src/quic_edge_device_adapter.cpp +++ b/cmvr-es/service/quic_edge/src/quic_edge_device_adapter.cpp @@ -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]( diff --git a/cmvr-es/service/quic_edge/src/quic_edge_service.cpp b/cmvr-es/service/quic_edge/src/quic_edge_service.cpp index 53c964e0..0309b122 100644 --- a/cmvr-es/service/quic_edge/src/quic_edge_service.cpp +++ b/cmvr-es/service/quic_edge/src/quic_edge_service.cpp @@ -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(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 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 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,15 +833,19 @@ 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( - config_.control_response_timeout_ms()); + heartbeat_deadline_ = + heartbeat_sent_at + std::chrono::milliseconds( + config_.control_response_timeout_ms()); ++stats_.heartbeats_sent; } - next_heartbeat_ = now + std::chrono::milliseconds( - effective_heartbeat_interval_ms_); + 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_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"); diff --git a/cmvr-es/service/quic_edge/tests/quic_edge_protocol_test.cpp b/cmvr-es/service/quic_edge/tests/quic_edge_protocol_test.cpp index bf9b4b35..a85ae955 100644 --- a/cmvr-es/service/quic_edge/tests/quic_edge_protocol_test.cpp +++ b/cmvr-es/service/quic_edge/tests/quic_edge_protocol_test.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -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"); - enqueueEnvelopeLocked(response); + 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( + 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 heartbeatIntervals() const + { + std::lock_guard lock(mutex_); + std::vector intervals; + for (std::size_t index = 1U; + index < heartbeat_times_.size(); ++index) { + intervals.push_back( + std::chrono::duration_cast( + 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> datagrams_; std::vector edge_message_sequences_; std::vector connect_times_; + std::vector heartbeat_times_; + bool has_last_heartbeat_{false}; + cmvr::quic_edge::v1::NodeHeartbeat last_heartbeat_; + std::optional + 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* 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> 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> 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> 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* 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(kinds.size())); + for (std::size_t index = 0U; index < kinds.size(); ++index) { + const auto& row = + heartbeat.device_manager().devices(static_cast(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( + 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( + 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() || diff --git a/dependency/x86/third_party/msquic/v2.5.9/BUILD-INFO.txt b/dependency/x86/third_party/msquic/v2.5.9/BUILD-INFO.txt new file mode 100644 index 00000000..8038f116 --- /dev/null +++ b/dependency/x86/third_party/msquic/v2.5.9/BUILD-INFO.txt @@ -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 diff --git a/dependency/x86/third_party/msquic/v2.5.9/include/msquic.h b/dependency/x86/third_party/msquic/v2.5.9/include/msquic.h new file mode 100644 index 00000000..5879d422 --- /dev/null +++ b/dependency/x86/third_party/msquic/v2.5.9/include/msquic.h @@ -0,0 +1,2123 @@ +/*++ + + Copyright (c) Microsoft Corporation. + Licensed under the MIT License. + +Abstract: + + Declarations for the MsQuic API, which enables applications and drivers to + create QUIC connections as a client or server. + + For more detailed information, see ../docs/API.md + +Supported Platforms: + + Windows User mode + Windows Kernel mode + Linux User mode + +--*/ + +#ifndef _MSQUIC_ +#define _MSQUIC_ + +#ifdef _WIN32 +#pragma once + +#pragma warning(disable:4201) // nonstandard extension used: nameless struct/union +#pragma warning(disable:4214) // nonstandard extension used: bit field types other than int +#endif + +#ifdef _KERNEL_MODE +#include "msquic_winkernel.h" +#elif _WIN32 +#include "msquic_winuser.h" +#elif __linux__ || __APPLE__ || __FreeBSD__ +#include "msquic_posix.h" +#else +#error "Unsupported Platform" +#endif + +#if defined(__cplusplus) +extern "C" { +#endif + +typedef struct QUIC_HANDLE *HQUIC; + +// +// The maximum value that can be encoded in a 62-bit integer. +// +#define QUIC_UINT62_MAX ((1ULL << 62U) - 1) + +// +// Represents a 62-bit integer. +// +typedef _In_range_(0, QUIC_UINT62_MAX) uint64_t QUIC_UINT62; + +// +// An ALPN must not exceed 255 bytes, and must not be zero-length. +// +#define QUIC_MAX_ALPN_LENGTH 255 + +// +// A server name must not exceed 65535 bytes. +// +#define QUIC_MAX_SNI_LENGTH 65535 + +// +// The maximum number of bytes of application data a server application can +// send in a resumption ticket. +// +#define QUIC_MAX_RESUMPTION_APP_DATA_LENGTH 1000 + +// +// The number of bytes of stateless reset key. +// +#define QUIC_STATELESS_RESET_KEY_LENGTH 32 + +typedef enum QUIC_TLS_PROVIDER { + QUIC_TLS_PROVIDER_SCHANNEL = 0x0000, + QUIC_TLS_PROVIDER_OPENSSL = 0x0001, +} QUIC_TLS_PROVIDER; + +typedef enum QUIC_EXECUTION_PROFILE { + QUIC_EXECUTION_PROFILE_LOW_LATENCY, // Default + QUIC_EXECUTION_PROFILE_TYPE_MAX_THROUGHPUT, + QUIC_EXECUTION_PROFILE_TYPE_SCAVENGER, + QUIC_EXECUTION_PROFILE_TYPE_REAL_TIME, +} QUIC_EXECUTION_PROFILE; + +typedef enum QUIC_LOAD_BALANCING_MODE { + QUIC_LOAD_BALANCING_DISABLED, // Default + QUIC_LOAD_BALANCING_SERVER_ID_IP, // Encodes IP address in Server ID + QUIC_LOAD_BALANCING_SERVER_ID_FIXED, // Encodes a fixed 4-byte value in Server ID + QUIC_LOAD_BALANCING_COUNT, // The number of supported load balancing modes + // MUST BE LAST +} QUIC_LOAD_BALANCING_MODE; + +typedef enum QUIC_TLS_ALERT_CODES { + QUIC_TLS_ALERT_CODE_SUCCESS = 0xFFFF, // Not a real TlsAlert + QUIC_TLS_ALERT_CODE_UNEXPECTED_MESSAGE = 10, + QUIC_TLS_ALERT_CODE_BAD_CERTIFICATE = 42, + QUIC_TLS_ALERT_CODE_UNSUPPORTED_CERTIFICATE = 43, + QUIC_TLS_ALERT_CODE_CERTIFICATE_REVOKED = 44, + QUIC_TLS_ALERT_CODE_CERTIFICATE_EXPIRED = 45, + QUIC_TLS_ALERT_CODE_CERTIFICATE_UNKNOWN = 46, + QUIC_TLS_ALERT_CODE_ILLEGAL_PARAMETER = 47, + QUIC_TLS_ALERT_CODE_UNKNOWN_CA = 48, + QUIC_TLS_ALERT_CODE_ACCESS_DENIED = 49, + QUIC_TLS_ALERT_CODE_INSUFFICIENT_SECURITY = 71, + QUIC_TLS_ALERT_CODE_INTERNAL_ERROR = 80, + QUIC_TLS_ALERT_CODE_USER_CANCELED = 90, + QUIC_TLS_ALERT_CODE_CERTIFICATE_REQUIRED = 116, + QUIC_TLS_ALERT_CODE_MAX = 255, +} QUIC_TLS_ALERT_CODES; + +typedef enum QUIC_CREDENTIAL_TYPE { + QUIC_CREDENTIAL_TYPE_NONE, + QUIC_CREDENTIAL_TYPE_CERTIFICATE_HASH, + QUIC_CREDENTIAL_TYPE_CERTIFICATE_HASH_STORE, + QUIC_CREDENTIAL_TYPE_CERTIFICATE_CONTEXT, + QUIC_CREDENTIAL_TYPE_CERTIFICATE_FILE, + QUIC_CREDENTIAL_TYPE_CERTIFICATE_FILE_PROTECTED, + QUIC_CREDENTIAL_TYPE_CERTIFICATE_PKCS12, +} QUIC_CREDENTIAL_TYPE; + +typedef enum QUIC_CREDENTIAL_FLAGS { + QUIC_CREDENTIAL_FLAG_NONE = 0x00000000, + QUIC_CREDENTIAL_FLAG_CLIENT = 0x00000001, // Lack of client flag indicates server. + QUIC_CREDENTIAL_FLAG_LOAD_ASYNCHRONOUS = 0x00000002, + QUIC_CREDENTIAL_FLAG_NO_CERTIFICATE_VALIDATION = 0x00000004, + QUIC_CREDENTIAL_FLAG_ENABLE_OCSP = 0x00000008, // Schannel only currently + QUIC_CREDENTIAL_FLAG_INDICATE_CERTIFICATE_RECEIVED = 0x00000010, + QUIC_CREDENTIAL_FLAG_DEFER_CERTIFICATE_VALIDATION = 0x00000020, + QUIC_CREDENTIAL_FLAG_REQUIRE_CLIENT_AUTHENTICATION = 0x00000040, + QUIC_CREDENTIAL_FLAG_USE_TLS_BUILTIN_CERTIFICATE_VALIDATION = 0x00000080, // OpenSSL only currently + QUIC_CREDENTIAL_FLAG_REVOCATION_CHECK_END_CERT = 0x00000100, // Schannel only currently + QUIC_CREDENTIAL_FLAG_REVOCATION_CHECK_CHAIN = 0x00000200, // Schannel only currently + QUIC_CREDENTIAL_FLAG_REVOCATION_CHECK_CHAIN_EXCLUDE_ROOT = 0x00000400, // Schannel only currently + QUIC_CREDENTIAL_FLAG_IGNORE_NO_REVOCATION_CHECK = 0x00000800, // Schannel only currently + QUIC_CREDENTIAL_FLAG_IGNORE_REVOCATION_OFFLINE = 0x00001000, // Schannel only currently + QUIC_CREDENTIAL_FLAG_SET_ALLOWED_CIPHER_SUITES = 0x00002000, + QUIC_CREDENTIAL_FLAG_USE_PORTABLE_CERTIFICATES = 0x00004000, + QUIC_CREDENTIAL_FLAG_USE_SUPPLIED_CREDENTIALS = 0x00008000, // Schannel only + QUIC_CREDENTIAL_FLAG_USE_SYSTEM_MAPPER = 0x00010000, // Schannel only + QUIC_CREDENTIAL_FLAG_CACHE_ONLY_URL_RETRIEVAL = 0x00020000, // Windows only currently + QUIC_CREDENTIAL_FLAG_REVOCATION_CHECK_CACHE_ONLY = 0x00040000, // Windows only currently + QUIC_CREDENTIAL_FLAG_INPROC_PEER_CERTIFICATE = 0x00080000, // Schannel only + QUIC_CREDENTIAL_FLAG_SET_CA_CERTIFICATE_FILE = 0x00100000, // OpenSSL only currently + QUIC_CREDENTIAL_FLAG_DISABLE_AIA = 0x00200000, // Schannel only currently +} QUIC_CREDENTIAL_FLAGS; + +DEFINE_ENUM_FLAG_OPERATORS(QUIC_CREDENTIAL_FLAGS) + +typedef enum QUIC_ALLOWED_CIPHER_SUITE_FLAGS { + QUIC_ALLOWED_CIPHER_SUITE_NONE = 0x0, + QUIC_ALLOWED_CIPHER_SUITE_AES_128_GCM_SHA256 = 0x1, + QUIC_ALLOWED_CIPHER_SUITE_AES_256_GCM_SHA384 = 0x2, + QUIC_ALLOWED_CIPHER_SUITE_CHACHA20_POLY1305_SHA256 = 0x4, // Not supported on Schannel +} QUIC_ALLOWED_CIPHER_SUITE_FLAGS; + +DEFINE_ENUM_FLAG_OPERATORS(QUIC_ALLOWED_CIPHER_SUITE_FLAGS); + +typedef enum QUIC_CERTIFICATE_HASH_STORE_FLAGS { + QUIC_CERTIFICATE_HASH_STORE_FLAG_NONE = 0x0000, + QUIC_CERTIFICATE_HASH_STORE_FLAG_MACHINE_STORE = 0x0001, +} QUIC_CERTIFICATE_HASH_STORE_FLAGS; + +DEFINE_ENUM_FLAG_OPERATORS(QUIC_CERTIFICATE_HASH_STORE_FLAGS) + +typedef enum QUIC_CONNECTION_SHUTDOWN_FLAGS { + QUIC_CONNECTION_SHUTDOWN_FLAG_NONE = 0x0000, + QUIC_CONNECTION_SHUTDOWN_FLAG_SILENT = 0x0001, // Don't send the close frame over the network. +} QUIC_CONNECTION_SHUTDOWN_FLAGS; + +DEFINE_ENUM_FLAG_OPERATORS(QUIC_CONNECTION_SHUTDOWN_FLAGS) + +typedef enum QUIC_SERVER_RESUMPTION_LEVEL { + QUIC_SERVER_NO_RESUME, + QUIC_SERVER_RESUME_ONLY, + QUIC_SERVER_RESUME_AND_ZERORTT, +} QUIC_SERVER_RESUMPTION_LEVEL; + +typedef enum QUIC_SEND_RESUMPTION_FLAGS { + QUIC_SEND_RESUMPTION_FLAG_NONE = 0x0000, + QUIC_SEND_RESUMPTION_FLAG_FINAL = 0x0001, // Free TLS state after sending this ticket. +} QUIC_SEND_RESUMPTION_FLAGS; + +DEFINE_ENUM_FLAG_OPERATORS(QUIC_SEND_RESUMPTION_FLAGS) + +typedef enum QUIC_STREAM_SCHEDULING_SCHEME { + QUIC_STREAM_SCHEDULING_SCHEME_FIFO = 0x0000, // Sends stream data first come, first served. (Default) + QUIC_STREAM_SCHEDULING_SCHEME_ROUND_ROBIN = 0x0001, // Sends stream data evenly multiplexed. + QUIC_STREAM_SCHEDULING_SCHEME_COUNT, // The number of stream scheduling schemes. +} QUIC_STREAM_SCHEDULING_SCHEME; + +typedef enum QUIC_STREAM_OPEN_FLAGS { + QUIC_STREAM_OPEN_FLAG_NONE = 0x0000, + QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL = 0x0001, // Indicates the stream is unidirectional. + QUIC_STREAM_OPEN_FLAG_0_RTT = 0x0002, // The stream was opened via a 0-RTT packet. + QUIC_STREAM_OPEN_FLAG_DELAY_ID_FC_UPDATES = 0x0004, // Indicates stream ID flow control limit updates for the + // connection should be delayed to StreamClose. +#ifdef QUIC_API_ENABLE_PREVIEW_FEATURES + QUIC_STREAM_OPEN_FLAG_APP_OWNED_BUFFERS = 0x0008, // No buffer will be allocated for the stream, the app must + // provide buffers (see StreamProvideReceiveBuffers) +#endif +} QUIC_STREAM_OPEN_FLAGS; + +DEFINE_ENUM_FLAG_OPERATORS(QUIC_STREAM_OPEN_FLAGS) + +typedef enum QUIC_STREAM_START_FLAGS { + QUIC_STREAM_START_FLAG_NONE = 0x0000, + QUIC_STREAM_START_FLAG_IMMEDIATE = 0x0001, // Immediately informs peer that stream is open. + QUIC_STREAM_START_FLAG_FAIL_BLOCKED = 0x0002, // Only opens the stream if flow control allows. + QUIC_STREAM_START_FLAG_SHUTDOWN_ON_FAIL = 0x0004, // Shutdown the stream immediately after start failure. + QUIC_STREAM_START_FLAG_INDICATE_PEER_ACCEPT = 0x0008, // Indicate PEER_ACCEPTED event if not accepted at start. + QUIC_STREAM_START_FLAG_PRIORITY_WORK = 0x0010, // Higher priority than other connection work. +} QUIC_STREAM_START_FLAGS; + +DEFINE_ENUM_FLAG_OPERATORS(QUIC_STREAM_START_FLAGS) + +typedef enum QUIC_STREAM_SHUTDOWN_FLAGS { + QUIC_STREAM_SHUTDOWN_FLAG_NONE = 0x0000, + QUIC_STREAM_SHUTDOWN_FLAG_GRACEFUL = 0x0001, // Cleanly closes the send path. + QUIC_STREAM_SHUTDOWN_FLAG_ABORT_SEND = 0x0002, // Abruptly closes the send path. + QUIC_STREAM_SHUTDOWN_FLAG_ABORT_RECEIVE = 0x0004, // Abruptly closes the receive path. + QUIC_STREAM_SHUTDOWN_FLAG_ABORT = 0x0006, // Abruptly closes both send and receive paths. + QUIC_STREAM_SHUTDOWN_FLAG_IMMEDIATE = 0x0008, // Immediately sends completion events to app. + QUIC_STREAM_SHUTDOWN_FLAG_INLINE = 0x0010, // Process the shutdown immediately inline. Only for calls on callbacks. + // WARNING: Can cause reentrant callbacks! +} QUIC_STREAM_SHUTDOWN_FLAGS; + +DEFINE_ENUM_FLAG_OPERATORS(QUIC_STREAM_SHUTDOWN_FLAGS) + +typedef enum QUIC_RECEIVE_FLAGS { + QUIC_RECEIVE_FLAG_NONE = 0x0000, + QUIC_RECEIVE_FLAG_0_RTT = 0x0001, // Data was encrypted with 0-RTT key. + QUIC_RECEIVE_FLAG_FIN = 0x0002, // FIN was included with this data. +} QUIC_RECEIVE_FLAGS; + +DEFINE_ENUM_FLAG_OPERATORS(QUIC_RECEIVE_FLAGS) + +typedef enum QUIC_SEND_FLAGS { + QUIC_SEND_FLAG_NONE = 0x0000, + QUIC_SEND_FLAG_ALLOW_0_RTT = 0x0001, // Allows the use of encrypting with 0-RTT key. + QUIC_SEND_FLAG_START = 0x0002, // Asynchronously starts the stream with the sent data. + QUIC_SEND_FLAG_FIN = 0x0004, // Indicates the request is the one last sent on the stream. + QUIC_SEND_FLAG_DGRAM_PRIORITY = 0x0008, // Indicates the datagram is higher priority than others. + QUIC_SEND_FLAG_DELAY_SEND = 0x0010, // Indicates the send should be delayed because more will be queued soon. + QUIC_SEND_FLAG_CANCEL_ON_LOSS = 0x0020, // Indicates that a stream is to be cancelled when packet loss is detected. + QUIC_SEND_FLAG_PRIORITY_WORK = 0x0040, // Higher priority than other connection work. + QUIC_SEND_FLAG_CANCEL_ON_BLOCKED = 0x0080, // Indicates that a frame should be dropped when it can't be sent immediately. +} QUIC_SEND_FLAGS; + +DEFINE_ENUM_FLAG_OPERATORS(QUIC_SEND_FLAGS) + +typedef enum QUIC_DATAGRAM_SEND_STATE { + QUIC_DATAGRAM_SEND_UNKNOWN, // Not yet sent. + QUIC_DATAGRAM_SEND_SENT, // Sent and awaiting acknowledgment + QUIC_DATAGRAM_SEND_LOST_SUSPECT, // Suspected as lost, but still tracked + QUIC_DATAGRAM_SEND_LOST_DISCARDED, // Lost and not longer being tracked + QUIC_DATAGRAM_SEND_ACKNOWLEDGED, // Acknowledged + QUIC_DATAGRAM_SEND_ACKNOWLEDGED_SPURIOUS, // Acknowledged after being suspected lost + QUIC_DATAGRAM_SEND_CANCELED, // Canceled before send +} QUIC_DATAGRAM_SEND_STATE; + +// +// Helper to determine if a datagrams state is final, and no longer tracked +// by MsQuic. +// +#define QUIC_DATAGRAM_SEND_STATE_IS_FINAL(State) \ + ((State) >= QUIC_DATAGRAM_SEND_LOST_DISCARDED) + +#ifdef QUIC_API_ENABLE_PREVIEW_FEATURES + +typedef enum QUIC_GLOBAL_EXECUTION_CONFIG_FLAGS { + QUIC_GLOBAL_EXECUTION_CONFIG_FLAG_NONE = 0x0000, + QUIC_GLOBAL_EXECUTION_CONFIG_FLAG_NO_IDEAL_PROC = 0x0008, + QUIC_GLOBAL_EXECUTION_CONFIG_FLAG_HIGH_PRIORITY = 0x0010, + QUIC_GLOBAL_EXECUTION_CONFIG_FLAG_AFFINITIZE = 0x0020, +} QUIC_GLOBAL_EXECUTION_CONFIG_FLAGS; + +DEFINE_ENUM_FLAG_OPERATORS(QUIC_GLOBAL_EXECUTION_CONFIG_FLAGS) + +// +// A custom configuration for thread execution in QUIC. +// +typedef struct QUIC_GLOBAL_EXECUTION_CONFIG { + + QUIC_GLOBAL_EXECUTION_CONFIG_FLAGS Flags; + uint32_t PollingIdleTimeoutUs; // Time before a polling thread, with no work to do, sleeps. + uint32_t ProcessorCount; + _Field_size_(ProcessorCount) + uint16_t ProcessorList[1]; // List of processors to use for threads. + +} QUIC_GLOBAL_EXECUTION_CONFIG; + +#define QUIC_GLOBAL_EXECUTION_CONFIG_MIN_SIZE \ + (uint32_t)FIELD_OFFSET(QUIC_GLOBAL_EXECUTION_CONFIG, ProcessorList) + +#ifndef _KERNEL_MODE + +// +// Execution Context abstraction, which allows the application layer to +// completely control execution of all MsQuic work. +// + +typedef struct QUIC_EXECUTION_CONFIG { + uint32_t IdealProcessor; + QUIC_EVENTQ* EventQ; +} QUIC_EXECUTION_CONFIG; + +typedef struct QUIC_EXECUTION QUIC_EXECUTION; + +// +// This is called to create the execution contexts. +// +typedef +_IRQL_requires_max_(PASSIVE_LEVEL) +QUIC_STATUS +(QUIC_API * QUIC_EXECUTION_CREATE_FN)( + _In_ QUIC_GLOBAL_EXECUTION_CONFIG_FLAGS Flags, // Used for datapath type + _In_ uint32_t PollingIdleTimeoutUs, + _In_ uint32_t Count, + _In_reads_(Count) QUIC_EXECUTION_CONFIG* Configs, + _Out_writes_(Count) QUIC_EXECUTION** Executions + ); + +// +// This is called to delete the execution contexts. +// +typedef +_IRQL_requires_max_(PASSIVE_LEVEL) +void +(QUIC_API * QUIC_EXECUTION_DELETE_FN)( + _In_ uint32_t Count, + _In_reads_(Count) QUIC_EXECUTION** Executions + ); + +// +// This is called to allow MsQuic to process any polling work. It returns the +// number of milliseconds until the next scheduled timer expiration. +// +// TODO: Should it return an indication for if we should yield? +// +typedef +_IRQL_requires_max_(PASSIVE_LEVEL) +uint32_t +(QUIC_API * QUIC_EXECUTION_POLL_FN)( + _In_ QUIC_EXECUTION* Execution + ); + +#endif // _KERNEL_MODE + +#endif // QUIC_API_ENABLE_PREVIEW_FEATURES + +typedef struct QUIC_REGISTRATION_CONFIG { // All fields may be NULL/zero. + const char* AppName; + QUIC_EXECUTION_PROFILE ExecutionProfile; +} QUIC_REGISTRATION_CONFIG; + +typedef +_IRQL_requires_max_(PASSIVE_LEVEL) +_Function_class_(QUIC_CREDENTIAL_LOAD_COMPLETE) +void +(QUIC_API QUIC_CREDENTIAL_LOAD_COMPLETE)( + _In_ HQUIC Configuration, + _In_opt_ void* Context, + _In_ QUIC_STATUS Status + ); + +typedef QUIC_CREDENTIAL_LOAD_COMPLETE *QUIC_CREDENTIAL_LOAD_COMPLETE_HANDLER; + +typedef struct QUIC_CERTIFICATE_HASH { + uint8_t ShaHash[20]; +} QUIC_CERTIFICATE_HASH; + +typedef struct QUIC_CERTIFICATE_HASH_STORE { + QUIC_CERTIFICATE_HASH_STORE_FLAGS Flags; + uint8_t ShaHash[20]; + char StoreName[128]; +} QUIC_CERTIFICATE_HASH_STORE; + +typedef struct QUIC_CERTIFICATE_FILE { + const char *PrivateKeyFile; + const char *CertificateFile; +} QUIC_CERTIFICATE_FILE; + +typedef struct QUIC_CERTIFICATE_FILE_PROTECTED { + const char *PrivateKeyFile; + const char *CertificateFile; + const char *PrivateKeyPassword; +} QUIC_CERTIFICATE_FILE_PROTECTED; + +typedef struct QUIC_CERTIFICATE_PKCS12 { + const uint8_t *Asn1Blob; + uint32_t Asn1BlobLength; + const char *PrivateKeyPassword; // Optional: used if provided. Ignored if NULL +} QUIC_CERTIFICATE_PKCS12; + +typedef void QUIC_CERTIFICATE; // Platform specific certificate object +typedef void QUIC_CERTIFICATE_CHAIN; // Platform specific certificate chain object + +typedef struct QUIC_CREDENTIAL_CONFIG { + QUIC_CREDENTIAL_TYPE Type; + QUIC_CREDENTIAL_FLAGS Flags; + union { + QUIC_CERTIFICATE_HASH* CertificateHash; + QUIC_CERTIFICATE_HASH_STORE* CertificateHashStore; + QUIC_CERTIFICATE* CertificateContext; + QUIC_CERTIFICATE_FILE* CertificateFile; + QUIC_CERTIFICATE_FILE_PROTECTED* CertificateFileProtected; + QUIC_CERTIFICATE_PKCS12* CertificatePkcs12; + }; + const char* Principal; + void* Reserved; // Currently unused + QUIC_CREDENTIAL_LOAD_COMPLETE_HANDLER AsyncHandler; // Optional + QUIC_ALLOWED_CIPHER_SUITE_FLAGS AllowedCipherSuites;// Optional + const char* CaCertificateFile; // Optional +} QUIC_CREDENTIAL_CONFIG; + +// +// The maximum number of QUIC_TICKET_KEY_CONFIG that can be used at one time. +// +#define QUIC_MAX_TICKET_KEY_COUNT 16 + +// +// TLS New Session Ticket encryption key configuration. +// +typedef struct QUIC_TICKET_KEY_CONFIG { + uint8_t Id[16]; + uint8_t Material[64]; + uint8_t MaterialLength; +} QUIC_TICKET_KEY_CONFIG; + +// +// A single contiguous buffer. +// +typedef struct QUIC_BUFFER { + uint32_t Length; + _Field_size_bytes_(Length) + uint8_t* Buffer; +} QUIC_BUFFER; + +// +// All the available information describing a new incoming connection. +// +typedef struct QUIC_NEW_CONNECTION_INFO { + uint32_t QuicVersion; + const QUIC_ADDR* LocalAddress; + const QUIC_ADDR* RemoteAddress; + uint32_t CryptoBufferLength; + uint16_t ClientAlpnListLength; + uint16_t ServerNameLength; + uint8_t NegotiatedAlpnLength; + _Field_size_bytes_(CryptoBufferLength) + const uint8_t* CryptoBuffer; + _Field_size_bytes_(ClientAlpnListLength) + const uint8_t* ClientAlpnList; + _Field_size_bytes_(NegotiatedAlpnLength) + const uint8_t* NegotiatedAlpn; + _Field_size_bytes_opt_(ServerNameLength) + const char* ServerName; +} QUIC_NEW_CONNECTION_INFO; + +typedef enum QUIC_TLS_PROTOCOL_VERSION { + QUIC_TLS_PROTOCOL_UNKNOWN = 0, + QUIC_TLS_PROTOCOL_1_3 = 0x3000, +} QUIC_TLS_PROTOCOL_VERSION; + +typedef enum QUIC_CIPHER_ALGORITHM { + QUIC_CIPHER_ALGORITHM_NONE = 0, + QUIC_CIPHER_ALGORITHM_AES_128 = 0x660E, + QUIC_CIPHER_ALGORITHM_AES_256 = 0x6610, + QUIC_CIPHER_ALGORITHM_CHACHA20 = 0x6612, // Not supported on Schannel/BCrypt +} QUIC_CIPHER_ALGORITHM; + +typedef enum QUIC_HASH_ALGORITHM { + QUIC_HASH_ALGORITHM_NONE = 0, + QUIC_HASH_ALGORITHM_SHA_256 = 0x800C, + QUIC_HASH_ALGORITHM_SHA_384 = 0x800D, +} QUIC_HASH_ALGORITHM; + +typedef enum QUIC_KEY_EXCHANGE_ALGORITHM { + QUIC_KEY_EXCHANGE_ALGORITHM_NONE = 0, +} QUIC_KEY_EXCHANGE_ALGORITHM; + +// +// See the following IANA registry for the TLS groups: +// https://www.iana.org/assignments/tls-parameters/tls-parameters.xhtml#tls-parameters-8 +// +typedef enum QUIC_TLS_GROUP { + QUIC_TLS_GROUP_UNKNOWN = 0, + QUIC_TLS_GROUP_SECP256R1 = 23, + QUIC_TLS_GROUP_SECP384R1 = 24, + QUIC_TLS_GROUP_X25519 = 29, + QUIC_TLS_GROUP_MLKEM512 = 512, + QUIC_TLS_GROUP_MLKEM768 = 513, + QUIC_TLS_GROUP_MLKEM1024 = 514, + QUIC_TLS_GROUP_SECP256R1MLKEM768 = 4587, + QUIC_TLS_GROUP_X25519MLKEM768 = 4588, + QUIC_TLS_GROUP_SECP384R1MLKEM1024 = 4589, +} QUIC_TLS_GROUP; + +typedef enum QUIC_CIPHER_SUITE { + QUIC_CIPHER_SUITE_TLS_AES_128_GCM_SHA256 = 0x1301, + QUIC_CIPHER_SUITE_TLS_AES_256_GCM_SHA384 = 0x1302, + QUIC_CIPHER_SUITE_TLS_CHACHA20_POLY1305_SHA256 = 0x1303, // Not supported on Schannel +} QUIC_CIPHER_SUITE; + +typedef enum QUIC_CONGESTION_CONTROL_ALGORITHM { + QUIC_CONGESTION_CONTROL_ALGORITHM_CUBIC, +#ifdef QUIC_API_ENABLE_PREVIEW_FEATURES + QUIC_CONGESTION_CONTROL_ALGORITHM_BBR, +#endif + QUIC_CONGESTION_CONTROL_ALGORITHM_MAX, +} QUIC_CONGESTION_CONTROL_ALGORITHM; + +// +// All the available information describing a handshake. +// +typedef struct QUIC_HANDSHAKE_INFO { + QUIC_TLS_PROTOCOL_VERSION TlsProtocolVersion; + QUIC_CIPHER_ALGORITHM CipherAlgorithm; + int32_t CipherStrength; + QUIC_HASH_ALGORITHM Hash; + int32_t HashStrength; + QUIC_KEY_EXCHANGE_ALGORITHM KeyExchangeAlgorithm; + int32_t KeyExchangeStrength; + QUIC_CIPHER_SUITE CipherSuite; + QUIC_TLS_GROUP TlsGroup; // Added in v2.5 +} QUIC_HANDSHAKE_INFO; + +// +// All statistics available to query about a connection. +// +typedef struct QUIC_STATISTICS { + uint64_t CorrelationId; + uint32_t VersionNegotiation : 1; + uint32_t StatelessRetry : 1; + uint32_t ResumptionAttempted : 1; + uint32_t ResumptionSucceeded : 1; + uint32_t Rtt; // In microseconds + uint32_t MinRtt; // In microseconds + uint32_t MaxRtt; // In microseconds + struct { + uint64_t Start; + uint64_t InitialFlightEnd; // Processed all peer's Initial packets + uint64_t HandshakeFlightEnd; // Processed all peer's Handshake packets + } Timing; + struct { + uint32_t ClientFlight1Bytes; // Sum of TLS payloads + uint32_t ServerFlight1Bytes; // Sum of TLS payloads + uint32_t ClientFlight2Bytes; // Sum of TLS payloads + } Handshake; + struct { + uint16_t PathMtu; // Current path MTU. + uint64_t TotalPackets; // QUIC packets; could be coalesced into fewer UDP datagrams. + uint64_t RetransmittablePackets; + uint64_t SuspectedLostPackets; + uint64_t SpuriousLostPackets; // Actual lost is (SuspectedLostPackets - SpuriousLostPackets) + uint64_t TotalBytes; // Sum of UDP payloads + uint64_t TotalStreamBytes; // Sum of stream payloads + uint32_t CongestionCount; // Number of congestion events + uint32_t PersistentCongestionCount; // Number of persistent congestion events + } Send; + struct { + uint64_t TotalPackets; // QUIC packets; could be coalesced into fewer UDP datagrams. + uint64_t ReorderedPackets; // Packets where packet number is less than highest seen. + uint64_t DroppedPackets; // Includes DuplicatePackets. + uint64_t DuplicatePackets; + uint64_t TotalBytes; // Sum of UDP payloads + uint64_t TotalStreamBytes; // Sum of stream payloads + uint64_t DecryptionFailures; // Count of packet decryption failures. + uint64_t ValidAckFrames; // Count of receive ACK frames. + } Recv; + struct { + uint32_t KeyUpdateCount; + } Misc; +} QUIC_STATISTICS; + +// +// N.B. Consumers of this struct depend on it being the same for 32-bit and +// 64-bit systems. DO NOT include any fields that have different sizes on those +// platforms, such as size_t or pointers. +// +typedef struct QUIC_STATISTICS_V2 { + + uint64_t CorrelationId; + uint32_t VersionNegotiation : 1; + uint32_t StatelessRetry : 1; + uint32_t ResumptionAttempted : 1; + uint32_t ResumptionSucceeded : 1; + uint32_t GreaseBitNegotiated : 1; // Set if we negotiated the GREASE bit. + uint32_t EcnCapable : 1; + uint32_t EncryptionOffloaded : 1; // At least one path successfully offloaded encryption + uint32_t RESERVED : 25; + uint32_t Rtt; // In microseconds + uint32_t MinRtt; // In microseconds + uint32_t MaxRtt; // In microseconds + + uint64_t TimingStart; + uint64_t TimingInitialFlightEnd; // Processed all peer's Initial packets + uint64_t TimingHandshakeFlightEnd; // Processed all peer's Handshake packets + + uint32_t HandshakeClientFlight1Bytes; // Sum of TLS payloads + uint32_t HandshakeServerFlight1Bytes; // Sum of TLS payloads + uint32_t HandshakeClientFlight2Bytes; // Sum of TLS payloads + + uint16_t SendPathMtu; // Current path MTU. + uint64_t SendTotalPackets; // QUIC packets; could be coalesced into fewer UDP datagrams. + uint64_t SendRetransmittablePackets; + uint64_t SendSuspectedLostPackets; + uint64_t SendSpuriousLostPackets; // Actual lost is (SuspectedLostPackets - SpuriousLostPackets) + uint64_t SendTotalBytes; // Sum of UDP payloads + uint64_t SendTotalStreamBytes; // Sum of stream payloads + uint32_t SendCongestionCount; // Number of congestion events + uint32_t SendPersistentCongestionCount; // Number of persistent congestion events + + uint64_t RecvTotalPackets; // QUIC packets; could be coalesced into fewer UDP datagrams. + uint64_t RecvReorderedPackets; // Packets where packet number is less than highest seen. + uint64_t RecvDroppedPackets; // Includes DuplicatePackets. + uint64_t RecvDuplicatePackets; + uint64_t RecvTotalBytes; // Sum of UDP payloads + uint64_t RecvTotalStreamBytes; // Sum of stream payloads + uint64_t RecvDecryptionFailures; // Count of packet decryption failures. + uint64_t RecvValidAckFrames; // Count of receive ACK frames. + + uint32_t KeyUpdateCount; + + uint32_t SendCongestionWindow; // Congestion window size + + uint32_t DestCidUpdateCount; // Number of times the destionation CID changed. + + uint32_t SendEcnCongestionCount; // Number of congestion events caused by ECN. + + uint8_t HandshakeHopLimitTTL; // The TTL value in the initial packet of the handshake. + + uint32_t RttVariance; // In microseconds + + // N.B. New fields must be appended to end + +} QUIC_STATISTICS_V2; + +#define QUIC_STRUCT_SIZE_THRU_FIELD(Struct, Field) \ + (FIELD_OFFSET(Struct, Field) + sizeof(((Struct*)0)->Field)) + +#define QUIC_STATISTICS_V2_SIZE_1 QUIC_STRUCT_SIZE_THRU_FIELD(QUIC_STATISTICS_V2, KeyUpdateCount) // MsQuic v2.0 final size +#define QUIC_STATISTICS_V2_SIZE_2 QUIC_STRUCT_SIZE_THRU_FIELD(QUIC_STATISTICS_V2, DestCidUpdateCount) // MsQuic v2.1 final size +#define QUIC_STATISTICS_V2_SIZE_3 QUIC_STRUCT_SIZE_THRU_FIELD(QUIC_STATISTICS_V2, SendEcnCongestionCount) // MsQuic v2.2 final size +#define QUIC_STATISTICS_V2_SIZE_4 QUIC_STRUCT_SIZE_THRU_FIELD(QUIC_STATISTICS_V2, RttVariance) // MsQuic v2.5 final size + +typedef struct QUIC_LISTENER_STATISTICS { + + uint64_t TotalAcceptedConnections; + uint64_t TotalRejectedConnections; + + uint64_t BindingRecvDroppedPackets; + +} QUIC_LISTENER_STATISTICS; + +typedef enum QUIC_PERFORMANCE_COUNTERS { + QUIC_PERF_COUNTER_CONN_CREATED, // Total connections ever allocated. + QUIC_PERF_COUNTER_CONN_HANDSHAKE_FAIL, // Total connections that failed during handshake. + QUIC_PERF_COUNTER_CONN_APP_REJECT, // Total connections rejected by the application. + QUIC_PERF_COUNTER_CONN_RESUMED, // Total connections resumed. + QUIC_PERF_COUNTER_CONN_ACTIVE, // Connections currently allocated. + QUIC_PERF_COUNTER_CONN_CONNECTED, // Connections currently in the connected state. + QUIC_PERF_COUNTER_CONN_PROTOCOL_ERRORS, // Total connections shutdown with a protocol error. + QUIC_PERF_COUNTER_CONN_NO_ALPN, // Total connection attempts with no matching ALPN. + QUIC_PERF_COUNTER_STRM_ACTIVE, // Current streams allocated. + QUIC_PERF_COUNTER_PKTS_SUSPECTED_LOST, // Total suspected packets lost + QUIC_PERF_COUNTER_PKTS_DROPPED, // Total packets dropped for any reason. + QUIC_PERF_COUNTER_PKTS_DECRYPTION_FAIL, // Total packets with decryption failures. + QUIC_PERF_COUNTER_UDP_RECV, // Total UDP datagrams received. + QUIC_PERF_COUNTER_UDP_SEND, // Total UDP datagrams sent. + QUIC_PERF_COUNTER_UDP_RECV_BYTES, // Total UDP payload bytes received. + QUIC_PERF_COUNTER_UDP_SEND_BYTES, // Total UDP payload bytes sent. + QUIC_PERF_COUNTER_UDP_RECV_EVENTS, // Total UDP receive events. + QUIC_PERF_COUNTER_UDP_SEND_CALLS, // Total UDP send API calls. + QUIC_PERF_COUNTER_APP_SEND_BYTES, // Total bytes sent by applications. + QUIC_PERF_COUNTER_APP_RECV_BYTES, // Total bytes received by applications. + QUIC_PERF_COUNTER_CONN_QUEUE_DEPTH, // Current connections queued for processing. + QUIC_PERF_COUNTER_CONN_OPER_QUEUE_DEPTH,// Current connection operations queued. + QUIC_PERF_COUNTER_CONN_OPER_QUEUED, // Total connection operations queued ever. + QUIC_PERF_COUNTER_CONN_OPER_COMPLETED, // Total connection operations processed ever. + QUIC_PERF_COUNTER_WORK_OPER_QUEUE_DEPTH,// Current worker operations queued. + QUIC_PERF_COUNTER_WORK_OPER_QUEUED, // Total worker operations queued ever. + QUIC_PERF_COUNTER_WORK_OPER_COMPLETED, // Total worker operations processed ever. + QUIC_PERF_COUNTER_PATH_VALIDATED, // Total path challenges that succeed ever. + QUIC_PERF_COUNTER_PATH_FAILURE, // Total path challenges that fail ever. + QUIC_PERF_COUNTER_SEND_STATELESS_RESET, // Total stateless reset packets sent ever. + QUIC_PERF_COUNTER_SEND_STATELESS_RETRY, // Total stateless retry packets sent ever. + QUIC_PERF_COUNTER_CONN_LOAD_REJECT, // Total connections rejected due to worker load. + QUIC_PERF_COUNTER_MAX, +} QUIC_PERFORMANCE_COUNTERS; + +#ifdef QUIC_API_ENABLE_PREVIEW_FEATURES +typedef struct QUIC_VERSION_SETTINGS { + + const uint32_t* AcceptableVersions; + const uint32_t* OfferedVersions; + const uint32_t* FullyDeployedVersions; + uint32_t AcceptableVersionsLength; + uint32_t OfferedVersionsLength; + uint32_t FullyDeployedVersionsLength; + +} QUIC_VERSION_SETTINGS; +#endif + +typedef struct QUIC_GLOBAL_SETTINGS { + union { + uint64_t IsSetFlags; + struct { + uint64_t RetryMemoryLimit : 1; + uint64_t LoadBalancingMode : 1; + uint64_t FixedServerID : 1; + uint64_t RESERVED : 61; + } IsSet; + }; + uint16_t RetryMemoryLimit; + uint16_t LoadBalancingMode; + uint32_t FixedServerID; +} QUIC_GLOBAL_SETTINGS; + +typedef struct QUIC_SETTINGS { + + union { + uint64_t IsSetFlags; + struct { + uint64_t MaxBytesPerKey : 1; + uint64_t HandshakeIdleTimeoutMs : 1; + uint64_t IdleTimeoutMs : 1; + uint64_t MtuDiscoverySearchCompleteTimeoutUs : 1; + uint64_t TlsClientMaxSendBuffer : 1; + uint64_t TlsServerMaxSendBuffer : 1; + uint64_t StreamRecvWindowDefault : 1; + uint64_t StreamRecvBufferDefault : 1; + uint64_t ConnFlowControlWindow : 1; + uint64_t MaxWorkerQueueDelayUs : 1; + uint64_t MaxStatelessOperations : 1; + uint64_t InitialWindowPackets : 1; + uint64_t SendIdleTimeoutMs : 1; + uint64_t InitialRttMs : 1; + uint64_t MaxAckDelayMs : 1; + uint64_t DisconnectTimeoutMs : 1; + uint64_t KeepAliveIntervalMs : 1; + uint64_t CongestionControlAlgorithm : 1; + uint64_t PeerBidiStreamCount : 1; + uint64_t PeerUnidiStreamCount : 1; + uint64_t MaxBindingStatelessOperations : 1; + uint64_t StatelessOperationExpirationMs : 1; + uint64_t MinimumMtu : 1; + uint64_t MaximumMtu : 1; + uint64_t SendBufferingEnabled : 1; + uint64_t PacingEnabled : 1; + uint64_t MigrationEnabled : 1; + uint64_t DatagramReceiveEnabled : 1; + uint64_t ServerResumptionLevel : 1; + uint64_t MaxOperationsPerDrain : 1; + uint64_t MtuDiscoveryMissingProbeCount : 1; + uint64_t DestCidUpdateIdleTimeoutMs : 1; + uint64_t GreaseQuicBitEnabled : 1; + uint64_t EcnEnabled : 1; + uint64_t HyStartEnabled : 1; + uint64_t StreamRecvWindowBidiLocalDefault : 1; + uint64_t StreamRecvWindowBidiRemoteDefault : 1; + uint64_t StreamRecvWindowUnidiDefault : 1; +#ifdef QUIC_API_ENABLE_PREVIEW_FEATURES + uint64_t EncryptionOffloadAllowed : 1; + uint64_t ReliableResetEnabled : 1; + uint64_t OneWayDelayEnabled : 1; + uint64_t NetStatsEventEnabled : 1; + uint64_t StreamMultiReceiveEnabled : 1; + uint64_t XdpEnabled : 1; + uint64_t QTIPEnabled : 1; + uint64_t RioEnabled : 1; + uint64_t RESERVED : 18; +#else + uint64_t RESERVED : 26; +#endif + } IsSet; + }; + + uint64_t MaxBytesPerKey; + uint64_t HandshakeIdleTimeoutMs; + uint64_t IdleTimeoutMs; + uint64_t MtuDiscoverySearchCompleteTimeoutUs; + uint32_t TlsClientMaxSendBuffer; + uint32_t TlsServerMaxSendBuffer; + uint32_t StreamRecvWindowDefault; + uint32_t StreamRecvBufferDefault; + uint32_t ConnFlowControlWindow; + uint32_t MaxWorkerQueueDelayUs; + uint32_t MaxStatelessOperations; + uint32_t InitialWindowPackets; + uint32_t SendIdleTimeoutMs; + uint32_t InitialRttMs; + uint32_t MaxAckDelayMs; + uint32_t DisconnectTimeoutMs; + uint32_t KeepAliveIntervalMs; + uint16_t CongestionControlAlgorithm; // QUIC_CONGESTION_CONTROL_ALGORITHM + uint16_t PeerBidiStreamCount; + uint16_t PeerUnidiStreamCount; + uint16_t MaxBindingStatelessOperations; + uint16_t StatelessOperationExpirationMs; + uint16_t MinimumMtu; + uint16_t MaximumMtu; + uint8_t SendBufferingEnabled : 1; + uint8_t PacingEnabled : 1; + uint8_t MigrationEnabled : 1; + uint8_t DatagramReceiveEnabled : 1; + uint8_t ServerResumptionLevel : 2; // QUIC_SERVER_RESUMPTION_LEVEL + uint8_t GreaseQuicBitEnabled : 1; + uint8_t EcnEnabled : 1; + uint8_t MaxOperationsPerDrain; + uint8_t MtuDiscoveryMissingProbeCount; + uint32_t DestCidUpdateIdleTimeoutMs; + union { + uint64_t Flags; + struct { + uint64_t HyStartEnabled : 1; +#ifdef QUIC_API_ENABLE_PREVIEW_FEATURES + uint64_t EncryptionOffloadAllowed : 1; + uint64_t ReliableResetEnabled : 1; + uint64_t OneWayDelayEnabled : 1; + uint64_t NetStatsEventEnabled : 1; + uint64_t StreamMultiReceiveEnabled : 1; + uint64_t XdpEnabled : 1; + uint64_t QTIPEnabled : 1; + uint64_t RioEnabled : 1; + uint64_t ReservedFlags : 55; +#else + uint64_t ReservedFlags : 63; +#endif + }; + }; + uint32_t StreamRecvWindowBidiLocalDefault; + uint32_t StreamRecvWindowBidiRemoteDefault; + uint32_t StreamRecvWindowUnidiDefault; + +} QUIC_SETTINGS; + +// +// This struct enables QUIC applications to support SSLKEYLOGFILE +// for debugging packet captures with e.g. Wireshark. +// + +#define QUIC_TLS_SECRETS_MAX_SECRET_LEN 64 +typedef struct QUIC_TLS_SECRETS { + uint8_t SecretLength; + struct { + uint8_t ClientRandom : 1; + uint8_t ClientEarlyTrafficSecret : 1; + uint8_t ClientHandshakeTrafficSecret : 1; + uint8_t ServerHandshakeTrafficSecret : 1; + uint8_t ClientTrafficSecret0 : 1; + uint8_t ServerTrafficSecret0 : 1; + } IsSet; + uint8_t ClientRandom[32]; + uint8_t ClientEarlyTrafficSecret[QUIC_TLS_SECRETS_MAX_SECRET_LEN]; + uint8_t ClientHandshakeTrafficSecret[QUIC_TLS_SECRETS_MAX_SECRET_LEN]; + uint8_t ServerHandshakeTrafficSecret[QUIC_TLS_SECRETS_MAX_SECRET_LEN]; + uint8_t ClientTrafficSecret0[QUIC_TLS_SECRETS_MAX_SECRET_LEN]; + uint8_t ServerTrafficSecret0[QUIC_TLS_SECRETS_MAX_SECRET_LEN]; +} QUIC_TLS_SECRETS; + +typedef struct QUIC_STREAM_STATISTICS { + uint64_t ConnBlockedBySchedulingUs; + uint64_t ConnBlockedByPacingUs; + uint64_t ConnBlockedByAmplificationProtUs; + uint64_t ConnBlockedByCongestionControlUs; + uint64_t ConnBlockedByFlowControlUs; + uint64_t StreamBlockedByIdFlowControlUs; + uint64_t StreamBlockedByFlowControlUs; + uint64_t StreamBlockedByAppUs; +} QUIC_STREAM_STATISTICS; + +typedef enum QUIC_AEAD_ALGORITHM_TYPE { + QUIC_AEAD_ALGORITHM_AES_128_GCM = 0, + QUIC_AEAD_ALGORITHM_AES_256_GCM = 1, +} QUIC_AEAD_ALGORITHM_TYPE; + +typedef struct QUIC_STATELESS_RETRY_CONFIG { + QUIC_AEAD_ALGORITHM_TYPE Algorithm; // AEAD algorithm for the key. + uint32_t RotationMs; // Key rotation interval in milliseconds. + uint32_t SecretLength; // Length of the secret. + _Field_size_bytes_(SecretLength) + const uint8_t* Secret; // Secret to generate the key. +} QUIC_STATELESS_RETRY_CONFIG; + +// +// Functions for associating application contexts with QUIC handles. MsQuic +// provides no explicit synchronization between parallel calls to these +// functions. +// + +typedef +_IRQL_requires_max_(DISPATCH_LEVEL) +void +(QUIC_API * QUIC_SET_CONTEXT_FN)( + _In_ _Pre_defensive_ HQUIC Handle, + _In_opt_ void* Context + ); + +typedef +_IRQL_requires_max_(DISPATCH_LEVEL) +void* +(QUIC_API * QUIC_GET_CONTEXT_FN)( + _In_ _Pre_defensive_ HQUIC Handle + ); + +// +// Sets the event handler for the QUIC handle. The type of the handler must be +// appropriate for the type of the handle. MsQuic provides no explicit +// synchronization between parallel calls to this function or the ones above. +// +typedef +_IRQL_requires_max_(DISPATCH_LEVEL) +void +(QUIC_API * QUIC_SET_CALLBACK_HANDLER_FN)( + _In_ _Pre_defensive_ HQUIC Handle, + _In_ void* Handler, + _In_opt_ void* Context + ); + +// +// Get and Set parameters on a handle. +// + +#define QUIC_PARAM_PREFIX_GLOBAL 0x01000000 +#define QUIC_PARAM_PREFIX_REGISTRATION 0x02000000 +#define QUIC_PARAM_PREFIX_CONFIGURATION 0x03000000 +#define QUIC_PARAM_PREFIX_LISTENER 0x04000000 +#define QUIC_PARAM_PREFIX_CONNECTION 0x05000000 +#define QUIC_PARAM_PREFIX_TLS 0x06000000 +#define QUIC_PARAM_PREFIX_TLS_SCHANNEL 0x07000000 +#define QUIC_PARAM_PREFIX_STREAM 0x08000000 + +#define QUIC_PARAM_HIGH_PRIORITY 0x40000000 // Combine with any param to make it high priority. + +#define QUIC_PARAM_IS_GLOBAL(Param) ((Param & 0x3F000000) == QUIC_PARAM_PREFIX_GLOBAL) + +// +// Parameters for Global. +// +#define QUIC_PARAM_GLOBAL_RETRY_MEMORY_PERCENT 0x01000000 // uint16_t +#define QUIC_PARAM_GLOBAL_SUPPORTED_VERSIONS 0x01000001 // uint32_t[] - network byte order +#define QUIC_PARAM_GLOBAL_LOAD_BALACING_MODE 0x01000002 // uint16_t - QUIC_LOAD_BALANCING_MODE +#define QUIC_PARAM_GLOBAL_PERF_COUNTERS 0x01000003 // uint64_t[] - Array size is QUIC_PERF_COUNTER_MAX +#define QUIC_PARAM_GLOBAL_LIBRARY_VERSION 0x01000004 // uint32_t[4] +#define QUIC_PARAM_GLOBAL_SETTINGS 0x01000005 // QUIC_SETTINGS +#define QUIC_PARAM_GLOBAL_GLOBAL_SETTINGS 0x01000006 // QUIC_GLOBAL_SETTINGS +#ifdef QUIC_API_ENABLE_PREVIEW_FEATURES +#define QUIC_PARAM_GLOBAL_VERSION_SETTINGS 0x01000007 // QUIC_VERSION_SETTINGS +#endif +#define QUIC_PARAM_GLOBAL_LIBRARY_GIT_HASH 0x01000008 // char[64] +#ifdef QUIC_API_ENABLE_PREVIEW_FEATURES +#define QUIC_PARAM_GLOBAL_EXECUTION_CONFIG 0x01000009 // QUIC_GLOBAL_EXECUTION_CONFIG +#endif +#define QUIC_PARAM_GLOBAL_TLS_PROVIDER 0x0100000A // QUIC_TLS_PROVIDER +#define QUIC_PARAM_GLOBAL_STATELESS_RESET_KEY 0x0100000B // uint8_t[] - Array size is QUIC_STATELESS_RESET_KEY_LENGTH +#define QUIC_PARAM_GLOBAL_STATISTICS_V2_SIZES 0x0100000C // uint32_t[] - Array of sizes for each QUIC_STATISTICS_V2 version. Get-only. Pass a buffer of uint32_t, output count is variable. See documentation for details. +#define QUIC_PARAM_GLOBAL_STATELESS_RETRY_CONFIG 0x0100000D // QUIC_STATELESS_RETRY_CONFIG + +// +// Parameters for Registration. +// + +// +// Parameters for Configuration. +// +#define QUIC_PARAM_CONFIGURATION_SETTINGS 0x03000000 // QUIC_SETTINGS +#define QUIC_PARAM_CONFIGURATION_TICKET_KEYS 0x03000001 // QUIC_TICKET_KEY_CONFIG[] +#ifdef QUIC_API_ENABLE_PREVIEW_FEATURES +#define QUIC_PARAM_CONFIGURATION_VERSION_SETTINGS 0x03000002 // QUIC_VERSION_SETTINGS +#endif +// Schannel-specific Configuration parameter +typedef struct QUIC_SCHANNEL_CREDENTIAL_ATTRIBUTE_W { + unsigned long Attribute; + unsigned long BufferLength; + void* Buffer; +} QUIC_SCHANNEL_CREDENTIAL_ATTRIBUTE_W; +#define QUIC_PARAM_CONFIGURATION_SCHANNEL_CREDENTIAL_ATTRIBUTE_W 0x03000003 // QUIC_SCHANNEL_CREDENTIAL_ATTRIBUTE_W + +// +// Parameters for Listener. +// +#define QUIC_PARAM_LISTENER_LOCAL_ADDRESS 0x04000000 // QUIC_ADDR +#define QUIC_PARAM_LISTENER_STATS 0x04000001 // QUIC_LISTENER_STATISTICS +#ifdef QUIC_API_ENABLE_PREVIEW_FEATURES +#define QUIC_PARAM_LISTENER_CIBIR_ID 0x04000002 // uint8_t[] {offset, id[]} +#endif +#define QUIC_PARAM_DOS_MODE_EVENTS 0x04000004 // BOOLEAN + +// +// Parameters for Connection. +// +#define QUIC_PARAM_CONN_QUIC_VERSION 0x05000000 // uint32_t +#define QUIC_PARAM_CONN_LOCAL_ADDRESS 0x05000001 // QUIC_ADDR +#define QUIC_PARAM_CONN_REMOTE_ADDRESS 0x05000002 // QUIC_ADDR +#define QUIC_PARAM_CONN_IDEAL_PROCESSOR 0x05000003 // uint16_t +#define QUIC_PARAM_CONN_SETTINGS 0x05000004 // QUIC_SETTINGS +#define QUIC_PARAM_CONN_STATISTICS 0x05000005 // QUIC_STATISTICS +#define QUIC_PARAM_CONN_STATISTICS_PLAT 0x05000006 // QUIC_STATISTICS +#define QUIC_PARAM_CONN_SHARE_UDP_BINDING 0x05000007 // uint8_t (BOOLEAN) +#define QUIC_PARAM_CONN_LOCAL_BIDI_STREAM_COUNT 0x05000008 // uint16_t +#define QUIC_PARAM_CONN_LOCAL_UNIDI_STREAM_COUNT 0x05000009 // uint16_t +#define QUIC_PARAM_CONN_MAX_STREAM_IDS 0x0500000A // uint64_t[4] +#define QUIC_PARAM_CONN_CLOSE_REASON_PHRASE 0x0500000B // char[] +#define QUIC_PARAM_CONN_STREAM_SCHEDULING_SCHEME 0x0500000C // QUIC_STREAM_SCHEDULING_SCHEME +#define QUIC_PARAM_CONN_DATAGRAM_RECEIVE_ENABLED 0x0500000D // uint8_t (BOOLEAN) +#define QUIC_PARAM_CONN_DATAGRAM_SEND_ENABLED 0x0500000E // uint8_t (BOOLEAN) +#ifdef QUIC_API_ENABLE_INSECURE_FEATURES +#define QUIC_PARAM_CONN_DISABLE_1RTT_ENCRYPTION 0x0500000F // uint8_t (BOOLEAN) +#endif +#define QUIC_PARAM_CONN_RESUMPTION_TICKET 0x05000010 // uint8_t[] +#define QUIC_PARAM_CONN_PEER_CERTIFICATE_VALID 0x05000011 // uint8_t (BOOLEAN) +#define QUIC_PARAM_CONN_LOCAL_INTERFACE 0x05000012 // uint32_t +#define QUIC_PARAM_CONN_TLS_SECRETS 0x05000013 // QUIC_TLS_SECRETS (SSLKEYLOGFILE compatible) +#ifdef QUIC_API_ENABLE_PREVIEW_FEATURES +#define QUIC_PARAM_CONN_VERSION_SETTINGS 0x05000014 // QUIC_VERSION_SETTINGS +#define QUIC_PARAM_CONN_CIBIR_ID 0x05000015 // uint8_t[] {offset, id[]} +#endif +#define QUIC_PARAM_CONN_STATISTICS_V2 0x05000016 // QUIC_STATISTICS_V2 +#define QUIC_PARAM_CONN_STATISTICS_V2_PLAT 0x05000017 // QUIC_STATISTICS_V2 +#define QUIC_PARAM_CONN_ORIG_DEST_CID 0x05000018 // uint8_t[] +#define QUIC_PARAM_CONN_SEND_DSCP 0x05000019 // uint8_t + +// +// Parameters for TLS. +// +#define QUIC_PARAM_TLS_HANDSHAKE_INFO 0x06000000 // QUIC_HANDSHAKE_INFO +#define QUIC_PARAM_TLS_NEGOTIATED_ALPN 0x06000001 // uint8_t[] (max 255 bytes) + +#ifdef WIN32 // Schannel specific TLS parameters +typedef struct QUIC_SCHANNEL_CONTEXT_ATTRIBUTE_W { + unsigned long Attribute; + void* Buffer; +} QUIC_SCHANNEL_CONTEXT_ATTRIBUTE_W; +#define QUIC_PARAM_TLS_SCHANNEL_CONTEXT_ATTRIBUTE_W 0x07000000 // QUIC_SCHANNEL_CONTEXT_ATTRIBUTE_W +typedef struct QUIC_SCHANNEL_CONTEXT_ATTRIBUTE_EX_W { + unsigned long Attribute; + unsigned long BufferLength; + void* Buffer; +} QUIC_SCHANNEL_CONTEXT_ATTRIBUTE_EX_W; +#define QUIC_PARAM_TLS_SCHANNEL_CONTEXT_ATTRIBUTE_EX_W 0x07000001 // QUIC_SCHANNEL_CONTEXT_ATTRIBUTE_EX_W +#define QUIC_PARAM_TLS_SCHANNEL_SECURITY_CONTEXT_TOKEN 0x07000002 // HANDLE +#endif + +// +// Parameters for Stream. +// +#define QUIC_PARAM_STREAM_ID 0x08000000 // QUIC_UINT62 +#define QUIC_PARAM_STREAM_0RTT_LENGTH 0x08000001 // uint64_t +#define QUIC_PARAM_STREAM_IDEAL_SEND_BUFFER_SIZE 0x08000002 // uint64_t - bytes +#define QUIC_PARAM_STREAM_PRIORITY 0x08000003 // uint16_t - 0 (low) to 0xFFFF (high) - 0x7FFF (default) +#define QUIC_PARAM_STREAM_STATISTICS 0X08000004 // QUIC_STREAM_STATISTICS +#ifdef QUIC_API_ENABLE_PREVIEW_FEATURES +#define QUIC_PARAM_STREAM_RELIABLE_OFFSET 0x08000005 // uint64_t +#endif + +typedef +_IRQL_requires_max_(PASSIVE_LEVEL) +QUIC_STATUS +(QUIC_API * QUIC_SET_PARAM_FN)( + _When_(QUIC_PARAM_IS_GLOBAL(Param), _Reserved_) + _When_(!QUIC_PARAM_IS_GLOBAL(Param), _In_ _Pre_defensive_) + HQUIC Handle, + _In_ uint32_t Param, + _In_ uint32_t BufferLength, + _In_reads_bytes_(BufferLength) + const void* Buffer + ); + +typedef +_IRQL_requires_max_(PASSIVE_LEVEL) +QUIC_STATUS +(QUIC_API * QUIC_GET_PARAM_FN)( + _When_(QUIC_PARAM_IS_GLOBAL(Param), _Reserved_) + _When_(!QUIC_PARAM_IS_GLOBAL(Param), _In_ _Pre_defensive_) + HQUIC Handle, + _In_ uint32_t Param, + _Inout_ _Pre_defensive_ uint32_t* BufferLength, + _Out_writes_bytes_opt_(*BufferLength) + void* Buffer + ); + +// +// Registration Context Interface. +// + +// +// Opens a new registration. +// +typedef +_IRQL_requires_max_(PASSIVE_LEVEL) +QUIC_STATUS +(QUIC_API * QUIC_REGISTRATION_OPEN_FN)( + _In_opt_ const QUIC_REGISTRATION_CONFIG* Config, + _Outptr_ _At_(*Registration, __drv_allocatesMem(Mem)) _Pre_defensive_ + HQUIC* Registration + ); + +// +// Closes the registration. This function synchronizes the cleanup of all +// child objects. It does this by blocking until all those child objects have +// been closed by the application. +// N.B. This function will deadlock if called in any MsQuic callbacks. +// +typedef +_IRQL_requires_max_(PASSIVE_LEVEL) +void +(QUIC_API * QUIC_REGISTRATION_CLOSE_FN)( + _In_ _Pre_defensive_ __drv_freesMem(Mem) + HQUIC Registration + ); + +// +// Calls shutdown for all connections in this registration. Don't call on a +// MsQuic callback thread or it might deadlock. +// +typedef +_IRQL_requires_max_(DISPATCH_LEVEL) +void +(QUIC_API * QUIC_REGISTRATION_SHUTDOWN_FN)( + _In_ _Pre_defensive_ HQUIC Registration, + _In_ QUIC_CONNECTION_SHUTDOWN_FLAGS Flags, + _In_ _Pre_defensive_ QUIC_UINT62 ErrorCode // Application defined error code + ); + +// +// Configuration Interface. +// + +// +// Opens a new configuration. +// +typedef +_IRQL_requires_max_(PASSIVE_LEVEL) +QUIC_STATUS +(QUIC_API * QUIC_CONFIGURATION_OPEN_FN)( + _In_ _Pre_defensive_ HQUIC Registration, + _In_reads_(AlpnBufferCount) _Pre_defensive_ + const QUIC_BUFFER* const AlpnBuffers, + _In_range_(>, 0) uint32_t AlpnBufferCount, + _In_reads_bytes_opt_(SettingsSize) + const QUIC_SETTINGS* Settings, + _In_ uint32_t SettingsSize, + _In_opt_ void* Context, + _Outptr_ _At_(*Configuration, __drv_allocatesMem(Mem)) _Pre_defensive_ + HQUIC* Configuration + ); + +// +// Closes an existing configuration. +// +typedef +_IRQL_requires_max_(PASSIVE_LEVEL) +void +(QUIC_API * QUIC_CONFIGURATION_CLOSE_FN)( + _In_ _Pre_defensive_ __drv_freesMem(Mem) + HQUIC Configuration + ); + +// +// Loads the credentials based on the input configuration. +// +typedef +_IRQL_requires_max_(PASSIVE_LEVEL) +QUIC_STATUS +(QUIC_API * QUIC_CONFIGURATION_LOAD_CREDENTIAL_FN)( + _In_ _Pre_defensive_ HQUIC Configuration, + _In_ _Pre_defensive_ const QUIC_CREDENTIAL_CONFIG* CredConfig + ); + +// +// Listener Context Interface. +// + +typedef enum QUIC_LISTENER_EVENT_TYPE { + QUIC_LISTENER_EVENT_NEW_CONNECTION = 0, + QUIC_LISTENER_EVENT_STOP_COMPLETE = 1, + QUIC_LISTENER_EVENT_DOS_MODE_CHANGED = 2, +} QUIC_LISTENER_EVENT_TYPE; + +typedef struct QUIC_LISTENER_EVENT { + QUIC_LISTENER_EVENT_TYPE Type; + union { + struct { + const QUIC_NEW_CONNECTION_INFO* Info; + HQUIC Connection; + } NEW_CONNECTION; + struct { + BOOLEAN AppCloseInProgress : 1; + BOOLEAN RESERVED : 7; + } STOP_COMPLETE; + struct { + BOOLEAN DosModeEnabled : 1; + BOOLEAN RESERVED : 7; + } DOS_MODE_CHANGED; + }; +} QUIC_LISTENER_EVENT; + +typedef +_When_( + Event->Type != QUIC_LISTENER_EVENT_DOS_MODE_CHANGED, + _IRQL_requires_max_(PASSIVE_LEVEL)) +_When_( + Event->Type == QUIC_LISTENER_EVENT_DOS_MODE_CHANGED, + _IRQL_requires_max_(DISPATCH_LEVEL)) +_Function_class_(QUIC_LISTENER_CALLBACK) +QUIC_STATUS +(QUIC_API QUIC_LISTENER_CALLBACK)( + _In_ HQUIC Listener, + _In_opt_ void* Context, + _Inout_ QUIC_LISTENER_EVENT* Event + ); + +typedef QUIC_LISTENER_CALLBACK *QUIC_LISTENER_CALLBACK_HANDLER; + +// +// Opens a new listener. +// +typedef +_IRQL_requires_max_(PASSIVE_LEVEL) +QUIC_STATUS +(QUIC_API * QUIC_LISTENER_OPEN_FN)( + _In_ _Pre_defensive_ HQUIC Registration, + _In_ _Pre_defensive_ QUIC_LISTENER_CALLBACK_HANDLER Handler, + _In_opt_ void* Context, + _Outptr_ _At_(*Listener, __drv_allocatesMem(Mem)) _Pre_defensive_ + HQUIC* Listener + ); + +// +// Closes an existing listener. +// +typedef +_IRQL_requires_max_(PASSIVE_LEVEL) +void +(QUIC_API * QUIC_LISTENER_CLOSE_FN)( + _In_ _Pre_defensive_ __drv_freesMem(Mem) + HQUIC Listener + ); + +// +// Starts the listener processing incoming connections. +// +typedef +_IRQL_requires_max_(PASSIVE_LEVEL) +QUIC_STATUS +(QUIC_API * QUIC_LISTENER_START_FN)( + _In_ _Pre_defensive_ HQUIC Listener, + _In_reads_(AlpnBufferCount) _Pre_defensive_ + const QUIC_BUFFER* const AlpnBuffers, + _In_range_(>, 0) uint32_t AlpnBufferCount, + _In_opt_ const QUIC_ADDR* LocalAddress + ); + +// +// Asynchronously stops the listener from processing incoming connections. +// +typedef +_IRQL_requires_max_(PASSIVE_LEVEL) +void +(QUIC_API * QUIC_LISTENER_STOP_FN)( + _In_ _Pre_defensive_ HQUIC Listener + ); + +// +// Connections +// + +typedef enum QUIC_CONNECTION_EVENT_TYPE { + QUIC_CONNECTION_EVENT_CONNECTED = 0, + QUIC_CONNECTION_EVENT_SHUTDOWN_INITIATED_BY_TRANSPORT = 1, // The transport started the shutdown process. + QUIC_CONNECTION_EVENT_SHUTDOWN_INITIATED_BY_PEER = 2, // The peer application started the shutdown process. + QUIC_CONNECTION_EVENT_SHUTDOWN_COMPLETE = 3, // Ready for the handle to be closed. + QUIC_CONNECTION_EVENT_LOCAL_ADDRESS_CHANGED = 4, + QUIC_CONNECTION_EVENT_PEER_ADDRESS_CHANGED = 5, + QUIC_CONNECTION_EVENT_PEER_STREAM_STARTED = 6, + QUIC_CONNECTION_EVENT_STREAMS_AVAILABLE = 7, + QUIC_CONNECTION_EVENT_PEER_NEEDS_STREAMS = 8, + QUIC_CONNECTION_EVENT_IDEAL_PROCESSOR_CHANGED = 9, + QUIC_CONNECTION_EVENT_DATAGRAM_STATE_CHANGED = 10, + QUIC_CONNECTION_EVENT_DATAGRAM_RECEIVED = 11, + QUIC_CONNECTION_EVENT_DATAGRAM_SEND_STATE_CHANGED = 12, + QUIC_CONNECTION_EVENT_RESUMED = 13, // Server-only; provides resumption data, if any. + QUIC_CONNECTION_EVENT_RESUMPTION_TICKET_RECEIVED = 14, // Client-only; provides ticket to persist, if any. + QUIC_CONNECTION_EVENT_PEER_CERTIFICATE_RECEIVED = 15, // Only with QUIC_CREDENTIAL_FLAG_INDICATE_CERTIFICATE_RECEIVED set +#ifdef QUIC_API_ENABLE_PREVIEW_FEATURES + QUIC_CONNECTION_EVENT_RELIABLE_RESET_NEGOTIATED = 16, // Only indicated if QUIC_SETTINGS.ReliableResetEnabled is TRUE. + QUIC_CONNECTION_EVENT_ONE_WAY_DELAY_NEGOTIATED = 17, // Only indicated if QUIC_SETTINGS.OneWayDelayEnabled is TRUE. + QUIC_CONNECTION_EVENT_NETWORK_STATISTICS = 18, // Only indicated if QUIC_SETTINGS.EnableNetStatsEvent is TRUE. +#endif +} QUIC_CONNECTION_EVENT_TYPE; + +typedef struct QUIC_CONNECTION_EVENT { + QUIC_CONNECTION_EVENT_TYPE Type; + union { + struct { + BOOLEAN SessionResumed; + _Field_range_(>, 0) + uint8_t NegotiatedAlpnLength; + _Field_size_(NegotiatedAlpnLength) + const uint8_t* NegotiatedAlpn; + } CONNECTED; + struct { + QUIC_STATUS Status; + QUIC_UINT62 ErrorCode; // Wire format error code. + } SHUTDOWN_INITIATED_BY_TRANSPORT; + struct { + QUIC_UINT62 ErrorCode; + } SHUTDOWN_INITIATED_BY_PEER; + struct { + BOOLEAN HandshakeCompleted : 1; + BOOLEAN PeerAcknowledgedShutdown : 1; + BOOLEAN AppCloseInProgress : 1; + } SHUTDOWN_COMPLETE; + struct { + const QUIC_ADDR* Address; + } LOCAL_ADDRESS_CHANGED; + struct { + const QUIC_ADDR* Address; + } PEER_ADDRESS_CHANGED; + struct { + HQUIC Stream; + QUIC_STREAM_OPEN_FLAGS Flags; + } PEER_STREAM_STARTED; + struct { + uint16_t BidirectionalCount; + uint16_t UnidirectionalCount; + } STREAMS_AVAILABLE; + struct { + BOOLEAN Bidirectional; + } PEER_NEEDS_STREAMS; + struct { + uint16_t IdealProcessor; + uint16_t PartitionIndex; + } IDEAL_PROCESSOR_CHANGED; + struct { + BOOLEAN SendEnabled; + uint16_t MaxSendLength; + } DATAGRAM_STATE_CHANGED; + struct { + const QUIC_BUFFER* Buffer; + QUIC_RECEIVE_FLAGS Flags; + } DATAGRAM_RECEIVED; + struct { + /* inout */ void* ClientContext; + QUIC_DATAGRAM_SEND_STATE State; + } DATAGRAM_SEND_STATE_CHANGED; + struct { + uint16_t ResumptionStateLength; + const uint8_t* ResumptionState; + } RESUMED; + struct { + _Field_range_(>, 0) + uint32_t ResumptionTicketLength; + _Field_size_(ResumptionTicketLength) + const uint8_t* ResumptionTicket; + } RESUMPTION_TICKET_RECEIVED; + struct { + QUIC_CERTIFICATE* Certificate; // Peer certificate (platform specific). Valid only during QUIC_CONNECTION_EVENT_PEER_CERTIFICATE_RECEIVED callback. + uint32_t DeferredErrorFlags; // Bit flag of errors (only valid with QUIC_CREDENTIAL_FLAG_DEFER_CERTIFICATE_VALIDATION) - Schannel only, zero otherwise. + QUIC_STATUS DeferredStatus; // Most severe error status (only valid with QUIC_CREDENTIAL_FLAG_DEFER_CERTIFICATE_VALIDATION) + QUIC_CERTIFICATE_CHAIN* Chain; // Peer certificate chain (platform specific). Valid only during QUIC_CONNECTION_EVENT_PEER_CERTIFICATE_RECEIVED callback. + } PEER_CERTIFICATE_RECEIVED; +#ifdef QUIC_API_ENABLE_PREVIEW_FEATURES + struct { + BOOLEAN IsNegotiated; + } RELIABLE_RESET_NEGOTIATED; + struct { + BOOLEAN SendNegotiated; // TRUE if sending one-way delay timestamps is negotiated. + BOOLEAN ReceiveNegotiated; // TRUE if receiving one-way delay timestamps is negotiated. + } ONE_WAY_DELAY_NEGOTIATED; + struct { + uint32_t BytesInFlight; // Bytes that were sent on the wire, but not yet acked + uint64_t PostedBytes; // Total bytes queued, but not yet acked. These may contain sent bytes that may have potentially lost too. + uint64_t IdealBytes; // Ideal number of bytes required to be available to avoid limiting throughput + uint64_t SmoothedRTT; // Smoothed RTT value + uint32_t CongestionWindow; // Congestion Window + uint64_t Bandwidth; // Estimated bandwidth + } NETWORK_STATISTICS; +#endif + }; +} QUIC_CONNECTION_EVENT; + +typedef +_IRQL_requires_max_(PASSIVE_LEVEL) +_Function_class_(QUIC_CONNECTION_CALLBACK) +QUIC_STATUS +(QUIC_API QUIC_CONNECTION_CALLBACK)( + _In_ HQUIC Connection, + _In_opt_ void* Context, + _Inout_ QUIC_CONNECTION_EVENT* Event + ); + +typedef QUIC_CONNECTION_CALLBACK *QUIC_CONNECTION_CALLBACK_HANDLER; + +// +// Opens a new connection. +// +typedef +_IRQL_requires_max_(DISPATCH_LEVEL) +QUIC_STATUS +(QUIC_API * QUIC_CONNECTION_OPEN_FN)( + _In_ _Pre_defensive_ HQUIC Registration, + _In_ _Pre_defensive_ QUIC_CONNECTION_CALLBACK_HANDLER Handler, + _In_opt_ void* Context, + _Outptr_ _At_(*Connection, __drv_allocatesMem(Mem)) _Pre_defensive_ + HQUIC* Connection + ); +typedef +_IRQL_requires_max_(DISPATCH_LEVEL) +QUIC_STATUS +(QUIC_API * QUIC_CONNECTION_OPEN_IN_PARTITION_FN)( + _In_ _Pre_defensive_ HQUIC Registration, + _In_ uint16_t PartitionIndex, + _In_ _Pre_defensive_ QUIC_CONNECTION_CALLBACK_HANDLER Handler, + _In_opt_ void* Context, + _Outptr_ _At_(*Connection, __drv_allocatesMem(Mem)) _Pre_defensive_ + HQUIC* Connection + ); + +// +// Closes an existing connection. +// +typedef +_IRQL_requires_max_(PASSIVE_LEVEL) +void +(QUIC_API * QUIC_CONNECTION_CLOSE_FN)( + _In_ _Pre_defensive_ __drv_freesMem(Mem) + HQUIC Connection + ); + +// +// Starts the shutdown process on the connection. This immediately and silently +// shuts down any open streams; which will trigger callbacks for +// QUIC_CONNECTION_EVENT_STREAM_CLOSED events. Does nothing if already shutdown. +// Can be passed either a connection or stream handle. +// +typedef +_IRQL_requires_max_(DISPATCH_LEVEL) +void +(QUIC_API * QUIC_CONNECTION_SHUTDOWN_FN)( + _In_ _Pre_defensive_ HQUIC Connection, + _In_ QUIC_CONNECTION_SHUTDOWN_FLAGS Flags, + _In_ _Pre_defensive_ QUIC_UINT62 ErrorCode // Application defined error code + ); + +// +// Uses the QUIC (client) handle to start a connection attempt to the +// remote server. Can be passed either a connection or stream handle. +// +typedef +_IRQL_requires_max_(DISPATCH_LEVEL) +QUIC_STATUS +(QUIC_API * QUIC_CONNECTION_START_FN)( + _In_ _Pre_defensive_ HQUIC Connection, + _In_ _Pre_defensive_ HQUIC Configuration, + _In_ QUIC_ADDRESS_FAMILY Family, + _In_reads_or_z_opt_(QUIC_MAX_SNI_LENGTH) + const char* ServerName, + _In_ uint16_t ServerPort // Host byte order + ); + +// +// Sets the (server-side) configuration handle for the connection. This must be +// called on an accepted connection in order to proceed with the QUIC handshake. +// +typedef +_IRQL_requires_max_(DISPATCH_LEVEL) +QUIC_STATUS +(QUIC_API * QUIC_CONNECTION_SET_CONFIGURATION_FN)( + _In_ _Pre_defensive_ HQUIC Connection, + _In_ _Pre_defensive_ HQUIC Configuration + ); + +// +// Uses the QUIC (server) handle to send a resumption ticket to the remote +// client, optionally with app-specific data useful during resumption. +// +typedef +_IRQL_requires_max_(DISPATCH_LEVEL) +QUIC_STATUS +(QUIC_API * QUIC_CONNECTION_SEND_RESUMPTION_FN)( + _In_ _Pre_defensive_ HQUIC Connection, + _In_ QUIC_SEND_RESUMPTION_FLAGS Flags, + _In_ uint16_t DataLength, + _In_reads_bytes_opt_(DataLength) + const uint8_t* ResumptionData + ); + +// +// Uses the QUIC (server) handle to complete resumption ticket validation. +// This must be called after server app handles ticket validation and then +// return QUIC_STATUS_PENDING. +// +typedef +_IRQL_requires_max_(DISPATCH_LEVEL) +QUIC_STATUS +(QUIC_API * QUIC_CONNECTION_COMP_RESUMPTION_FN)( + _In_ _Pre_defensive_ HQUIC Connection, + _In_ BOOLEAN Result + ); + +// +// Uses the QUIC (client) handle to complete certificate validation. +// This must be called after client app handles certificate validation +// and then return QUIC_STATUS_PENDING. The TlsAlert value is ignored if Result +// equals TRUE (recommend just pass QUIC_TLS_ALERT_CODE_SUCCESS). +// +typedef +_IRQL_requires_max_(DISPATCH_LEVEL) +QUIC_STATUS +(QUIC_API * QUIC_CONNECTION_COMP_CERT_FN)( + _In_ _Pre_defensive_ HQUIC Connection, + _In_ BOOLEAN Result, + _In_ QUIC_TLS_ALERT_CODES TlsAlert + ); + +// +// Streams +// + +typedef enum QUIC_STREAM_EVENT_TYPE { + QUIC_STREAM_EVENT_START_COMPLETE = 0, + QUIC_STREAM_EVENT_RECEIVE = 1, + QUIC_STREAM_EVENT_SEND_COMPLETE = 2, + QUIC_STREAM_EVENT_PEER_SEND_SHUTDOWN = 3, + QUIC_STREAM_EVENT_PEER_SEND_ABORTED = 4, + QUIC_STREAM_EVENT_PEER_RECEIVE_ABORTED = 5, + QUIC_STREAM_EVENT_SEND_SHUTDOWN_COMPLETE = 6, + QUIC_STREAM_EVENT_SHUTDOWN_COMPLETE = 7, + QUIC_STREAM_EVENT_IDEAL_SEND_BUFFER_SIZE = 8, + QUIC_STREAM_EVENT_PEER_ACCEPTED = 9, + QUIC_STREAM_EVENT_CANCEL_ON_LOSS = 10, +#ifdef QUIC_API_ENABLE_PREVIEW_FEATURES + QUIC_STREAM_EVENT_RECEIVE_BUFFER_NEEDED = 11, +#endif +} QUIC_STREAM_EVENT_TYPE; + +typedef struct QUIC_STREAM_EVENT { + QUIC_STREAM_EVENT_TYPE Type; + union { + struct { + QUIC_STATUS Status; + QUIC_UINT62 ID; + BOOLEAN PeerAccepted : 1; + BOOLEAN RESERVED : 7; + } START_COMPLETE; + struct { + /* in */ uint64_t AbsoluteOffset; + /* inout */ uint64_t TotalBufferLength; + _Field_size_(BufferCount) + /* in */ const QUIC_BUFFER* Buffers; + _Field_range_(0, UINT32_MAX) + /* in */ uint32_t BufferCount; + /* in */ QUIC_RECEIVE_FLAGS Flags; + } RECEIVE; + struct { + BOOLEAN Canceled; + void* ClientContext; + } SEND_COMPLETE; + struct { + QUIC_UINT62 ErrorCode; + } PEER_SEND_ABORTED; + struct { + QUIC_UINT62 ErrorCode; + } PEER_RECEIVE_ABORTED; + struct { + BOOLEAN Graceful; + } SEND_SHUTDOWN_COMPLETE; + struct { + BOOLEAN ConnectionShutdown; + BOOLEAN AppCloseInProgress : 1; + BOOLEAN ConnectionShutdownByApp : 1; + BOOLEAN ConnectionClosedRemotely : 1; + BOOLEAN RESERVED : 5; + QUIC_UINT62 ConnectionErrorCode; + QUIC_STATUS ConnectionCloseStatus; + } SHUTDOWN_COMPLETE; + struct { + uint64_t ByteCount; + } IDEAL_SEND_BUFFER_SIZE; + struct { + /* out */ QUIC_UINT62 ErrorCode; + } CANCEL_ON_LOSS; +#ifdef QUIC_API_ENABLE_PREVIEW_FEATURES + struct { + /* in */ uint64_t BufferLengthNeeded; + } RECEIVE_BUFFER_NEEDED; +#endif + }; +} QUIC_STREAM_EVENT; + +typedef +_IRQL_requires_max_(PASSIVE_LEVEL) +_Function_class_(QUIC_STREAM_CALLBACK) +QUIC_STATUS +(QUIC_API QUIC_STREAM_CALLBACK)( + _In_ HQUIC Stream, + _In_opt_ void* Context, + _Inout_ QUIC_STREAM_EVENT* Event + ); + +typedef QUIC_STREAM_CALLBACK *QUIC_STREAM_CALLBACK_HANDLER; + +// +// Opens a stream on the given connection. +// +typedef +_IRQL_requires_max_(DISPATCH_LEVEL) +QUIC_STATUS +(QUIC_API * QUIC_STREAM_OPEN_FN)( + _In_ _Pre_defensive_ HQUIC Connection, + _In_ QUIC_STREAM_OPEN_FLAGS Flags, + _In_ _Pre_defensive_ QUIC_STREAM_CALLBACK_HANDLER Handler, + _In_opt_ void* Context, + _Outptr_ _At_(*Stream, __drv_allocatesMem(Mem)) _Pre_defensive_ + HQUIC* Stream + ); + +// +// Closes a stream handle. +// +typedef +_IRQL_requires_max_(PASSIVE_LEVEL) +void +(QUIC_API * QUIC_STREAM_CLOSE_FN)( + _In_ _Pre_defensive_ __drv_freesMem(Mem) + HQUIC Stream + ); + +// +// Starts processing the stream. +// +typedef +_IRQL_requires_max_(DISPATCH_LEVEL) +QUIC_STATUS +(QUIC_API * QUIC_STREAM_START_FN)( + _In_ _Pre_defensive_ HQUIC Stream, + _In_ QUIC_STREAM_START_FLAGS Flags + ); + +// +// Shuts the stream down as specified, and waits for graceful +// shutdowns to complete. Does nothing if already shut down. +// +typedef +_IRQL_requires_max_(DISPATCH_LEVEL) +QUIC_STATUS +(QUIC_API * QUIC_STREAM_SHUTDOWN_FN)( + _In_ _Pre_defensive_ HQUIC Stream, + _In_ QUIC_STREAM_SHUTDOWN_FLAGS Flags, + _In_ _Pre_defensive_ QUIC_UINT62 ErrorCode // Application defined error code + ); + +// +// Sends data on an open stream. +// +typedef +_IRQL_requires_max_(DISPATCH_LEVEL) +QUIC_STATUS +(QUIC_API * QUIC_STREAM_SEND_FN)( + _In_ _Pre_defensive_ HQUIC Stream, + _In_reads_(BufferCount) _Pre_defensive_ + const QUIC_BUFFER* const Buffers, + _In_ uint32_t BufferCount, + _In_ QUIC_SEND_FLAGS Flags, + _In_opt_ void* ClientSendContext + ); + +// +// Completes a previously pended receive callback. +// +typedef +_IRQL_requires_max_(DISPATCH_LEVEL) +void +(QUIC_API * QUIC_STREAM_RECEIVE_COMPLETE_FN)( + _In_ _Pre_defensive_ HQUIC Stream, + _In_ uint64_t BufferLength + ); + +// +// Enables or disables stream receive callbacks. +// +typedef +_IRQL_requires_max_(DISPATCH_LEVEL) +QUIC_STATUS +(QUIC_API * QUIC_STREAM_RECEIVE_SET_ENABLED_FN)( + _In_ _Pre_defensive_ HQUIC Stream, + _In_ BOOLEAN IsEnabled + ); + +#ifdef QUIC_API_ENABLE_PREVIEW_FEATURES +// +// Provides receive buffers to the stream. +// The buffers are owned by the caller and must remain valid until a receive +// indication for all bytes in the buffer, or the stream is closed. +// +typedef +_IRQL_requires_max_(DISPATCH_LEVEL) +QUIC_STATUS +(QUIC_API * QUIC_STREAM_PROVIDE_RECEIVE_BUFFERS_FN)( + _In_ _Pre_defensive_ HQUIC Stream, + _In_ uint32_t BufferCount, + _In_reads_(BufferCount) const QUIC_BUFFER* Buffers + ); + +#endif + +// +// Datagrams +// + +// +// Sends an unreliable datagram on the connection. Note, the total payload +// of the send must fit in a single QUIC packet. +// +typedef +_IRQL_requires_max_(DISPATCH_LEVEL) +QUIC_STATUS +(QUIC_API * QUIC_DATAGRAM_SEND_FN)( + _In_ _Pre_defensive_ HQUIC Connection, + _In_reads_(BufferCount) _Pre_defensive_ + const QUIC_BUFFER* const Buffers, + _In_ uint32_t BufferCount, + _In_ QUIC_SEND_FLAGS Flags, + _In_opt_ void* ClientSendContext + ); + +// +// Connection Pool API +// + +#ifdef QUIC_API_ENABLE_PREVIEW_FEATURES + +typedef enum QUIC_CONNECTION_POOL_FLAGS { + QUIC_CONNECTION_POOL_FLAG_NONE = 0x00000000, + QUIC_CONNECTION_POOL_FLAG_CLOSE_ON_FAILURE = 0x00000001, +} QUIC_CONNECTION_POOL_FLAGS; + +DEFINE_ENUM_FLAG_OPERATORS(QUIC_CONNECTION_POOL_FLAGS); + +typedef struct QUIC_CONNECTION_POOL_CONFIG { + HQUIC Registration; + HQUIC Configuration; + QUIC_CONNECTION_CALLBACK_HANDLER Handler; + _Field_size_opt_(NumberOfConnections) + void** Context; // Optional + _Field_z_ const char* ServerName; + const QUIC_ADDR* ServerAddress; // Optional + QUIC_ADDRESS_FAMILY Family; + uint16_t ServerPort; + uint16_t NumberOfConnections; + _At_buffer_(_Curr_, _Iter_, NumberOfConnections, _Field_size_(CibirIdLength)) + _Field_size_opt_(NumberOfConnections) + uint8_t** CibirIds; // Optional + uint8_t CibirIdLength; // Zero if not using CIBIR + QUIC_CONNECTION_POOL_FLAGS Flags; +} QUIC_CONNECTION_POOL_CONFIG; + +// +// Creates a simple pool of NumberOfConnections connections, all with the same +// Handler, and puts them in the caller-supplied array. +// Connections are spread evenly across RSS CPUs as much as possible. +// If NumberOfConnections is more than the number of RSS cores, then multiple +// connections will be put on the same CPU. +// +typedef +_IRQL_requires_max_(PASSIVE_LEVEL) +QUIC_STATUS +(QUIC_API * QUIC_CONN_POOL_CREATE_FN)( + _In_ QUIC_CONNECTION_POOL_CONFIG* Config, + _Out_writes_(Config->NumberOfConnections) + HQUIC* ConnectionPool + ); + +#endif // QUIC_API_ENABLE_PREVIEW_FEATURES + +// +// Version 2 API Function Table. Returned from MsQuicOpenVersion when Version +// is 2. Also returned from MsQuicOpen2. +// +typedef struct QUIC_API_TABLE { + + QUIC_SET_CONTEXT_FN SetContext; + QUIC_GET_CONTEXT_FN GetContext; + QUIC_SET_CALLBACK_HANDLER_FN SetCallbackHandler; + + QUIC_SET_PARAM_FN SetParam; + QUIC_GET_PARAM_FN GetParam; + + QUIC_REGISTRATION_OPEN_FN RegistrationOpen; + QUIC_REGISTRATION_CLOSE_FN RegistrationClose; + QUIC_REGISTRATION_SHUTDOWN_FN RegistrationShutdown; + + QUIC_CONFIGURATION_OPEN_FN ConfigurationOpen; + QUIC_CONFIGURATION_CLOSE_FN ConfigurationClose; + QUIC_CONFIGURATION_LOAD_CREDENTIAL_FN + ConfigurationLoadCredential; + + QUIC_LISTENER_OPEN_FN ListenerOpen; + QUIC_LISTENER_CLOSE_FN ListenerClose; + QUIC_LISTENER_START_FN ListenerStart; + QUIC_LISTENER_STOP_FN ListenerStop; + + QUIC_CONNECTION_OPEN_FN ConnectionOpen; + QUIC_CONNECTION_CLOSE_FN ConnectionClose; + QUIC_CONNECTION_SHUTDOWN_FN ConnectionShutdown; + QUIC_CONNECTION_START_FN ConnectionStart; + QUIC_CONNECTION_SET_CONFIGURATION_FN + ConnectionSetConfiguration; + QUIC_CONNECTION_SEND_RESUMPTION_FN ConnectionSendResumptionTicket; + + QUIC_STREAM_OPEN_FN StreamOpen; + QUIC_STREAM_CLOSE_FN StreamClose; + QUIC_STREAM_START_FN StreamStart; + QUIC_STREAM_SHUTDOWN_FN StreamShutdown; + QUIC_STREAM_SEND_FN StreamSend; + QUIC_STREAM_RECEIVE_COMPLETE_FN StreamReceiveComplete; + QUIC_STREAM_RECEIVE_SET_ENABLED_FN StreamReceiveSetEnabled; + + QUIC_DATAGRAM_SEND_FN DatagramSend; + + QUIC_CONNECTION_COMP_RESUMPTION_FN ConnectionResumptionTicketValidationComplete; // Available from v2.2 + QUIC_CONNECTION_COMP_CERT_FN ConnectionCertificateValidationComplete; // Available from v2.2 + + QUIC_CONNECTION_OPEN_IN_PARTITION_FN + ConnectionOpenInPartition; // Available from v2.5 + +#ifdef QUIC_API_ENABLE_PREVIEW_FEATURES + QUIC_STREAM_PROVIDE_RECEIVE_BUFFERS_FN + StreamProvideReceiveBuffers; // Available from v2.5 + + QUIC_CONN_POOL_CREATE_FN ConnectionPoolCreate; // Available from v2.5 + +#ifndef _KERNEL_MODE + QUIC_EXECUTION_CREATE_FN ExecutionCreate; // Available from v2.5 + QUIC_EXECUTION_DELETE_FN ExecutionDelete; // Available from v2.5 + QUIC_EXECUTION_POLL_FN ExecutionPoll; // Available from v2.5 +#endif // _KERNEL_MODE +#endif // QUIC_API_ENABLE_PREVIEW_FEATURES + +} QUIC_API_TABLE; + +#define QUIC_API_VERSION_1 1 // Not supported any more +#define QUIC_API_VERSION_2 2 // Current latest + +#if defined(_KERNEL_MODE) && !defined(_WIN64) + +// +// 32 bit kernel mode is no longer supported, so shim behavior in 32 bit kernel +// mode +// +#define MsQuicClose(QuicApi) UNREFERENCED_PARAMETER((QuicApi)) +#define MsQuicOpenVersion(Version, QuicApi) QUIC_STATUS_NOT_SUPPORTED + +#else + +// +// Opens the API library and initializes it if this is the first call for the +// process. It returns API function table for the rest of the API's functions. +// MsQuicClose must be called when the app is done with the function table. +// +_IRQL_requires_max_(PASSIVE_LEVEL) +_Check_return_ +#if (__cplusplus >= 201703L || _MSVC_LANG >= 201703L) +[[nodiscard]] +#endif +QUIC_STATUS +QUIC_API +MsQuicOpenVersion( + _In_ uint32_t Version, + _Out_ _Pre_defensive_ const void** QuicApi + ); + +// +// Cleans up the function table returned from MsQuicOpenVersion and releases the +// reference on the API. +// +_IRQL_requires_max_(PASSIVE_LEVEL) +void +QUIC_API +MsQuicClose( + _In_ _Pre_defensive_ const void* QuicApi + ); + +#endif + +_IRQL_requires_max_(PASSIVE_LEVEL) +_Check_return_ +typedef +QUIC_STATUS +(QUIC_API *MsQuicOpenVersionFn)( + _In_ uint32_t Version, + _Out_ _Pre_defensive_ const void** QuicApi + ); + +_IRQL_requires_max_(PASSIVE_LEVEL) +typedef +void +(QUIC_API *MsQuicCloseFn)( + _In_ _Pre_defensive_ const void* QuicApi + ); + +#ifdef _KERNEL_MODE + +DECLSPEC_SELECTANY GUID MSQUIC_NPI_ID = { + 0xC43138E3, 0xCD13, 0x4CB1, { 0x9C, 0xAE, 0xE0, 0x05, 0xC8, 0x55, 0x7A, 0xBA } +}; // C43138E3-CD13-4CB1-9CAE-E005C8557ABA + +DECLSPEC_SELECTANY GUID MSQUIC_MODULE_ID = { + 0x698F7C72, 0xC2E6, 0x49CD, { 0x8C, 0x39, 0x98, 0x85, 0x1D, 0x50, 0x19, 0x01 } +}; // 698F7C72-C2E6-49CD-8C39-98851D501901 + +typedef struct MSQUIC_NMR_DISPATCH { + uint16_t Version; + uint16_t Reserved; + MsQuicOpenVersionFn OpenVersion; + MsQuicCloseFn Close; +} MSQUIC_NMR_DISPATCH; + +// +// Stores the internal NMR client state. It's meant to be opaque to the users. +// +typedef struct __MSQUIC_NMR_CLIENT { + NPI_CLIENT_CHARACTERISTICS NpiClientCharacteristics; + LONG BindingCount; + HANDLE NmrClientHandle; + NPI_MODULEID ModuleId; + KEVENT RegistrationCompleteEvent; + MSQUIC_NMR_DISPATCH* ProviderDispatch; + BOOLEAN Deleting; +} __MSQUIC_NMR_CLIENT; + +#define QUIC_GET_DISPATCH(h) (((__MSQUIC_NMR_CLIENT*)(h))->ProviderDispatch) + +static +NTSTATUS +__MsQuicClientAttachProvider( + _In_ HANDLE NmrBindingHandle, + _In_ void *ClientContext, + _In_ const NPI_REGISTRATION_INSTANCE *ProviderRegistrationInstance + ) +{ + UNREFERENCED_PARAMETER(ProviderRegistrationInstance); + + NTSTATUS Status; + __MSQUIC_NMR_CLIENT* Client = (__MSQUIC_NMR_CLIENT*)ClientContext; + void* ProviderContext; + + if (InterlockedIncrement(&Client->BindingCount) == 1) { + #pragma warning(suppress:6387) // _Param_(2) could be '0' - by design. + Status = + NmrClientAttachProvider( + NmrBindingHandle, + Client, + NULL, + &ProviderContext, + (const void**)&Client->ProviderDispatch); + if (NT_SUCCESS(Status)) { + KeSetEvent(&Client->RegistrationCompleteEvent, IO_NO_INCREMENT, FALSE); + } else { + InterlockedDecrement(&Client->BindingCount); + } + } else { + Status = STATUS_NOINTERFACE; + } + + return Status; +} + +static +NTSTATUS +__MsQuicClientDetachProvider( + _In_ void *ClientBindingContext + ) +{ + __MSQUIC_NMR_CLIENT* Client = (__MSQUIC_NMR_CLIENT*)ClientBindingContext; + if (InterlockedOr8((char*)&Client->Deleting, 1)) { + return STATUS_SUCCESS; + } else { + return STATUS_PENDING; + } +} + +_IRQL_requires_max_(PASSIVE_LEVEL) +__forceinline +void +MsQuicNmrClientDeregister( + _Inout_ HANDLE* ClientHandle + ) +{ + __MSQUIC_NMR_CLIENT* Client = (__MSQUIC_NMR_CLIENT*)(*ClientHandle); + + if (InterlockedOr8((char*)&Client->Deleting, 1)) { + // + // We are already in the middle of detaching the client. + // Complete it now. + // + NmrClientDetachProviderComplete(Client->NmrClientHandle); + } + + if (Client->NmrClientHandle) { + if (NmrDeregisterClient(Client->NmrClientHandle) == STATUS_PENDING) { + // + // Wait for the deregistration to complete. + // + NmrWaitForClientDeregisterComplete(Client->NmrClientHandle); + } + Client->NmrClientHandle = NULL; + } + + ExFreePoolWithTag(Client, 'cNQM'); + *ClientHandle = NULL; +} + +_IRQL_requires_max_(PASSIVE_LEVEL) +__forceinline +NTSTATUS +MsQuicNmrClientRegister( + _Out_ HANDLE* ClientHandle, + _In_ GUID* ClientModuleId, + _In_ ULONG TimeoutMs // zero = no wait, non-zero = some wait + ) +{ + NPI_REGISTRATION_INSTANCE *ClientRegistrationInstance; + NTSTATUS Status = STATUS_SUCCESS; + + __MSQUIC_NMR_CLIENT* Client = + (__MSQUIC_NMR_CLIENT*)ExAllocatePool2(POOL_FLAG_NON_PAGED, sizeof(*Client), 'cNQM'); + if (Client == NULL) { + Status = STATUS_INSUFFICIENT_RESOURCES; + goto Exit; + } + + KeInitializeEvent(&Client->RegistrationCompleteEvent, SynchronizationEvent, FALSE); + + Client->ModuleId.Length = sizeof(Client->ModuleId); + Client->ModuleId.Type = MIT_GUID; + Client->ModuleId.Guid = *ClientModuleId; + + Client->NpiClientCharacteristics.Length = sizeof(Client->NpiClientCharacteristics); + Client->NpiClientCharacteristics.ClientAttachProvider = __MsQuicClientAttachProvider; + Client->NpiClientCharacteristics.ClientDetachProvider = __MsQuicClientDetachProvider; + + ClientRegistrationInstance = &Client->NpiClientCharacteristics.ClientRegistrationInstance; + ClientRegistrationInstance->Size = sizeof(*ClientRegistrationInstance); + ClientRegistrationInstance->Version = 0; + ClientRegistrationInstance->NpiId = &MSQUIC_NPI_ID; + ClientRegistrationInstance->ModuleId = &Client->ModuleId; + + Status = + NmrRegisterClient( + &Client->NpiClientCharacteristics, Client, &Client->NmrClientHandle); + if (!NT_SUCCESS(Status)) { + goto Exit; + } + + LARGE_INTEGER Timeout; + Timeout.QuadPart = UInt32x32To64(TimeoutMs, 10000); + Timeout.QuadPart = -Timeout.QuadPart; + + Status = + KeWaitForSingleObject( + &Client->RegistrationCompleteEvent, + Executive, KernelMode, FALSE, + &Timeout); + if (Status != STATUS_SUCCESS) { + Status = STATUS_UNSUCCESSFUL; + goto Exit; + } + + *ClientHandle = Client; + +Exit: + if (!NT_SUCCESS(Status) && Client != NULL) { + MsQuicNmrClientDeregister((HANDLE*)&Client); + } + + return Status; +} + +#endif + +// +// Version specific helpers that wrap MsQuicOpenVersion. +// + +#if defined(__cplusplus) + +_IRQL_requires_max_(PASSIVE_LEVEL) +_Check_return_ +#if (__cplusplus >= 201703L || _MSVC_LANG >= 201703L) +[[nodiscard]] +#endif +#ifdef WIN32 +__forceinline +#else +__attribute__((always_inline)) QUIC_INLINE +#endif +QUIC_STATUS +MsQuicOpen2( + _Out_ _Pre_defensive_ const QUIC_API_TABLE** QuicApi + ) +{ + return MsQuicOpenVersion(QUIC_API_VERSION_2, (const void**)QuicApi); +} + +#else + +#define MsQuicOpen2(QuicApi) MsQuicOpenVersion(2, (const void**)QuicApi) + +#endif // defined(__cplusplus) + +#if defined(__cplusplus) +} +#endif + +#endif // _MSQUIC_ diff --git a/dependency/x86/third_party/msquic/v2.5.9/include/msquic_posix.h b/dependency/x86/third_party/msquic/v2.5.9/include/msquic_posix.h new file mode 100644 index 00000000..14358e5a --- /dev/null +++ b/dependency/x86/third_party/msquic/v2.5.9/include/msquic_posix.h @@ -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 +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "quic_sal_stub.h" + +#define QUIC_INLINE static inline + +#ifdef __cplusplus +extern "C++" { +template 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 +template struct _ENUM_FLAG_SIZED_INTEGER +{ + typedef typename _ENUM_FLAG_INTEGER_FOR_SIZE::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::type)a) | ((_ENUM_FLAG_SIZED_INTEGER::type)b)); } \ +QUIC_INLINE ENUMTYPE &operator |= (ENUMTYPE &a, ENUMTYPE b) throw() { return (ENUMTYPE &)(((_ENUM_FLAG_SIZED_INTEGER::type &)a) |= ((_ENUM_FLAG_SIZED_INTEGER::type)b)); } \ +QUIC_INLINE ENUMTYPE operator & (ENUMTYPE a, ENUMTYPE b) throw() { return ENUMTYPE(((_ENUM_FLAG_SIZED_INTEGER::type)a) & ((_ENUM_FLAG_SIZED_INTEGER::type)b)); } \ +QUIC_INLINE ENUMTYPE &operator &= (ENUMTYPE &a, ENUMTYPE b) throw() { return (ENUMTYPE &)(((_ENUM_FLAG_SIZED_INTEGER::type &)a) &= ((_ENUM_FLAG_SIZED_INTEGER::type)b)); } \ +QUIC_INLINE ENUMTYPE operator ~ (ENUMTYPE a) throw() { return ENUMTYPE(~((_ENUM_FLAG_SIZED_INTEGER::type)a)); } \ +QUIC_INLINE ENUMTYPE operator ^ (ENUMTYPE a, ENUMTYPE b) throw() { return ENUMTYPE(((_ENUM_FLAG_SIZED_INTEGER::type)a) ^ ((_ENUM_FLAG_SIZED_INTEGER::type)b)); } \ +QUIC_INLINE ENUMTYPE &operator ^= (ENUMTYPE &a, ENUMTYPE b) throw() { return (ENUMTYPE &)(((_ENUM_FLAG_SIZED_INTEGER::type &)a) ^= ((_ENUM_FLAG_SIZED_INTEGER::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 +#include + +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 +#include + +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 diff --git a/dependency/x86/third_party/msquic/v2.5.9/include/quic_sal_stub.h b/dependency/x86/third_party/msquic/v2.5.9/include/quic_sal_stub.h new file mode 100644 index 00000000..1dba4882 --- /dev/null +++ b/dependency/x86/third_party/msquic/v2.5.9/include/quic_sal_stub.h @@ -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 diff --git a/dependency/x86/third_party/msquic/v2.5.9/lib/libmsquic.so b/dependency/x86/third_party/msquic/v2.5.9/lib/libmsquic.so new file mode 120000 index 00000000..df69289a --- /dev/null +++ b/dependency/x86/third_party/msquic/v2.5.9/lib/libmsquic.so @@ -0,0 +1 @@ +libmsquic.so.2 \ No newline at end of file diff --git a/dependency/x86/third_party/msquic/v2.5.9/lib/libmsquic.so.2 b/dependency/x86/third_party/msquic/v2.5.9/lib/libmsquic.so.2 new file mode 120000 index 00000000..1da2a4c9 --- /dev/null +++ b/dependency/x86/third_party/msquic/v2.5.9/lib/libmsquic.so.2 @@ -0,0 +1 @@ +libmsquic.so.2.5.9 \ No newline at end of file diff --git a/dependency/x86/third_party/msquic/v2.5.9/lib/libmsquic.so.2.5.9 b/dependency/x86/third_party/msquic/v2.5.9/lib/libmsquic.so.2.5.9 new file mode 100644 index 00000000..566bc42a Binary files /dev/null and b/dependency/x86/third_party/msquic/v2.5.9/lib/libmsquic.so.2.5.9 differ diff --git a/dependency/x86/third_party/msquic/v2.5.9/share/licenses/msquic/LICENSE b/dependency/x86/third_party/msquic/v2.5.9/share/licenses/msquic/LICENSE new file mode 100644 index 00000000..63447fd8 --- /dev/null +++ b/dependency/x86/third_party/msquic/v2.5.9/share/licenses/msquic/LICENSE @@ -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. \ No newline at end of file diff --git a/dependency/x86/third_party/msquic/v2.5.9/share/licenses/msquic/THIRD-PARTY-NOTICES b/dependency/x86/third_party/msquic/v2.5.9/share/licenses/msquic/THIRD-PARTY-NOTICES new file mode 100644 index 00000000..274f37d4 --- /dev/null +++ b/dependency/x86/third_party/msquic/v2.5.9/share/licenses/msquic/THIRD-PARTY-NOTICES @@ -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 diff --git a/protos/cmvr/config/quic_edge_config/quic_edge_config.proto b/protos/cmvr/config/quic_edge_config/quic_edge_config.proto index 819e50b2..6b7c597b 100644 --- a/protos/cmvr/config/quic_edge_config/quic_edge_config.proto +++ b/protos/cmvr/config/quic_edge_config/quic_edge_config.proto @@ -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; diff --git a/protos/cmvr/quic_edge/v1/README.md b/protos/cmvr/quic_edge/v1/README.md index ceb742e9..00b00746 100644 --- a/protos/cmvr/quic_edge/v1/README.md +++ b/protos/cmvr/quic_edge/v1/README.md @@ -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//third_party/msquic/` 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//third_party/msquic/v` 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 diff --git a/protos/cmvr/quic_edge/v1/quic_edge.proto b/protos/cmvr/quic_edge/v1/quic_edge.proto index b4f03a89..3dca447c 100644 --- a/protos/cmvr/quic_edge/v1/quic_edge.proto +++ b/protos/cmvr/quic_edge/v1/quic_edge.proto @@ -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 { diff --git a/request.txt b/request.txt index 2c11ca20..91070004 100644 --- a/request.txt +++ b/request.txt @@ -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 - diff --git a/script/build_msquic.sh b/script/build_msquic.sh new file mode 100755 index 00000000..39a95b00 --- /dev/null +++ b/script/build_msquic.sh @@ -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//third_party/msquic/v + +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" </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" diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt new file mode 100644 index 00000000..cd4c56f0 --- /dev/null +++ b/test/CMakeLists.txt @@ -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() diff --git a/test/README.md b/test/README.md new file mode 100644 index 00000000..c7ddb718 --- /dev/null +++ b/test/README.md @@ -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 和协议联调;生产平台仍应独立实现鉴权、节点状态持久化、媒体转发和 +浏览器接入。 diff --git a/test/e2e/CMakeLists.txt b/test/e2e/CMakeLists.txt new file mode 100644 index 00000000..88649bd6 --- /dev/null +++ b/test/e2e/CMakeLists.txt @@ -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 "$" + --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() diff --git a/test/e2e/README.md b/test/e2e/README.md new file mode 100644 index 00000000..aef2ff1b --- /dev/null +++ b/test/e2e/README.md @@ -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,不得用于生产环境。 diff --git a/test/e2e/quic_msquic_e2e_test.cpp b/test/e2e/quic_msquic_e2e_test.cpp new file mode 100644 index 00000000..241519b3 --- /dev/null +++ b/test/e2e/quic_msquic_e2e_test.cpp @@ -0,0 +1,804 @@ +#include "quic_test_gateway.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#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 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(json[position]))) { + ++position; + } + if (position == json.size() || + !std::isdigit(static_cast(json[position]))) { + return std::nullopt; + } + + std::uint64_t value = 0U; + while (position < json.size() && + std::isdigit(static_cast(json[position]))) { + const std::uint64_t digit = + static_cast(json[position] - '0'); + if (value > + (std::numeric_limits::max() - digit) / 10U) { + return std::nullopt; + } + value = value * 10U + digit; + ++position; + } + return value; +} + +std::optional 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 +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::chrono::duration_cast( + 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 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(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 h264Payload(const std::uint8_t fill) +{ + std::vector payload(2500U, fill); + payload[0] = 0x00U; + payload[1] = 0x00U; + payload[2] = 0x00U; + payload[3] = 0x01U; + payload[4] = 0x65U; // IDR slice. + return payload; +} + +std::vector aacPayload(const std::uint8_t fill) +{ + std::vector payload(512U, fill); + // AAC-LC, 48 kHz, stereo ADTS header for a synthetic access unit. + const std::uint16_t frame_length = + static_cast(payload.size()); + payload[0] = 0xFFU; + payload[1] = 0xF1U; + payload[2] = 0x4CU; + payload[3] = static_cast( + 0x80U | ((frame_length >> 11U) & 0x03U)); + payload[4] = static_cast((frame_length >> 3U) & 0xFFU); + payload[5] = static_cast( + ((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 expected_video = h264Payload(0x33U); + const std::vector 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; + } +} diff --git a/test/e2e/run_cmvr_es_quic_smoke.py b/test/e2e/run_cmvr_es_quic_smoke.py new file mode 100644 index 00000000..a1a56b88 --- /dev/null +++ b/test/e2e/run_cmvr_es_quic_smoke.py @@ -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()) diff --git a/test/quic_gateway/CMakeLists.txt b/test/quic_gateway/CMakeLists.txt new file mode 100644 index 00000000..7cdc57d8 --- /dev/null +++ b/test/quic_gateway/CMakeLists.txt @@ -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() diff --git a/test/quic_gateway/README.md b/test/quic_gateway/README.md new file mode 100644 index 00000000..9965204b --- /dev/null +++ b/test/quic_gateway/README.md @@ -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 暴露到不受信任网络; +- 不要复用或提交生产证书、生产私钥; +- 该工具没有生产级认证、授权、持久化和多租户隔离。 diff --git a/test/quic_gateway/include/media_reassembler.h b/test/quic_gateway/include/media_reassembler.h new file mode 100644 index 00000000..2b846ed4 --- /dev/null +++ b/test/quic_gateway/include/media_reassembler.h @@ -0,0 +1,103 @@ +#ifndef CMVR_ES_TEST_QUIC_GATEWAY_MEDIA_REASSEMBLER_H +#define CMVR_ES_TEST_QUIC_GATEWAY_MEDIA_REASSEMBLER_H + +#include +#include +#include +#include +#include +#include + +#include "service/quic_edge/include/quic_edge_types.h" + +namespace cmvr::test::quic_gateway { + +struct ReassembledFrame { + cmvr::quic_edge::DatagramHeader header; + std::vector payload; +}; + +struct ReassemblyResult { + enum class Status { + ACCEPTED, + DUPLICATE, + COMPLETED, + INVALID, + CAPACITY_DROPPED, + }; + + Status status{Status::INVALID}; + std::optional 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 payload; + }; + + struct PartialFrame { + cmvr::quic_edge::DatagramHeader first_header; + std::unordered_map 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::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 frames_; +}; + +} // namespace cmvr::test::quic_gateway + +#endif // CMVR_ES_TEST_QUIC_GATEWAY_MEDIA_REASSEMBLER_H diff --git a/test/quic_gateway/include/quic_test_gateway.h b/test/quic_gateway/include/quic_test_gateway.h new file mode 100644 index 00000000..ae31da34 --- /dev/null +++ b/test/quic_gateway/include/quic_test_gateway.h @@ -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 +#include +#include +#include + +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_; +}; + +} // namespace cmvr::test::quic_gateway + +#endif // CMVR_ES_TEST_QUIC_GATEWAY_QUIC_TEST_GATEWAY_H diff --git a/test/quic_gateway/run_gateway.sh.in b/test/quic_gateway/run_gateway.sh.in new file mode 100644 index 00000000..ff25ec0f --- /dev/null +++ b/test/quic_gateway/run_gateway.sh.in @@ -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" "$@" diff --git a/test/quic_gateway/src/main.cpp b/test/quic_gateway/src/main.cpp new file mode 100644 index 00000000..3fc99be5 --- /dev/null +++ b/test/quic_gateway/src/main.cpp @@ -0,0 +1,263 @@ +#include "quic_test_gateway.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +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(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(number); + } else if (argument == "--heartbeat-interval-ms") { + if (!parseUnsigned( + value, std::numeric_limits::max(), + &number)) { + if (error) *error = "invalid heartbeat interval: " + value; + return false; + } + options->heartbeat_interval_ms = + static_cast(number); + } else if (argument == "--exit-after-registrations") { + if (!parseUnsigned( + value, std::numeric_limits::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::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::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::max(), + &number) || number == 0U) { + if (error) *error = "invalid control limit: " + value; + return false; + } + options->maximum_control_frame_bytes = + static_cast(number); + } else if (argument == "--max-reassembly-bytes") { + if (!parseUnsigned( + value, std::numeric_limits::max(), + &number) || number == 0U) { + if (error) *error = "invalid reassembly byte limit: " + value; + return false; + } + options->maximum_reassembly_bytes = + static_cast(number); + } else if (argument == "--max-reassembly-frames") { + if (!parseUnsigned( + value, std::numeric_limits::max(), + &number) || number == 0U) { + if (error) *error = "invalid reassembly frame limit: " + value; + return false; + } + options->maximum_reassembly_frames = + static_cast(number); + } else if (argument == "--max-frame-bytes") { + if (!parseUnsigned( + value, std::numeric_limits::max(), + &number) || number == 0U) { + if (error) *error = "invalid frame limit: " + value; + return false; + } + options->maximum_frame_bytes = static_cast(number); + } else if (argument == "--max-work-queue-bytes") { + if (!parseUnsigned( + value, std::numeric_limits::max(), + &number) || number == 0U) { + if (error) *error = "invalid work queue limit: " + value; + return false; + } + options->maximum_work_queue_bytes = + static_cast(number); + } else if (argument == "--reassembly-timeout-ms") { + if (!parseUnsigned( + value, std::numeric_limits::max(), + &number) || number == 0U) { + if (error) *error = "invalid reassembly timeout: " + value; + return false; + } + options->reassembly_timeout_ms = + static_cast(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; +} diff --git a/test/quic_gateway/src/media_reassembler.cpp b/test/quic_gateway/src/media_reassembler.cpp new file mode 100644 index 00000000..bacb3d7e --- /dev/null +++ b/test/quic_gateway/src/media_reassembler.cpp @@ -0,0 +1,258 @@ +#include "media_reassembler.h" + +#include +#include + +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(left_offset) + left_size; + const std::uint64_t right_end = + static_cast(right_offset) + right_size; + return static_cast(left_offset) < right_end && + static_cast(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(1U, maximum_bytes)), + maximum_frames_(std::max(1U, maximum_frames)), + maximum_frame_bytes_(std::max(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{}(key.session_epoch); + value ^= std::hash{}(key.track_id) + + 0x9e3779b9U + (value << 6U) + (value >> 2U); + value ^= std::hash{}(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::iterator it) +{ + if (it == frames_.end()) return; + buffered_bytes_ -= it->second.received_bytes; + frames_.erase(it); +} + +} // namespace cmvr::test::quic_gateway diff --git a/test/quic_gateway/src/quic_test_gateway.cpp b/test/quic_gateway/src/quic_test_gateway.cpp new file mode 100644 index 00000000..cb1a3bd3 --- /dev/null +++ b/test/quic_gateway/src/quic_test_gateway.cpp @@ -0,0 +1,2131 @@ +#include "quic_test_gateway.h" + +#include "media_reassembler.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "cmvr/quic_edge/v1/quic_edge.pb.h" +#include "service/quic_edge/include/control_framing.h" +#include "service/quic_edge/include/datagram_packetizer.h" + +namespace cmvr::test::quic_gateway { +namespace { + +using Envelope = cmvr::quic_edge::v1::EdgeControlEnvelope; +using Clock = std::chrono::steady_clock; + +constexpr QUIC_UINT62 kApplicationShutdownCode = 0x100U; +constexpr QUIC_UINT62 kProtocolViolationCode = 0x101U; +constexpr std::uint64_t kProtocolErrorCode = 1U; +constexpr std::uint64_t kDefaultIdleTimeoutMs = 120000U; +constexpr std::uint64_t kFnv1a64OffsetBasis = 14695981039346656037ULL; +constexpr std::uint64_t kFnv1a64Prime = 1099511628211ULL; + +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; +} + +std::uint64_t fnv1a64(const std::string& data) +{ + return fnv1a64( + reinterpret_cast(data.data()), data.size()); +} + +struct TrackDescriptorObservation { + std::uint32_t track_id{0U}; + std::string device_id; + std::string source_track_id; + std::string codec; + std::string payload_format; + std::uint64_t codec_generation{0U}; + std::uint32_t codec_generation_token{0U}; + std::uint32_t width{0U}; + std::uint32_t height{0U}; + std::uint32_t frames_per_second{0U}; + std::uint32_t sample_rate{0U}; + std::uint32_t channels{0U}; + std::size_t codec_config_bytes{0U}; + std::uint64_t codec_config_hash{kFnv1a64OffsetBasis}; +}; + +struct CompletedFrameObservation { + bool present{false}; + std::uint32_t track_id{0U}; + std::uint64_t frame_sequence{0U}; + std::size_t frame_bytes{0U}; + std::uint64_t frame_hash{kFnv1a64OffsetBasis}; + std::uint16_t flags{0U}; + std::uint64_t capture_timestamp_us{0U}; +}; + +std::string jsonEscape(const std::string& input) +{ + std::ostringstream output; + for (const unsigned char character : input) { + switch (character) { + case '"': output << "\\\""; break; + case '\\': output << "\\\\"; break; + case '\b': output << "\\b"; break; + case '\f': output << "\\f"; break; + case '\n': output << "\\n"; break; + case '\r': output << "\\r"; break; + case '\t': output << "\\t"; break; + default: + if (character < 0x20U) { + output << "\\u" + << std::hex << std::setw(4) << std::setfill('0') + << static_cast(character) + << std::dec << std::setfill(' '); + } else { + output << static_cast(character); + } + break; + } + } + return output.str(); +} + +std::string statusText(const char* operation, const QUIC_STATUS status) +{ + std::ostringstream output; + output << operation << " failed with QUIC_STATUS 0x" + << std::hex << static_cast(status); + return output.str(); +} + +std::string addressWithPort(const QUIC_ADDR& address) +{ + QUIC_ADDR_STR text{}; + if (QuicAddrToString(&address, &text) == FALSE) return {}; + return text.Address; +} + +std::string addressWithoutPort(const QUIC_ADDR& address) +{ + char text[INET6_ADDRSTRLEN] = {}; + const void* bytes = nullptr; + int family = AF_UNSPEC; + if (QuicAddrGetFamily(&address) == QUIC_ADDRESS_FAMILY_INET) { + family = AF_INET; + bytes = &address.Ipv4.sin_addr; + } else if (QuicAddrGetFamily(&address) == QUIC_ADDRESS_FAMILY_INET6) { + family = AF_INET6; + bytes = &address.Ipv6.sin6_addr; + } + if (!bytes || !inet_ntop(family, bytes, text, sizeof(text))) return {}; + return text; +} + +bool writeTextFile(const std::string& path, + const std::string& contents, + std::string* error) +{ + if (path.empty()) return true; + std::ofstream output(path, std::ios::out | std::ios::trunc); + if (!output) { + if (error) *error = "failed to open output file: " + path; + return false; + } + output << contents << '\n'; + output.flush(); + if (!output) { + if (error) *error = "failed to write output file: " + path; + return false; + } + return true; +} + +} // namespace + +const char* scenarioName(const Scenario scenario) +{ + switch (scenario) { + case Scenario::NORMAL: return "normal"; + case Scenario::REJECT_REGISTRATION: return "reject-registration"; + case Scenario::DROP_HEARTBEAT_ACK: return "drop-heartbeat-ack"; + case Scenario::WRONG_ACK_SESSION: return "wrong-ack-session"; + case Scenario::FATAL_PROTOCOL_ERROR: return "fatal-protocol-error"; + case Scenario::NONFATAL_PROTOCOL_ERROR: return "nonfatal-protocol-error"; + case Scenario::DATAGRAM_DISABLED: return "datagram-disabled"; + } + return "unknown"; +} + +bool parseScenario(const std::string& text, Scenario* scenario) +{ + if (!scenario) return false; + static const std::pair values[] = { + {"normal", Scenario::NORMAL}, + {"reject-registration", Scenario::REJECT_REGISTRATION}, + {"drop-heartbeat-ack", Scenario::DROP_HEARTBEAT_ACK}, + {"wrong-ack-session", Scenario::WRONG_ACK_SESSION}, + {"fatal-protocol-error", Scenario::FATAL_PROTOCOL_ERROR}, + {"nonfatal-protocol-error", Scenario::NONFATAL_PROTOCOL_ERROR}, + {"datagram-disabled", Scenario::DATAGRAM_DISABLED}, + }; + for (const auto& value : values) { + if (text == value.first) { + *scenario = value.second; + return true; + } + } + return false; +} + +class QuicTestGateway::Impl { +public: + explicit Impl(GatewayOptions options) + : options_(std::move(options)) + { + } + + ~Impl() + { + stop(); + } + + bool start(std::string* error) + { + std::lock_guard lifecycle_lock(lifecycle_mutex_); + if (started_.load()) return true; + if (start_attempted_ || stopped_) { + if (error) { + *error = + "QUIC test gateway instances are single-use"; + } + return false; + } + start_attempted_ = true; + if (!validateOptions(error)) return false; + + QUIC_STATUS status = MsQuicOpen2(&api_); + if (QUIC_FAILED(status)) { + return failStart(statusText("MsQuicOpen2", status), error); + } + + const QUIC_REGISTRATION_CONFIG registration_config = { + "cmvr-quic-test-gateway", QUIC_EXECUTION_PROFILE_LOW_LATENCY}; + status = api_->RegistrationOpen(®istration_config, ®istration_); + if (QUIC_FAILED(status)) { + return failStart(statusText("RegistrationOpen", status), error); + } + + QUIC_BUFFER alpn{}; + alpn.Buffer = reinterpret_cast( + const_cast(options_.alpn.data())); + alpn.Length = static_cast(options_.alpn.size()); + + QUIC_SETTINGS settings{}; + settings.IsSet.PeerBidiStreamCount = TRUE; + settings.PeerBidiStreamCount = 1U; + settings.IsSet.PeerUnidiStreamCount = TRUE; + settings.PeerUnidiStreamCount = 0U; + settings.IsSet.IdleTimeoutMs = TRUE; + settings.IdleTimeoutMs = kDefaultIdleTimeoutMs; + settings.IsSet.DatagramReceiveEnabled = TRUE; + settings.DatagramReceiveEnabled = + options_.scenario == Scenario::DATAGRAM_DISABLED ? FALSE : TRUE; + + status = api_->ConfigurationOpen( + registration_, &alpn, 1U, &settings, sizeof(settings), + nullptr, &configuration_); + if (QUIC_FAILED(status)) { + return failStart(statusText("ConfigurationOpen", status), error); + } + + QUIC_CERTIFICATE_FILE certificate{}; + certificate.PrivateKeyFile = options_.private_key_file.c_str(); + certificate.CertificateFile = options_.certificate_file.c_str(); + QUIC_CREDENTIAL_CONFIG credential{}; + credential.Type = QUIC_CREDENTIAL_TYPE_CERTIFICATE_FILE; + credential.Flags = QUIC_CREDENTIAL_FLAG_NONE; + credential.CertificateFile = &certificate; + status = api_->ConfigurationLoadCredential( + configuration_, &credential); + if (QUIC_FAILED(status)) { + return failStart( + statusText("ConfigurationLoadCredential", status), error); + } + + status = api_->ListenerOpen( + registration_, &Impl::listenerCallback, this, &listener_); + if (QUIC_FAILED(status)) { + return failStart(statusText("ListenerOpen", status), error); + } + + QUIC_ADDR address{}; + if (QuicAddrFromString( + options_.bind_address.c_str(), options_.port, &address) == FALSE) { + return failStart( + "bind address must be a numeric IPv4 or IPv6 address", error); + } + + startWorker(); + status = api_->ListenerStart(listener_, &alpn, 1U, &address); + if (QUIC_FAILED(status)) { + return failStart(statusText("ListenerStart", status), error); + } + listener_started_ = true; + + QUIC_ADDR bound_address{}; + std::uint32_t bound_address_size = sizeof(bound_address); + status = api_->GetParam( + listener_, QUIC_PARAM_LISTENER_LOCAL_ADDRESS, + &bound_address_size, &bound_address); + if (QUIC_FAILED(status) || bound_address_size != sizeof(bound_address)) { + return failStart(statusText( + "GetParam(QUIC_PARAM_LISTENER_LOCAL_ADDRESS)", status), error); + } + bound_port_.store(QuicAddrGetPort(&bound_address)); + bound_address_text_ = addressWithPort(bound_address); + started_at_ = Clock::now(); + started_.store(true); + + const std::string ready = readyJson(); + logJson(ready); + std::string file_error; + if (!writeTextFile(options_.ready_file, ready, &file_error)) { + return failStart(file_error, error); + } + return true; + } + + void stop() + { + std::unique_lock lifecycle_lock(lifecycle_mutex_); + if (stopped_) return; + stopped_ = true; + started_.store(false); + + if (listener_ && listener_started_ && api_) { + api_->ListenerStop(listener_); + std::unique_lock listener_lock(listener_mutex_); + listener_condition_.wait_for( + listener_lock, std::chrono::seconds(5), + [this]() { return listener_stop_complete_; }); + listener_started_ = false; + } + + if (registration_ && api_) { + api_->RegistrationShutdown( + registration_, QUIC_CONNECTION_SHUTDOWN_FLAG_NONE, + kApplicationShutdownCode); + } + + { + std::unique_lock connection_lock(connection_mutex_); + connection_condition_.wait(connection_lock, [this]() { + return connections_.empty() && live_connection_contexts_ == 0U; + }); + } + + stopWorker(); + + if (listener_ && api_) { + api_->ListenerClose(listener_); + listener_ = nullptr; + } + if (configuration_ && api_) { + api_->ConfigurationClose(configuration_); + configuration_ = nullptr; + } + if (registration_ && api_) { + api_->RegistrationClose(registration_); + registration_ = nullptr; + } + if (api_) { + MsQuicClose(api_); + api_ = nullptr; + } + + const std::string summary = summaryJson(); + logJson(summary); + std::string file_error; + if (!writeTextFile(options_.summary_file, summary, &file_error)) { + setRuntimeFailure(file_error); + std::cerr << file_error << '\n'; + } + } + + std::uint16_t boundPort() const + { + return bound_port_.load(); + } + + bool completionReached() const + { + if (options_.exit_after_registrations != 0U && + stats_.registrations_received.load() >= + options_.exit_after_registrations) { + return true; + } + if (options_.exit_after_heartbeats != 0U && + stats_.heartbeats_received.load() >= + options_.exit_after_heartbeats) { + return true; + } + if (options_.run_for_ms != 0U && started_.load() && + Clock::now() - started_at_ >= + std::chrono::milliseconds(options_.run_for_ms)) { + return true; + } + return false; + } + + bool hasRuntimeFailure() const + { + return runtime_failed_.load(); + } + + std::string lastError() const + { + std::lock_guard lock(error_mutex_); + return last_error_; + } + + std::string summaryJson() const + { + std::string last_node_id; + std::string last_boot_id; + std::string last_session_id; + std::string last_grpc_endpoint_host; + std::string last_observed_source_ip; + std::uint32_t last_grpc_endpoint_port = 0U; + std::uint64_t registration_interface_count = 0U; + std::uint64_t heartbeat_interface_count = 0U; + bool heartbeat_has_device_manager = false; + std::string heartbeat_device_manager_name; + std::string heartbeat_device_manager_version; + std::string heartbeat_device_manager_description; + std::uint64_t heartbeat_device_count = 0U; + std::uint64_t heartbeat_enabled_device_count = 0U; + std::uint64_t heartbeat_disabled_device_count = 0U; + std::uint64_t heartbeat_error_device_count = 0U; + std::uint64_t heartbeat_unknown_health_device_count = 0U; + std::vector + heartbeat_devices; + TrackDescriptorObservation video_descriptor; + TrackDescriptorObservation audio_descriptor; + CompletedFrameObservation video_frame; + CompletedFrameObservation audio_frame; + { + std::lock_guard lock(observation_mutex_); + last_node_id = last_node_id_; + last_boot_id = last_boot_id_; + last_session_id = last_session_id_; + last_grpc_endpoint_host = last_grpc_endpoint_host_; + last_grpc_endpoint_port = last_grpc_endpoint_port_; + last_observed_source_ip = last_observed_source_ip_; + registration_interface_count = registration_interface_count_; + heartbeat_interface_count = heartbeat_interface_count_; + heartbeat_has_device_manager = heartbeat_has_device_manager_; + heartbeat_device_manager_name = heartbeat_device_manager_name_; + heartbeat_device_manager_version = + heartbeat_device_manager_version_; + heartbeat_device_manager_description = + heartbeat_device_manager_description_; + heartbeat_device_count = heartbeat_device_count_; + heartbeat_enabled_device_count = + heartbeat_enabled_device_count_; + heartbeat_disabled_device_count = + heartbeat_disabled_device_count_; + heartbeat_error_device_count = heartbeat_error_device_count_; + heartbeat_unknown_health_device_count = + heartbeat_unknown_health_device_count_; + heartbeat_devices = heartbeat_devices_; + video_descriptor = video_descriptor_; + audio_descriptor = audio_descriptor_; + video_frame = maximum_video_frame_; + audio_frame = maximum_audio_frame_; + } + + std::ostringstream output; + output << "{\"event\":\"summary\"" + << ",\"scenario\":\"" << scenarioName(options_.scenario) << '"' + << ",\"bind\":\"" << jsonEscape(bound_address_text_) << '"' + << ",\"port\":" << bound_port_.load() + << ",\"runtime_failed\":" + << (runtime_failed_.load() ? "true" : "false") + << ",\"last_error\":\"" << jsonEscape(lastError()) << '"' + << ",\"last_node_id\":\"" << jsonEscape(last_node_id) << '"' + << ",\"last_boot_id\":\"" << jsonEscape(last_boot_id) << '"' + << ",\"last_session_id\":\"" + << jsonEscape(last_session_id) << '"' + << ",\"last_grpc_endpoint_host\":\"" + << jsonEscape(last_grpc_endpoint_host) << '"' + << ",\"last_grpc_endpoint_port\":" + << last_grpc_endpoint_port + << ",\"last_observed_source_ip\":\"" + << jsonEscape(last_observed_source_ip) << '"' + << ",\"registration_interface_count\":" + << registration_interface_count + << ",\"heartbeat_interface_count\":" + << heartbeat_interface_count + << ",\"heartbeat_has_device_manager\":" + << (heartbeat_has_device_manager ? "true" : "false") + << ",\"heartbeat_device_manager_name\":\"" + << jsonEscape(heartbeat_device_manager_name) << '"' + << ",\"heartbeat_device_manager_version\":\"" + << jsonEscape(heartbeat_device_manager_version) << '"' + << ",\"heartbeat_device_manager_description\":\"" + << jsonEscape(heartbeat_device_manager_description) << '"' + << ",\"heartbeat_device_count\":" + << heartbeat_device_count + << ",\"heartbeat_enabled_device_count\":" + << heartbeat_enabled_device_count + << ",\"heartbeat_disabled_device_count\":" + << heartbeat_disabled_device_count + << ",\"heartbeat_error_device_count\":" + << heartbeat_error_device_count + << ",\"heartbeat_unknown_health_device_count\":" + << heartbeat_unknown_health_device_count; + output << ",\"heartbeat_devices\":["; + for (std::size_t index = 0U; + index < heartbeat_devices.size(); ++index) { + if (index != 0U) output << ','; + const auto& device = heartbeat_devices[index]; + output << "{\"device_id\":\"" + << jsonEscape(device.device_id()) << '"' + << ",\"kind\":" << static_cast(device.kind()) + << ",\"type_name\":\"" + << jsonEscape(device.type_name()) << '"' + << ",\"enabled\":" + << (device.enabled() ? "true" : "false") + << ",\"manager_state\":" + << static_cast(device.manager_state()) + << ",\"health\":" + << static_cast(device.health()) + << ",\"has_error\":" + << (device.has_error() ? "true" : "false") + << ",\"error_message\":\"" + << jsonEscape(device.error_message()) << '"' + << ",\"status_updated_at_unix_ms\":" + << device.status_updated_at_unix_ms() + << '}'; + } + output << ']' + << ",\"video_track_id\":" << video_descriptor.track_id + << ",\"video_device_id\":\"" + << jsonEscape(video_descriptor.device_id) << '"' + << ",\"video_source_track_id\":\"" + << jsonEscape(video_descriptor.source_track_id) << '"' + << ",\"video_codec\":\"" + << jsonEscape(video_descriptor.codec) << '"' + << ",\"video_payload_format\":\"" + << jsonEscape(video_descriptor.payload_format) << '"' + << ",\"video_codec_generation\":" + << video_descriptor.codec_generation + << ",\"video_codec_generation_token\":" + << video_descriptor.codec_generation_token + << ",\"video_width\":" << video_descriptor.width + << ",\"video_height\":" << video_descriptor.height + << ",\"video_frames_per_second\":" + << video_descriptor.frames_per_second + << ",\"video_codec_config_bytes\":" + << video_descriptor.codec_config_bytes + << ",\"video_codec_config_hash\":" + << video_descriptor.codec_config_hash + << ",\"audio_track_id\":" << audio_descriptor.track_id + << ",\"audio_device_id\":\"" + << jsonEscape(audio_descriptor.device_id) << '"' + << ",\"audio_source_track_id\":\"" + << jsonEscape(audio_descriptor.source_track_id) << '"' + << ",\"audio_codec\":\"" + << jsonEscape(audio_descriptor.codec) << '"' + << ",\"audio_payload_format\":\"" + << jsonEscape(audio_descriptor.payload_format) << '"' + << ",\"audio_codec_generation\":" + << audio_descriptor.codec_generation + << ",\"audio_codec_generation_token\":" + << audio_descriptor.codec_generation_token + << ",\"audio_sample_rate\":" + << audio_descriptor.sample_rate + << ",\"audio_channels\":" << audio_descriptor.channels + << ",\"audio_codec_config_bytes\":" + << audio_descriptor.codec_config_bytes + << ",\"audio_codec_config_hash\":" + << audio_descriptor.codec_config_hash + << ",\"maximum_video_frame_sequence\":" + << video_frame.frame_sequence + << ",\"maximum_video_frame_track_id\":" + << video_frame.track_id + << ",\"maximum_video_frame_bytes\":" + << video_frame.frame_bytes + << ",\"maximum_video_frame_hash\":" + << video_frame.frame_hash + << ",\"maximum_video_frame_flags\":" + << video_frame.flags + << ",\"maximum_video_capture_timestamp_us\":" + << video_frame.capture_timestamp_us + << ",\"maximum_audio_frame_sequence\":" + << audio_frame.frame_sequence + << ",\"maximum_audio_frame_track_id\":" + << audio_frame.track_id + << ",\"maximum_audio_frame_bytes\":" + << audio_frame.frame_bytes + << ",\"maximum_audio_frame_hash\":" + << audio_frame.frame_hash + << ",\"maximum_audio_frame_flags\":" + << audio_frame.flags + << ",\"maximum_audio_capture_timestamp_us\":" + << audio_frame.capture_timestamp_us + << ",\"connections_accepted\":" + << stats_.connections_accepted.load() + << ",\"connections_closed\":" + << stats_.connections_closed.load() + << ",\"registrations_received\":" + << stats_.registrations_received.load() + << ",\"registrations_accepted\":" + << stats_.registrations_accepted.load() + << ",\"registrations_rejected\":" + << stats_.registrations_rejected.load() + << ",\"heartbeats_received\":" + << stats_.heartbeats_received.load() + << ",\"heartbeat_acks_sent\":" + << stats_.heartbeat_acks_sent.load() + << ",\"heartbeat_acks_dropped\":" + << stats_.heartbeat_acks_dropped.load() + << ",\"protocol_errors_sent\":" + << stats_.protocol_errors_sent.load() + << ",\"protocol_violations\":" + << stats_.protocol_violations.load() + << ",\"media_sessions_opened\":" + << stats_.media_sessions_opened.load() + << ",\"track_descriptors_received\":" + << stats_.track_descriptors_received.load() + << ",\"datagrams_received\":" + << stats_.datagrams_received.load() + << ",\"datagrams_invalid\":" + << stats_.datagrams_invalid.load() + << ",\"datagrams_queue_dropped\":" + << stats_.datagrams_queue_dropped.load() + << ",\"frames_completed\":" + << stats_.frames_completed.load() + << ",\"video_frames_completed\":" + << stats_.video_frames_completed.load() + << ",\"audio_frames_completed\":" + << stats_.audio_frames_completed.load() + << ",\"frame_bytes_completed\":" + << stats_.frame_bytes_completed.load() + << ",\"incomplete_frames_expired\":" + << stats_.incomplete_frames_expired.load() + << ",\"incomplete_frames_evicted\":" + << stats_.incomplete_frames_evicted.load() + << ",\"incomplete_frames_cleared\":" + << stats_.incomplete_frames_cleared.load() + << '}'; + return output.str(); + } + +private: + class Connection; + + struct AtomicStats { + std::atomic connections_accepted{0}; + std::atomic connections_closed{0}; + std::atomic registrations_received{0}; + std::atomic registrations_accepted{0}; + std::atomic registrations_rejected{0}; + std::atomic heartbeats_received{0}; + std::atomic heartbeat_acks_sent{0}; + std::atomic heartbeat_acks_dropped{0}; + std::atomic protocol_errors_sent{0}; + std::atomic protocol_violations{0}; + std::atomic media_sessions_opened{0}; + std::atomic track_descriptors_received{0}; + std::atomic datagrams_received{0}; + std::atomic datagrams_invalid{0}; + std::atomic datagrams_queue_dropped{0}; + std::atomic frames_completed{0}; + std::atomic video_frames_completed{0}; + std::atomic audio_frames_completed{0}; + std::atomic frame_bytes_completed{0}; + std::atomic incomplete_frames_expired{0}; + std::atomic incomplete_frames_evicted{0}; + std::atomic incomplete_frames_cleared{0}; + }; + + struct WorkItem { + Connection* connection{nullptr}; + std::vector bytes; + }; + + struct SendContext { + explicit SendContext(std::vector value) + : bytes(std::move(value)) + { + buffer.Buffer = bytes.data(); + buffer.Length = static_cast(bytes.size()); + } + + std::vector bytes; + QUIC_BUFFER buffer{}; + }; + + struct TrackInfo { + cmvr::quic_edge::v1::MediaKind kind{ + cmvr::quic_edge::v1::MEDIA_KIND_UNSPECIFIED}; + std::uint32_t generation_token{0}; + std::uint64_t generation{0}; + std::string codec; + std::string source_track_id; + }; + + void observeTrackDescriptor( + const cmvr::quic_edge::v1::MediaTrackDescriptor& descriptor) + { + TrackDescriptorObservation observation; + observation.track_id = descriptor.track_id(); + observation.device_id = descriptor.device_id(); + observation.source_track_id = descriptor.source_track_id(); + observation.codec = descriptor.codec(); + observation.payload_format = descriptor.payload_format(); + observation.codec_generation = descriptor.codec_generation(); + observation.codec_generation_token = + descriptor.codec_generation_token(); + observation.width = descriptor.width(); + observation.height = descriptor.height(); + observation.frames_per_second = descriptor.frames_per_second(); + observation.sample_rate = descriptor.sample_rate(); + observation.channels = descriptor.channels(); + observation.codec_config_bytes = descriptor.codec_config().size(); + observation.codec_config_hash = fnv1a64(descriptor.codec_config()); + + std::lock_guard lock(observation_mutex_); + if (descriptor.kind() == + cmvr::quic_edge::v1::MEDIA_KIND_VIDEO) { + video_descriptor_ = std::move(observation); + } else if ( + descriptor.kind() == + cmvr::quic_edge::v1::MEDIA_KIND_AUDIO) { + audio_descriptor_ = std::move(observation); + } + } + + void observeCompletedFrame( + const cmvr::quic_edge::v1::MediaKind kind, + const cmvr::quic_edge::DatagramHeader& header, + const std::vector& payload) + { + std::lock_guard lock(observation_mutex_); + CompletedFrameObservation* observation = nullptr; + if (kind == cmvr::quic_edge::v1::MEDIA_KIND_VIDEO) { + observation = &maximum_video_frame_; + } else if (kind == cmvr::quic_edge::v1::MEDIA_KIND_AUDIO) { + observation = &maximum_audio_frame_; + } + if (!observation || + (observation->present && + header.frame_sequence <= observation->frame_sequence)) { + return; + } + observation->present = true; + observation->track_id = header.track_id; + observation->frame_sequence = header.frame_sequence; + observation->frame_bytes = payload.size(); + observation->frame_hash = fnv1a64(payload.data(), payload.size()); + observation->flags = header.flags; + observation->capture_timestamp_us = header.capture_timestamp_us; + } + + class Connection { + public: + Connection(Impl* gateway, + HQUIC handle, + const QUIC_ADDR* remote_address) + : gateway_(gateway), + api_(gateway->api_), + connection_(handle), + decoder_(gateway->options_.maximum_control_frame_bytes), + reassembler_( + gateway->options_.maximum_reassembly_bytes, + gateway->options_.maximum_reassembly_frames, + gateway->options_.maximum_frame_bytes, + std::chrono::milliseconds( + gateway->options_.reassembly_timeout_ms)) + { + if (remote_address) { + remote_address_ = *remote_address; + remote_address_text_ = addressWithPort(remote_address_); + remote_ip_ = addressWithoutPort(remote_address_); + } + gateway_->connectionContextCreated(); + } + + void addRef() + { + references_.fetch_add(1U, std::memory_order_relaxed); + } + + void release() + { + if (references_.fetch_sub(1U, std::memory_order_acq_rel) == 1U) { + delete this; + } + } + + void abandonRejectedConnection() + { + std::lock_guard operation_lock(operation_mutex_); + connection_ = nullptr; + } + + QUIC_STATUS onConnectionEvent( + HQUIC connection, QUIC_CONNECTION_EVENT* event) + { + switch (event->Type) { + case QUIC_CONNECTION_EVENT_CONNECTED: + connected_.store(true); + gateway_->logEvent( + "connection-established", + "\"peer\":\"" + jsonEscape(remote_address_text_) + "\""); + break; + case QUIC_CONNECTION_EVENT_PEER_STREAM_STARTED: + acceptPeerStream( + event->PEER_STREAM_STARTED.Stream, + event->PEER_STREAM_STARTED.Flags); + break; + case QUIC_CONNECTION_EVENT_DATAGRAM_RECEIVED: + receiveDatagram(event->DATAGRAM_RECEIVED.Buffer); + break; + case QUIC_CONNECTION_EVENT_PEER_ADDRESS_CHANGED: + if (event->PEER_ADDRESS_CHANGED.Address) { + std::lock_guard state_lock(state_mutex_); + remote_address_ = + *event->PEER_ADDRESS_CHANGED.Address; + remote_address_text_ = + addressWithPort(remote_address_); + remote_ip_ = addressWithoutPort(remote_address_); + } + break; + case QUIC_CONNECTION_EVENT_SHUTDOWN_INITIATED_BY_TRANSPORT: + connected_.store(false); + gateway_->logEvent( + "connection-transport-shutdown", + "\"status\":\"" + + jsonEscape(statusText( + "transport", + event->SHUTDOWN_INITIATED_BY_TRANSPORT.Status)) + + "\""); + break; + case QUIC_CONNECTION_EVENT_SHUTDOWN_INITIATED_BY_PEER: + connected_.store(false); + gateway_->logEvent( + "connection-peer-shutdown", + "\"error_code\":" + + std::to_string( + event->SHUTDOWN_INITIATED_BY_PEER.ErrorCode)); + break; + case QUIC_CONNECTION_EVENT_SHUTDOWN_COMPLETE: + completeConnectionShutdown(connection); + break; + default: + break; + } + return QUIC_STATUS_SUCCESS; + } + + QUIC_STATUS onStreamEvent(HQUIC stream, QUIC_STREAM_EVENT* event) + { + switch (event->Type) { + case QUIC_STREAM_EVENT_RECEIVE: + return receiveControl(event); + case QUIC_STREAM_EVENT_SEND_COMPLETE: + delete static_cast( + event->SEND_COMPLETE.ClientContext); + break; + case QUIC_STREAM_EVENT_PEER_SEND_ABORTED: + case QUIC_STREAM_EVENT_PEER_RECEIVE_ABORTED: + connected_.store(false); + break; + case QUIC_STREAM_EVENT_SHUTDOWN_COMPLETE: + completeStreamShutdown(stream); + break; + default: + break; + } + return QUIC_STATUS_SUCCESS; + } + + void processControlChunk(const std::vector& bytes) + { + std::vector> frames; + std::string error; + if (!decoder_.push(bytes, &frames, &error)) { + protocolViolation(error, 0U); + return; + } + for (const auto& frame : frames) { + if (!processControlFrame(frame)) return; + } + } + + void processDatagram(const std::vector& bytes) + { + cmvr::quic_edge::DatagramHeader header; + std::string error; + if (!cmvr::quic_edge::DatagramPacketizer::decodeHeader( + bytes, &header, &error)) { + gateway_->stats_.datagrams_invalid.fetch_add(1U); + gateway_->logEvent( + "datagram-invalid", + "\"reason\":\"" + jsonEscape(error) + "\""); + return; + } + constexpr std::uint16_t allowed_flags = + cmvr::quic_edge::DATAGRAM_FLAG_KEY_FRAME | + cmvr::quic_edge::DATAGRAM_FLAG_DISCONTINUITY; + if ((header.flags & + static_cast(~allowed_flags)) != 0U) { + gateway_->stats_.datagrams_invalid.fetch_add(1U); + gateway_->logEvent( + "datagram-invalid", + "\"reason\":\"unsupported flags\""); + return; + } + + TrackInfo track; + { + std::lock_guard state_lock(state_mutex_); + const auto found = tracks_.find(header.track_id); + if (!registered_ || media_session_epoch_ == 0U || + header.session_epoch != media_session_epoch_ || + found == tracks_.end()) { + gateway_->stats_.datagrams_invalid.fetch_add(1U); + return; + } + track = found->second; + } + const auto expected_kind = + static_cast( + static_cast(header.kind)); + if (track.kind != expected_kind || + track.generation_token != header.codec_generation) { + gateway_->stats_.datagrams_invalid.fetch_add(1U); + return; + } + + const auto result = reassembler_.accept( + header, + bytes.data() + cmvr::quic_edge::kDatagramHeaderBytes, + bytes.size() - cmvr::quic_edge::kDatagramHeaderBytes, + Clock::now()); + gateway_->stats_.incomplete_frames_expired.fetch_add( + result.expired_frames); + gateway_->stats_.incomplete_frames_evicted.fetch_add( + result.evicted_frames); + switch (result.status) { + case ReassemblyResult::Status::COMPLETED: { + const std::uint64_t frame_count = + gateway_->stats_.frames_completed.fetch_add(1U) + 1U; + if (track.kind == + cmvr::quic_edge::v1::MEDIA_KIND_VIDEO) { + gateway_->stats_.video_frames_completed.fetch_add(1U); + } else if ( + track.kind == + cmvr::quic_edge::v1::MEDIA_KIND_AUDIO) { + gateway_->stats_.audio_frames_completed.fetch_add(1U); + } + const std::size_t frame_size = + result.completed_frame + ? result.completed_frame->payload.size() : 0U; + gateway_->stats_.frame_bytes_completed.fetch_add(frame_size); + if (result.completed_frame) { + gateway_->observeCompletedFrame( + track.kind, + result.completed_frame->header, + result.completed_frame->payload); + } + if (frame_count <= 5U || frame_count % 100U == 0U) { + gateway_->logEvent( + "media-frame-complete", + "\"track_id\":" + + std::to_string(header.track_id) + + ",\"frame_sequence\":" + + std::to_string(header.frame_sequence) + + ",\"bytes\":" + std::to_string(frame_size)); + } + break; + } + case ReassemblyResult::Status::INVALID: + gateway_->stats_.datagrams_invalid.fetch_add(1U); + break; + case ReassemblyResult::Status::CAPACITY_DROPPED: + gateway_->stats_.incomplete_frames_evicted.fetch_add(1U); + break; + case ReassemblyResult::Status::ACCEPTED: + case ReassemblyResult::Status::DUPLICATE: + break; + } + } + + void requestShutdown(const QUIC_UINT62 code) + { + std::lock_guard operation_lock(operation_mutex_); + if (connection_ && api_) { + api_->ConnectionShutdown( + connection_, QUIC_CONNECTION_SHUTDOWN_FLAG_NONE, code); + } + } + + private: + ~Connection() + { + gateway_->stats_.incomplete_frames_cleared.fetch_add( + reassembler_.clear()); + gateway_->connectionContextDestroyed(); + } + + void acceptPeerStream( + HQUIC stream, const QUIC_STREAM_OPEN_FLAGS flags) + { + const bool is_unidirectional = + (flags & QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL) != 0; + bool accepted = false; + { + std::lock_guard operation_lock(operation_mutex_); + if (!is_unidirectional && !stream_ && + !stream_was_opened_) { + stream_ = stream; + stream_was_opened_ = true; + stream_reference_active_ = true; + addRef(); + api_->SetCallbackHandler( + stream, + reinterpret_cast(&Impl::streamCallback), + this); + accepted = true; + } + } + if (accepted) { + gateway_->logEvent( + "control-stream-open", + "\"peer\":\"" + jsonEscape(remote_address_text_) + "\""); + return; + } + + api_->SetCallbackHandler( + stream, + reinterpret_cast(&Impl::rejectedStreamCallback), + gateway_); + api_->StreamShutdown( + stream, + static_cast( + QUIC_STREAM_SHUTDOWN_FLAG_ABORT | + QUIC_STREAM_SHUTDOWN_FLAG_IMMEDIATE), + kProtocolViolationCode); + } + + QUIC_STATUS receiveControl(QUIC_STREAM_EVENT* event) + { + if (event->RECEIVE.BufferCount != 0U && + !event->RECEIVE.Buffers) { + requestShutdown(kProtocolViolationCode); + return QUIC_STATUS_INVALID_PARAMETER; + } + std::size_t total_size = 0U; + for (std::uint32_t index = 0U; + index < event->RECEIVE.BufferCount; ++index) { + const QUIC_BUFFER& buffer = event->RECEIVE.Buffers[index]; + if (buffer.Length != 0U && !buffer.Buffer) { + requestShutdown(kProtocolViolationCode); + return QUIC_STATUS_INVALID_PARAMETER; + } + if (buffer.Length > + std::numeric_limits::max() - total_size) { + requestShutdown(kProtocolViolationCode); + return QUIC_STATUS_BUFFER_TOO_SMALL; + } + total_size += buffer.Length; + } + if (total_size == 0U) return QUIC_STATUS_SUCCESS; + // A single MsQuic RECEIVE callback may coalesce several valid + // length-prefixed frames. Bound the callback allocation by the + // work queue, while ControlFrameDecoder enforces the per-frame + // maximum after the bytes reach the worker. + if (total_size > gateway_->options_.maximum_work_queue_bytes) { + protocolViolation( + "control receive chunk exceeds work queue bound", 0U); + return QUIC_STATUS_BUFFER_TOO_SMALL; + } + + std::vector bytes; + try { + bytes.reserve(total_size); + for (std::uint32_t index = 0U; + index < event->RECEIVE.BufferCount; ++index) { + const QUIC_BUFFER& buffer = event->RECEIVE.Buffers[index]; + bytes.insert( + bytes.end(), buffer.Buffer, + buffer.Buffer + buffer.Length); + } + } catch (...) { + requestShutdown(kProtocolViolationCode); + return QUIC_STATUS_OUT_OF_MEMORY; + } + + addRef(); + if (!gateway_->enqueueControl(this, std::move(bytes))) { + release(); + requestShutdown(kProtocolViolationCode); + return QUIC_STATUS_BUFFER_TOO_SMALL; + } + return QUIC_STATUS_SUCCESS; + } + + void receiveDatagram(const QUIC_BUFFER* buffer) + { + gateway_->stats_.datagrams_received.fetch_add(1U); + if (!buffer || !buffer->Buffer || + buffer->Length < cmvr::quic_edge::kDatagramHeaderBytes) { + gateway_->stats_.datagrams_invalid.fetch_add(1U); + return; + } + std::vector bytes; + try { + bytes.assign( + buffer->Buffer, buffer->Buffer + buffer->Length); + } catch (...) { + gateway_->stats_.datagrams_queue_dropped.fetch_add(1U); + return; + } + addRef(); + if (!gateway_->enqueueDatagram(this, std::move(bytes))) { + release(); + gateway_->stats_.datagrams_queue_dropped.fetch_add(1U); + } + } + + bool processControlFrame(const std::vector& frame) + { + Envelope envelope; + if (!envelope.ParseFromArray( + frame.data(), static_cast(frame.size()))) { + protocolViolation("failed to parse EdgeControlEnvelope", 0U); + return false; + } + if (envelope.protocol_version() != + cmvr::quic_edge::kProtocolVersion) { + protocolViolation( + "unsupported QUIC edge protocol version", + envelope.message_sequence()); + return false; + } + if (has_inbound_sequence_ && + envelope.message_sequence() <= inbound_sequence_) { + protocolViolation( + "edge control message sequence did not increase", + envelope.message_sequence()); + return false; + } + has_inbound_sequence_ = true; + inbound_sequence_ = envelope.message_sequence(); + + if (!received_first_message_ && + !envelope.has_node_register_request()) { + protocolViolation( + "NodeRegisterRequest must be the first control message", + envelope.message_sequence()); + return false; + } + received_first_message_ = true; + + if (envelope.has_node_register_request()) { + return handleRegistration( + envelope.node_register_request(), + envelope.message_sequence()); + } + if (envelope.has_node_heartbeat()) { + return handleHeartbeat( + envelope.node_heartbeat(), + envelope.message_sequence()); + } + if (envelope.has_media_session_open()) { + return handleMediaSessionOpen( + envelope.media_session_open(), + envelope.message_sequence()); + } + if (envelope.has_media_track_descriptor()) { + return handleTrackDescriptor( + envelope.media_track_descriptor(), + envelope.message_sequence()); + } + + protocolViolation( + "edge sent an unexpected control message", + envelope.message_sequence()); + return false; + } + + bool handleRegistration( + const cmvr::quic_edge::v1::NodeRegisterRequest& request, + const std::uint64_t related_sequence) + { + gateway_->stats_.registrations_received.fetch_add(1U); + { + std::lock_guard state_lock(state_mutex_); + if (registration_attempted_ || registered_ || + request.node().node_id().empty() || + request.node().boot_id().empty()) { + protocolViolation( + "invalid or duplicate node registration", + related_sequence); + return false; + } + registration_attempted_ = true; + } + + gateway_->recordRegistration( + request.node().node_id(), + request.node().boot_id(), + request.node().grpc_endpoint().host(), + request.node().grpc_endpoint().port(), + static_cast( + request.node().local_interfaces_size()), + remoteIp()); + gateway_->logEvent( + "node-register", + "\"node_id\":\"" + + jsonEscape(request.node().node_id()) + + "\",\"boot_id\":\"" + + jsonEscape(request.node().boot_id()) + + "\",\"interfaces\":" + + std::to_string(request.node().local_interfaces_size()) + + ",\"grpc_host\":\"" + + jsonEscape(request.node().grpc_endpoint().host()) + + "\",\"grpc_port\":" + + std::to_string(request.node().grpc_endpoint().port())); + + Envelope response; + response.set_protocol_version(cmvr::quic_edge::kProtocolVersion); + response.set_message_sequence(nextOutboundSequence()); + auto* registration = response.mutable_node_register_response(); + registration->set_heartbeat_interval_ms( + gateway_->options_.heartbeat_interval_ms); + registration->set_observed_source_ip(remoteIp()); + + if (gateway_->options_.scenario == + Scenario::REJECT_REGISTRATION) { + registration->set_accepted(false); + registration->set_message("rejected by test scenario"); + gateway_->stats_.registrations_rejected.fetch_add(1U); + return sendEnvelope(response); + } + + const std::string session_id = gateway_->nextSessionId(); + registration->set_accepted(true); + registration->set_session_id(session_id); + registration->set_message("accepted by cmvr QUIC test gateway"); + { + std::lock_guard state_lock(state_mutex_); + registered_ = true; + node_id_ = request.node().node_id(); + boot_id_ = request.node().boot_id(); + session_id_ = session_id; + } + gateway_->stats_.registrations_accepted.fetch_add(1U); + gateway_->recordNode( + request.node().node_id(), request.node().boot_id(), session_id); + if (!sendEnvelope(response)) return false; + + if (gateway_->options_.scenario == + Scenario::FATAL_PROTOCOL_ERROR || + gateway_->options_.scenario == + Scenario::NONFATAL_PROTOCOL_ERROR) { + const bool fatal = + gateway_->options_.scenario == + Scenario::FATAL_PROTOCOL_ERROR; + return sendProtocolError( + fatal ? "fatal test protocol error" + : "nonfatal test protocol error", + related_sequence, fatal); + } + return true; + } + + bool handleHeartbeat( + const cmvr::quic_edge::v1::NodeHeartbeat& heartbeat, + const std::uint64_t related_sequence) + { + { + std::lock_guard state_lock(state_mutex_); + if (!registered_ || + heartbeat.session_id() != session_id_ || + heartbeat.node_id() != node_id_ || + heartbeat.boot_id() != boot_id_ || + (has_heartbeat_sequence_ && + heartbeat.sequence() <= last_heartbeat_sequence_)) { + protocolViolation( + "heartbeat does not match the registered session", + related_sequence); + return false; + } + has_heartbeat_sequence_ = true; + last_heartbeat_sequence_ = heartbeat.sequence(); + } + gateway_->stats_.heartbeats_received.fetch_add(1U); + gateway_->recordHeartbeat(heartbeat, remoteIp()); + gateway_->logEvent( + "node-heartbeat", + "\"sequence\":" + + std::to_string(heartbeat.sequence()) + + ",\"interfaces\":" + + std::to_string(heartbeat.local_interfaces_size()) + + ",\"devices\":" + + std::to_string( + heartbeat.has_device_manager() + ? heartbeat.device_manager().devices_size() : 0)); + + if (gateway_->options_.scenario == + Scenario::DROP_HEARTBEAT_ACK) { + gateway_->stats_.heartbeat_acks_dropped.fetch_add(1U); + return true; + } + + Envelope response; + response.set_protocol_version(cmvr::quic_edge::kProtocolVersion); + response.set_message_sequence(nextOutboundSequence()); + auto* ack = response.mutable_node_heartbeat_ack(); + ack->set_accepted(true); + ack->set_acknowledged_sequence(heartbeat.sequence()); + ack->set_server_time_unix_ms(unixTimeMs()); + ack->set_observed_source_ip(remoteIp()); + ack->set_session_id( + gateway_->options_.scenario == Scenario::WRONG_ACK_SESSION + ? "wrong-test-session" : currentSessionId()); + if (!sendEnvelope(response)) return false; + gateway_->stats_.heartbeat_acks_sent.fetch_add(1U); + return true; + } + + bool handleMediaSessionOpen( + const cmvr::quic_edge::v1::MediaSessionOpen& open, + const std::uint64_t related_sequence) + { + { + std::lock_guard state_lock(state_mutex_); + if (!registered_ || open.session_id() != session_id_ || + open.node_id() != node_id_ || + open.session_epoch() == 0U || + media_session_epoch_ != 0U) { + protocolViolation( + "invalid media session open", related_sequence); + return false; + } + media_session_epoch_ = open.session_epoch(); + tracks_.clear(); + } + gateway_->stats_.incomplete_frames_cleared.fetch_add( + reassembler_.reset(open.session_epoch())); + gateway_->stats_.media_sessions_opened.fetch_add(1U); + gateway_->logEvent( + "media-session-open", + "\"session_epoch\":" + + std::to_string(open.session_epoch())); + return true; + } + + bool handleTrackDescriptor( + const cmvr::quic_edge::v1::MediaTrackDescriptor& descriptor, + const std::uint64_t related_sequence) + { + if (descriptor.track_id() == 0U || + descriptor.codec_generation() == 0U || + descriptor.codec_generation_token() == 0U || + descriptor.kind() == + cmvr::quic_edge::v1::MEDIA_KIND_UNSPECIFIED || + descriptor.codec().empty() || + descriptor.payload_format().empty() || + descriptor.source_track_id().empty()) { + protocolViolation( + "invalid media track descriptor", related_sequence); + return false; + } + { + std::lock_guard state_lock(state_mutex_); + if (!registered_ || media_session_epoch_ == 0U) { + protocolViolation( + "track descriptor arrived before media session", + related_sequence); + return false; + } + TrackInfo info; + info.kind = descriptor.kind(); + info.generation_token = + descriptor.codec_generation_token(); + info.generation = descriptor.codec_generation(); + info.codec = descriptor.codec(); + info.source_track_id = descriptor.source_track_id(); + tracks_[descriptor.track_id()] = std::move(info); + } + gateway_->observeTrackDescriptor(descriptor); + gateway_->stats_.track_descriptors_received.fetch_add(1U); + gateway_->logEvent( + "media-track-descriptor", + "\"track_id\":" + + std::to_string(descriptor.track_id()) + + ",\"kind\":" + + std::to_string(static_cast(descriptor.kind())) + + ",\"codec\":\"" + jsonEscape(descriptor.codec()) + + "\",\"source_track_id\":\"" + + jsonEscape(descriptor.source_track_id()) + "\""); + return true; + } + + bool sendProtocolError(const std::string& message, + const std::uint64_t related_sequence, + const bool fatal) + { + Envelope response; + response.set_protocol_version(cmvr::quic_edge::kProtocolVersion); + response.set_message_sequence(nextOutboundSequence()); + auto* protocol_error = response.mutable_protocol_error(); + protocol_error->set_code(kProtocolErrorCode); + protocol_error->set_message(message); + protocol_error->set_related_message_sequence(related_sequence); + protocol_error->set_fatal(fatal); + if (!sendEnvelope(response)) return false; + gateway_->stats_.protocol_errors_sent.fetch_add(1U); + return true; + } + + void protocolViolation(const std::string& message, + const std::uint64_t related_sequence) + { + gateway_->stats_.protocol_violations.fetch_add(1U); + gateway_->logEvent( + "protocol-violation", + "\"reason\":\"" + jsonEscape(message) + + "\",\"related_sequence\":" + + std::to_string(related_sequence)); + requestShutdown(kProtocolViolationCode); + } + + bool sendEnvelope(const Envelope& envelope) + { + std::string serialized; + if (!envelope.SerializeToString(&serialized)) { + gateway_->setRuntimeFailure( + "failed to serialize gateway control envelope"); + return false; + } + std::vector framed; + std::string error; + if (!cmvr::quic_edge::ControlFrameEncoder::encode( + reinterpret_cast(serialized.data()), + serialized.size(), + gateway_->options_.maximum_control_frame_bytes, + &framed, &error)) { + gateway_->setRuntimeFailure(error); + return false; + } + if (framed.size() > std::numeric_limits::max()) { + gateway_->setRuntimeFailure( + "framed control response is too large for MsQuic"); + return false; + } + + auto* send = new (std::nothrow) SendContext(std::move(framed)); + if (!send) { + gateway_->setRuntimeFailure( + "failed to allocate control send context"); + return false; + } + QUIC_STATUS status = QUIC_STATUS_INVALID_STATE; + { + std::lock_guard operation_lock(operation_mutex_); + if (stream_ && connected_.load()) { + status = api_->StreamSend( + stream_, &send->buffer, 1U, + QUIC_SEND_FLAG_NONE, send); + } + } + if (QUIC_FAILED(status)) { + delete send; + gateway_->logEvent( + "control-send-failed", + "\"status\":\"" + + jsonEscape(statusText("StreamSend", status)) + "\""); + requestShutdown(kProtocolViolationCode); + return false; + } + return true; + } + + void completeStreamShutdown(HQUIC stream) + { + bool release_stream_reference = false; + { + std::lock_guard operation_lock(operation_mutex_); + if (stream_ == stream) { + stream_ = nullptr; + release_stream_reference = stream_reference_active_; + stream_reference_active_ = false; + } + api_->StreamClose(stream); + } + if (release_stream_reference) release(); + } + + void completeConnectionShutdown(HQUIC callback_handle) + { + HQUIC handle = nullptr; + { + std::lock_guard operation_lock(operation_mutex_); + if (connection_ == callback_handle) { + handle = connection_; + connection_ = nullptr; + } + connected_.store(false); + } + if (handle) api_->ConnectionClose(handle); + gateway_->connectionClosed(callback_handle); + release(); + } + + std::uint64_t nextOutboundSequence() + { + return outbound_sequence_++; + } + + std::string currentSessionId() const + { + std::lock_guard state_lock(state_mutex_); + return session_id_; + } + + std::string remoteIp() const + { + std::lock_guard state_lock(state_mutex_); + return remote_ip_; + } + + static std::uint64_t unixTimeMs() + { + return static_cast( + std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count()); + } + + Impl* gateway_; + const QUIC_API_TABLE* api_; + std::atomic references_{1U}; + std::atomic connected_{false}; + mutable std::mutex operation_mutex_; + HQUIC connection_{nullptr}; + HQUIC stream_{nullptr}; + bool stream_reference_active_{false}; + bool stream_was_opened_{false}; + + cmvr::quic_edge::ControlFrameDecoder decoder_; + bool received_first_message_{false}; + bool has_inbound_sequence_{false}; + std::uint64_t inbound_sequence_{0}; + std::uint64_t outbound_sequence_{0}; + + mutable std::mutex state_mutex_; + bool registration_attempted_{false}; + bool registered_{false}; + bool has_heartbeat_sequence_{false}; + std::uint64_t last_heartbeat_sequence_{0}; + std::string node_id_; + std::string boot_id_; + std::string session_id_; + QUIC_ADDR remote_address_{}; + std::string remote_address_text_; + std::string remote_ip_; + std::uint64_t media_session_epoch_{0}; + std::unordered_map tracks_; + MediaReassembler reassembler_; + }; + + bool validateOptions(std::string* error) const + { + auto reject = [error](const std::string& message) { + if (error) *error = message; + return false; + }; + if (options_.bind_address.empty()) { + return reject("bind address must not be empty"); + } + if (options_.alpn.empty() || + options_.alpn.size() > QUIC_MAX_ALPN_LENGTH) { + return reject("ALPN must contain 1 to 255 bytes"); + } + if (options_.certificate_file.empty() || + options_.private_key_file.empty()) { + return reject("server certificate and private key are required"); + } + if (options_.maximum_control_frame_bytes == 0U || + options_.maximum_control_frame_bytes > + std::numeric_limits::max() - 4U) { + return reject("maximum control frame size is invalid"); + } + if (options_.maximum_reassembly_bytes == 0U || + options_.maximum_reassembly_frames == 0U || + options_.maximum_frame_bytes == 0U || + options_.maximum_work_queue_bytes == 0U || + options_.reassembly_timeout_ms == 0U) { + return reject("gateway memory and timeout limits must be nonzero"); + } + if (options_.maximum_work_queue_bytes < + options_.maximum_control_frame_bytes + 4U) { + return reject( + "work queue limit must fit one maximum control frame"); + } + return true; + } + + bool failStart(const std::string& message, std::string* error) + { + setRuntimeFailure(message); + if (error) *error = message; + return false; + } + + void startWorker() + { + std::lock_guard lock(work_mutex_); + if (worker_.joinable()) return; + worker_stop_ = false; + worker_ = std::thread([this]() { workerLoop(); }); + } + + void stopWorker() + { + { + std::lock_guard lock(work_mutex_); + worker_stop_ = true; + } + work_condition_.notify_all(); + if (worker_.joinable()) worker_.join(); + } + + void workerLoop() + { + while (true) { + WorkItem item; + bool control = false; + { + std::unique_lock lock(work_mutex_); + work_condition_.wait(lock, [this]() { + return worker_stop_ || !control_work_.empty() || + !datagram_work_.empty(); + }); + if (control_work_.empty() && datagram_work_.empty() && + worker_stop_) { + break; + } + if (!control_work_.empty()) { + item = std::move(control_work_.front()); + control_work_.pop_front(); + control_work_bytes_ -= item.bytes.size(); + control = true; + } else { + item = std::move(datagram_work_.front()); + datagram_work_.pop_front(); + datagram_work_bytes_ -= item.bytes.size(); + } + } + if (item.connection) { + try { + if (control) { + item.connection->processControlChunk(item.bytes); + } else { + item.connection->processDatagram(item.bytes); + } + } catch (const std::bad_alloc&) { + handleWorkerException( + item.connection, + "gateway worker exhausted memory"); + } catch (...) { + handleWorkerException( + item.connection, + "gateway worker raised an unexpected exception"); + } + item.connection->release(); + } + } + } + + void handleWorkerException( + Connection* connection, const char* message) noexcept + { + runtime_failed_.store(true); + try { + std::lock_guard lock(error_mutex_); + if (last_error_.empty()) last_error_ = message; + } catch (...) { + // The runtime_failed flag remains observable even when an + // allocation failure prevents retaining the diagnostic string. + } + if (connection) { + try { + connection->requestShutdown(kApplicationShutdownCode); + } catch (...) { + // Never allow a secondary lock failure to escape the worker. + } + } + } + + bool enqueueControl( + Connection* connection, std::vector bytes) noexcept + { + const std::size_t byte_count = bytes.size(); + try { + std::lock_guard lock(work_mutex_); + if (worker_stop_ || + byte_count > options_.maximum_work_queue_bytes || + control_work_bytes_ > + options_.maximum_work_queue_bytes - byte_count) { + return false; + } + control_work_.push_back({connection, std::move(bytes)}); + control_work_bytes_ += byte_count; + } catch (...) { + return false; + } + work_condition_.notify_one(); + return true; + } + + bool enqueueDatagram( + Connection* connection, std::vector bytes) noexcept + { + const std::size_t byte_count = bytes.size(); + try { + std::lock_guard lock(work_mutex_); + if (worker_stop_ || + byte_count > options_.maximum_work_queue_bytes || + datagram_work_bytes_ > + options_.maximum_work_queue_bytes - byte_count) { + return false; + } + datagram_work_.push_back({connection, std::move(bytes)}); + datagram_work_bytes_ += byte_count; + } catch (...) { + return false; + } + work_condition_.notify_one(); + return true; + } + + void connectionContextCreated() + { + std::lock_guard lock(connection_mutex_); + ++live_connection_contexts_; + } + + void connectionContextDestroyed() + { + std::lock_guard lock(connection_mutex_); + if (live_connection_contexts_ != 0U) --live_connection_contexts_; + connection_condition_.notify_all(); + } + + bool trackConnection(HQUIC handle, Connection* connection) noexcept + { + try { + std::lock_guard lock(connection_mutex_); + const auto [unused, inserted] = + connections_.emplace(handle, connection); + static_cast(unused); + if (!inserted) return false; + stats_.connections_accepted.fetch_add(1U); + return true; + } catch (...) { + return false; + } + } + + void discardTrackedConnection(HQUIC handle) + { + std::lock_guard lock(connection_mutex_); + connections_.erase(handle); + connection_condition_.notify_all(); + } + + void connectionClosed(HQUIC handle) + { + { + std::lock_guard lock(connection_mutex_); + connections_.erase(handle); + } + stats_.connections_closed.fetch_add(1U); + connection_condition_.notify_all(); + } + + std::string nextSessionId() + { + const std::uint64_t number = + next_session_number_.fetch_add(1U); + return "cmvr-test-session-" + std::to_string(number); + } + + void recordRegistration( + const std::string& node_id, + const std::string& boot_id, + const std::string& grpc_endpoint_host, + const std::uint32_t grpc_endpoint_port, + const std::uint64_t interface_count, + const std::string& observed_source_ip) + { + std::lock_guard lock(observation_mutex_); + last_node_id_ = node_id; + last_boot_id_ = boot_id; + last_grpc_endpoint_host_ = grpc_endpoint_host; + last_grpc_endpoint_port_ = grpc_endpoint_port; + last_observed_source_ip_ = observed_source_ip; + registration_interface_count_ = interface_count; + } + + void recordNode(const std::string& node_id, + const std::string& boot_id, + const std::string& session_id) + { + std::lock_guard lock(observation_mutex_); + last_node_id_ = node_id; + last_boot_id_ = boot_id; + if (!session_id.empty()) last_session_id_ = session_id; + } + + void recordHeartbeat( + const cmvr::quic_edge::v1::NodeHeartbeat& heartbeat, + const std::string& observed_source_ip) + { + std::uint64_t enabled_count = 0U; + std::uint64_t disabled_count = 0U; + std::uint64_t error_count = 0U; + std::uint64_t unknown_health_count = 0U; + if (heartbeat.has_device_manager()) { + for (const auto& device : + heartbeat.device_manager().devices()) { + if (device.enabled()) { + ++enabled_count; + } else { + ++disabled_count; + } + if (device.has_error()) ++error_count; + if (device.health() == + cmvr::quic_edge::v1:: + DEVICE_HEALTH_STATUS_UNSPECIFIED) { + ++unknown_health_count; + } + } + } + + std::lock_guard lock(observation_mutex_); + heartbeat_interface_count_ = + static_cast( + heartbeat.local_interfaces_size()); + last_observed_source_ip_ = observed_source_ip; + heartbeat_has_device_manager_ = heartbeat.has_device_manager(); + if (!heartbeat.has_device_manager()) { + heartbeat_device_manager_name_.clear(); + heartbeat_device_manager_version_.clear(); + heartbeat_device_manager_description_.clear(); + heartbeat_device_count_ = 0U; + heartbeat_enabled_device_count_ = 0U; + heartbeat_disabled_device_count_ = 0U; + heartbeat_error_device_count_ = 0U; + heartbeat_unknown_health_device_count_ = 0U; + heartbeat_devices_.clear(); + return; + } + const auto& manager = heartbeat.device_manager(); + heartbeat_device_manager_name_ = manager.manager_name(); + heartbeat_device_manager_version_ = manager.manager_version(); + heartbeat_device_manager_description_ = + manager.manager_description(); + heartbeat_device_count_ = + static_cast(manager.devices_size()); + heartbeat_enabled_device_count_ = enabled_count; + heartbeat_disabled_device_count_ = disabled_count; + heartbeat_error_device_count_ = error_count; + heartbeat_unknown_health_device_count_ = unknown_health_count; + heartbeat_devices_.assign( + manager.devices().begin(), manager.devices().end()); + } + + void setRuntimeFailure(const std::string& message) + { + runtime_failed_.store(true); + std::lock_guard lock(error_mutex_); + if (last_error_.empty()) last_error_ = message; + } + + void logEvent(const std::string& event, const std::string& fields) + { + std::ostringstream output; + output << "{\"event\":\"" << jsonEscape(event) << '"'; + if (!fields.empty()) output << ',' << fields; + output << '}'; + logJson(output.str()); + } + + void logJson(const std::string& json) const + { + std::lock_guard lock(log_mutex_); + std::cout << json << std::endl; + } + + std::string readyJson() const + { + std::ostringstream output; + output << "{\"event\":\"ready\"" + << ",\"bind\":\"" << jsonEscape(bound_address_text_) << '"' + << ",\"port\":" << bound_port_.load() + << ",\"alpn\":\"" << jsonEscape(options_.alpn) << '"' + << ",\"scenario\":\"" << scenarioName(options_.scenario) << '"' + << ",\"datagram_enabled\":" + << (options_.scenario == Scenario::DATAGRAM_DISABLED + ? "false" : "true") + << '}'; + return output.str(); + } + + static QUIC_STATUS QUIC_API listenerCallback( + HQUIC, void* context, QUIC_LISTENER_EVENT* event) noexcept + { + try { + auto* gateway = static_cast(context); + if (!gateway || !event) return QUIC_STATUS_INVALID_PARAMETER; + if (event->Type == QUIC_LISTENER_EVENT_STOP_COMPLETE) { + { + std::lock_guard lock(gateway->listener_mutex_); + gateway->listener_stop_complete_ = true; + } + gateway->listener_condition_.notify_all(); + return QUIC_STATUS_SUCCESS; + } + if (event->Type != QUIC_LISTENER_EVENT_NEW_CONNECTION || + !event->NEW_CONNECTION.Connection) { + return QUIC_STATUS_SUCCESS; + } + + Connection* connection = nullptr; + try { + connection = new (std::nothrow) Connection( + gateway, event->NEW_CONNECTION.Connection, + event->NEW_CONNECTION.Info + ? event->NEW_CONNECTION.Info->RemoteAddress : nullptr); + } catch (...) { + return QUIC_STATUS_OUT_OF_MEMORY; + } + if (!connection) return QUIC_STATUS_OUT_OF_MEMORY; + + if (!gateway->trackConnection( + event->NEW_CONNECTION.Connection, connection)) { + connection->abandonRejectedConnection(); + connection->release(); + return QUIC_STATUS_OUT_OF_MEMORY; + } + gateway->api_->SetCallbackHandler( + event->NEW_CONNECTION.Connection, + reinterpret_cast(&Impl::connectionCallback), + connection); + const QUIC_STATUS status = + gateway->api_->ConnectionSetConfiguration( + event->NEW_CONNECTION.Connection, + gateway->configuration_); + if (QUIC_FAILED(status)) { + gateway->discardTrackedConnection( + event->NEW_CONNECTION.Connection); + connection->abandonRejectedConnection(); + connection->release(); + return status; + } + return QUIC_STATUS_SUCCESS; + } catch (const std::bad_alloc&) { + return QUIC_STATUS_OUT_OF_MEMORY; + } catch (...) { + return QUIC_STATUS_INTERNAL_ERROR; + } + } + + static QUIC_STATUS QUIC_API connectionCallback( + HQUIC connection, void* context, + QUIC_CONNECTION_EVENT* event) noexcept + { + try { + auto* state = static_cast(context); + if (!state || !event) return QUIC_STATUS_INVALID_PARAMETER; + return state->onConnectionEvent(connection, event); + } catch (const std::bad_alloc&) { + return QUIC_STATUS_OUT_OF_MEMORY; + } catch (...) { + return QUIC_STATUS_INTERNAL_ERROR; + } + } + + static QUIC_STATUS QUIC_API streamCallback( + HQUIC stream, void* context, QUIC_STREAM_EVENT* event) noexcept + { + try { + auto* state = static_cast(context); + if (!state || !event) return QUIC_STATUS_INVALID_PARAMETER; + return state->onStreamEvent(stream, event); + } catch (const std::bad_alloc&) { + return QUIC_STATUS_OUT_OF_MEMORY; + } catch (...) { + return QUIC_STATUS_INTERNAL_ERROR; + } + } + + static QUIC_STATUS QUIC_API rejectedStreamCallback( + HQUIC stream, void* context, QUIC_STREAM_EVENT* event) noexcept + { + try { + auto* gateway = static_cast(context); + if (!gateway || !event) return QUIC_STATUS_INVALID_PARAMETER; + if (event->Type == QUIC_STREAM_EVENT_SEND_COMPLETE) { + delete static_cast( + event->SEND_COMPLETE.ClientContext); + } else if (event->Type == QUIC_STREAM_EVENT_SHUTDOWN_COMPLETE) { + gateway->api_->StreamClose(stream); + } + return QUIC_STATUS_SUCCESS; + } catch (const std::bad_alloc&) { + return QUIC_STATUS_OUT_OF_MEMORY; + } catch (...) { + return QUIC_STATUS_INTERNAL_ERROR; + } + } + + GatewayOptions options_; + const QUIC_API_TABLE* api_{nullptr}; + HQUIC registration_{nullptr}; + HQUIC configuration_{nullptr}; + HQUIC listener_{nullptr}; + bool listener_started_{false}; + mutable std::mutex lifecycle_mutex_; + bool start_attempted_{false}; + bool stopped_{false}; + std::atomic started_{false}; + Clock::time_point started_at_{}; + std::atomic bound_port_{0U}; + std::string bound_address_text_; + + mutable std::mutex listener_mutex_; + std::condition_variable listener_condition_; + bool listener_stop_complete_{false}; + + mutable std::mutex connection_mutex_; + std::condition_variable connection_condition_; + std::unordered_map connections_; + std::size_t live_connection_contexts_{0U}; + std::atomic next_session_number_{1U}; + + mutable std::mutex work_mutex_; + std::condition_variable work_condition_; + std::deque control_work_; + std::deque datagram_work_; + std::size_t control_work_bytes_{0U}; + std::size_t datagram_work_bytes_{0U}; + bool worker_stop_{false}; + std::thread worker_; + + AtomicStats stats_; + std::atomic runtime_failed_{false}; + mutable std::mutex error_mutex_; + std::string last_error_; + mutable std::mutex log_mutex_; + mutable std::mutex observation_mutex_; + std::string last_node_id_; + std::string last_boot_id_; + std::string last_session_id_; + std::string last_grpc_endpoint_host_; + std::uint32_t last_grpc_endpoint_port_{0U}; + std::string last_observed_source_ip_; + std::uint64_t registration_interface_count_{0U}; + std::uint64_t heartbeat_interface_count_{0U}; + bool heartbeat_has_device_manager_{false}; + std::string heartbeat_device_manager_name_; + std::string heartbeat_device_manager_version_; + std::string heartbeat_device_manager_description_; + std::uint64_t heartbeat_device_count_{0U}; + std::uint64_t heartbeat_enabled_device_count_{0U}; + std::uint64_t heartbeat_disabled_device_count_{0U}; + std::uint64_t heartbeat_error_device_count_{0U}; + std::uint64_t heartbeat_unknown_health_device_count_{0U}; + std::vector + heartbeat_devices_; + TrackDescriptorObservation video_descriptor_; + TrackDescriptorObservation audio_descriptor_; + CompletedFrameObservation maximum_video_frame_; + CompletedFrameObservation maximum_audio_frame_; +}; + +QuicTestGateway::QuicTestGateway(GatewayOptions options) + : impl_(std::make_unique(std::move(options))) +{ +} + +QuicTestGateway::~QuicTestGateway() = default; + +bool QuicTestGateway::start(std::string* error) +{ + return impl_->start(error); +} + +void QuicTestGateway::stop() +{ + impl_->stop(); +} + +std::uint16_t QuicTestGateway::boundPort() const +{ + return impl_->boundPort(); +} + +bool QuicTestGateway::completionReached() const +{ + return impl_->completionReached(); +} + +bool QuicTestGateway::hasRuntimeFailure() const +{ + return impl_->hasRuntimeFailure(); +} + +std::string QuicTestGateway::lastError() const +{ + return impl_->lastError(); +} + +std::string QuicTestGateway::summaryJson() const +{ + return impl_->summaryJson(); +} + +} // namespace cmvr::test::quic_gateway diff --git a/test/quic_gateway/tests/media_reassembler_test.cpp b/test/quic_gateway/tests/media_reassembler_test.cpp new file mode 100644 index 00000000..8e35dffd --- /dev/null +++ b/test/quic_gateway/tests/media_reassembler_test.cpp @@ -0,0 +1,204 @@ +#include "media_reassembler.h" + +#include +#include +#include +#include + +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(second.data()), + second.size(), now); + CHECK_TRUE(result.status == ReassemblyResult::Status::ACCEPTED); + + result = reassembler.accept( + header(1U, 3U, 3U), + reinterpret_cast(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(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(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(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(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(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(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(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(payload.data()), + payload.size(), now); + CHECK_TRUE(result.status == ReassemblyResult::Status::ACCEPTED); + result = expiring.accept( + header(0U, 0U, 3U, 2U), + reinterpret_cast(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(payload.data()), + payload.size(), now); + CHECK_TRUE(result.status == ReassemblyResult::Status::ACCEPTED); + result = bounded.accept( + header(1U, 3U, 3U), + reinterpret_cast(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; +}