From 245cfd8a82bc5fdb88aec5595a6494ec46cb043c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=91=A8=E7=9D=BF?= Date: Thu, 8 Oct 2026 13:55:05 +0800 Subject: [PATCH 01/14] feat(ostool-server): add automatic axloader serial binding --- Cargo.lock | 21 +- Cargo.toml | 5 +- docs/admin-ui.md | 7 +- docs/api.md | 18 +- docs/axloader-network-control.md | 74 +-- docs/axloader-serial-ownership.md | 129 ++++ httpboot-protocol/Cargo.toml | 2 +- httpboot-protocol/src/lib.rs | 16 +- httpboot-protocol/src/serial.rs | 238 +++++++ ostool-serial/Cargo.toml | 24 + ostool-serial/src/lib.rs | 276 ++++++++ ostool-serial/src/manager.rs | 625 ++++++++++++++++++ ostool-serial/src/native.rs | 229 +++++++ ostool-serial/src/physical.rs | 236 +++++++ ostool-serial/tests/manager.rs | 370 +++++++++++ ostool-server/Cargo.toml | 2 + ostool-server/scripts/test-axloader-local.py | 318 +-------- ostool-server/src/admin_events.rs | 1 + ostool-server/src/admin_power.rs | 3 + ostool-server/src/api/models.rs | 2 + ostool-server/src/api/router.rs | 156 +++-- ostool-server/src/config.rs | 25 +- ostool-server/src/device.rs | 200 +++++- ostool-server/src/loader.rs | 52 +- ostool-server/src/serial.rs | 3 + ostool-server/src/serial/backend.rs | 131 ++++ ostool-server/src/serial/discovery.rs | 20 +- ostool-server/src/serial/physical.rs | 174 +---- ostool-server/src/serial/runtime.rs | 479 ++++++++++++++ ostool-server/src/serial/transport.rs | 122 ++-- ostool-server/src/serial/ws.rs | 93 ++- ostool-server/src/session.rs | 17 + ostool-server/src/state.rs | 74 ++- ostool-server/src/virtual_qemu.rs | 14 +- ostool-server/tests/axloader_serial.rs | 379 +++++++++++ ostool-server/tests/common/serial.rs | 74 +++ ostool-server/tests/qemu_serial.rs | 289 ++++++++ .../webui/e2e/loader-binding.spec.ts | 7 + ostool-server/webui/src/api/events.ts | 1 + ostool-server/webui/src/board-form.test.ts | 18 +- ostool-server/webui/src/board-form.ts | 31 +- .../webui/src/components/serial-runtime.tsx | 30 + .../webui/src/pages/BoardEditor.test.tsx | 58 +- ostool-server/webui/src/pages/BoardEditor.tsx | 94 ++- ostool-server/webui/src/pages/Boards.tsx | 8 +- ostool-server/webui/src/pages/Sessions.tsx | 5 + ostool-server/webui/src/types/api.ts | 34 +- ostool/Cargo.toml | 1 + ostool/src/axloader.rs | 267 ++++++++ ostool/src/board/client.rs | 13 + ostool/src/lib.rs | 2 + ostool/src/main.rs | 5 + ostool/src/sterm/input.rs | 6 +- ostool/src/sterm/mod.rs | 2 +- 54 files changed, 4651 insertions(+), 829 deletions(-) create mode 100644 docs/axloader-serial-ownership.md create mode 100644 httpboot-protocol/src/serial.rs create mode 100644 ostool-serial/Cargo.toml create mode 100644 ostool-serial/src/lib.rs create mode 100644 ostool-serial/src/manager.rs create mode 100644 ostool-serial/src/native.rs create mode 100644 ostool-serial/src/physical.rs create mode 100644 ostool-serial/tests/manager.rs create mode 100644 ostool-server/src/serial/backend.rs create mode 100644 ostool-server/src/serial/runtime.rs create mode 100644 ostool-server/tests/axloader_serial.rs create mode 100644 ostool-server/tests/common/serial.rs create mode 100644 ostool-server/tests/qemu_serial.rs create mode 100644 ostool-server/webui/src/components/serial-runtime.tsx create mode 100644 ostool/src/axloader.rs diff --git a/Cargo.lock b/Cargo.lock index 0d7e4843..5e465bfd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1689,7 +1689,7 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "httpboot-protocol" -version = "0.4.1" +version = "0.5.0" dependencies = [ "serde", "serde_json", @@ -2783,6 +2783,7 @@ dependencies = [ "lzma-rs", "network-interface", "object", + "ostool-serial", "ratatui", "regex", "reqwest", @@ -2806,6 +2807,23 @@ dependencies = [ "url", ] +[[package]] +name = "ostool-serial" +version = "0.1.0" +dependencies = [ + "futures-util", + "httpboot-protocol", + "libc", + "log", + "nix 0.30.1", + "serde", + "serialport", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tokio-serial", +] + [[package]] name = "ostool-server" version = "0.8.2" @@ -2827,6 +2845,7 @@ dependencies = [ "nix 0.30.1", "notify", "nusb", + "ostool-serial", "reqwest", "rust-embed", "schemars", diff --git a/Cargo.toml b/Cargo.toml index 4240695f..a7c0fbbc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ members = [ "fitimage", "ostool-server", "httpboot-protocol", + "ostool-serial", ] resolver = "3" @@ -23,7 +24,9 @@ serde_json = "1" toml = "1.0" url = {version = "2", features = ["serde"]} -httpboot-protocol = { version = "0.4", path = "./httpboot-protocol" } +httpboot-protocol = { version = "0.5", path = "./httpboot-protocol" } + +ostool-serial = { version = "0.1", path = "./ostool-serial" } # Error handling thiserror = "2" diff --git a/docs/admin-ui.md b/docs/admin-ui.md index 6c9d8316..26b96315 100644 --- a/docs/admin-ui.md +++ b/docs/admin-ui.md @@ -11,6 +11,11 @@ 选择发现设备不会自动填写板型、电源或串口,保存前不会生成半成品板卡。 QEMU 使用已有虚拟设备,保留原来的真实网络发现与匹配校验。 +选择 axloader 后,`BoardEditor` 隐藏手动串口开关、SN/path 和线参数,保存 `serial: null`。 +切回 U-Boot 保留编辑中的手动串口草稿。实际参数、端口、错误和 `SerialRuntimeStatus` +由会话的 SSE 展示;`serial_manager` 展示发现请求数与候选监听数。没有黑名单界面。 +继电器配置继续保留,server 在发现前排除其所有路径别名和 SN。 + HTTP Boot 的 `boot_arch` 保留在“高级启动设置”中;留空沿用现有 CLI 的 `x86_64` 默认值,并不表示自动识别。设备上报架构只读展示,普通绑定无需填写该项。 @@ -64,7 +69,7 @@ data: {"epoch":"...","revision":1,"kind":"snapshot","data":{"boards":[],"session 后续事件名为 `update`,信封字段相同,`data` 仅包含改变的资源集合。 集合键包括 `quarantined_boards`、`boards`、`runtimes`、`sessions`、`loaders`、`ota`(镜像和升级任务)、`virtual`、`dtbs`、 -`serial`、`network`、`server`、`tftp`、`tftp_status`、`overview`、`power_actions`。 +`serial`、`serial_manager`、`network`、`server`、`tftp`、`tftp_status`、`overview`、`power_actions`。 集合更新是该集合的完整替换;消失的实体表示删除。前端按稳定 ID/MAC/name 合并并复用未变化的对象,不重新挂载整张表或表单。 diff --git a/docs/api.md b/docs/api.md index 95bfbaab..a1d4e5ff 100644 --- a/docs/api.md +++ b/docs/api.md @@ -316,7 +316,7 @@ GET /api/v1/admin/boards GET /api/v1/admin/boards/{board_id} ``` -列表接口返回 `BoardConfig` 数组,单板接口返回一个 `BoardConfig`。读取时服务端会尝试解析串口稳定标识;解析成功后,`serial` 中会额外出现 `resolved_device_path` 和可选的 `resolved_usb_path`。 +列表接口返回 `BoardConfig` 数组,单板接口返回一个 `BoardConfig`。axloader 写入时将 `serial` 规范化为 `null`。读取手动串口配置时服务端会尝试解析串口稳定标识;解析成功后,`serial` 中会额外出现 `resolved_device_path` 和可选的 `resolved_usb_path`。 创建和更新使用相同请求结构: @@ -374,7 +374,7 @@ Content-Type: application/json } ``` - 启用内建虚拟板时也可使用 `{"kind":"qemu","virtual_device_id":"..."}`。此时串口必须为同一个虚拟设备 ID 的 `qemu` key,并配置相同虚拟设备的 MAC。 + 启用内建虚拟板时也可使用 `{"kind":"qemu","virtual_device_id":"..."}`。此时 axloader 配置使用 `serial: null`,串口 provider 从电源取得,并配置相同虚拟设备的 MAC。 - `boot.kind` 可为上例的 `uboot`、`{"kind":"pxe","notes":null}`,或 `{"kind":"httpboot","boot_arch":"aarch64"}`。`boot_arch` 可为 `x86_64`、`aarch64`、`loongarch64`、`riscv64` 或 `other`。 - `httpboot` 板卡必须提供 `network_identity: {"mac_address":"02:00:00:00:00:01"}`。MAC 会规范化为小写六字节冒号格式并在全部板卡配置中保持唯一;重复绑定返回 `409` 和错误码 `mac_already_bound`。`board_type` 始终由管理员填写,不根据 SMBIOS 或架构推断。 @@ -689,9 +689,9 @@ Content-Type: application/json 本节定义两种后端共用的开发板服务契约:本地局域网模式由 `ostool-server` 直接提供,认证模式由独立认证后端提供受认证的对应接口。这里覆盖 `ostool-server` 的全部公开、非管理 REST 接口。`ostool` 当前命令会使用会话文件上传;配置宿主 initramfs 时还会调用普通 HTTP Boot 文件上传。它不直接调用会话详情、会话文件列表/查询/删除和显式电源控制;这些仍属于公开 board 服务契约,其中显式电源控制也已有 `BoardServerClient` 方法。 -axloader 协议 v2/v3/v4 使用 `POST /api/v1/loaders/poll`、`POST /api/v1/loaders/status` 和 `GET /api/v1/sessions/{session_id}/loader-status`。poll/status 由 UDP 发现返回的一次性 `registration_id` 关联本次固件启动;状态以 `session_id + boot_id + registration_id` 定位,旧代次迟到上报不能覆盖新代次。v2 loader 仅在没有 `initramfs` 与 `cmdline` 时接收启动,否则得到 `boot_payload_unsupported`;v3 保留原有启动契约;v4 另携带 `ota` 状态并可收到 `update` / `confirm_update`。Session 释放时删除启动清单和 loader 状态,不删除独立持久的 OTA 任务。 +axloader 协议 v2/v3/v4 的 `POST /api/v1/loaders/poll`、`POST /api/v1/loaders/status` 和 `GET /api/v1/sessions/{session_id}/loader-status` 保留识别及升级用途。poll/status 由 UDP 发现返回的一次性 `registration_id` 关联本次固件启动;状态以 `session_id + boot_id + registration_id` 定位,旧代次迟到上报不能覆盖新代次。v4 携带 `ota` 状态时仍可收到 `update` / `confirm_update`,普通启动统一返回 `serial_protocol_upgrade_required`。Session 释放时删除启动清单和 loader 状态,不删除独立持久的 OTA 任务。 -v5 axloader 通过 UDP 广播自身地址,ostool-server 随后调用设备的 HTTP 接口。设备 `POST /api/v1/boot/jobs` 创建事务返回 `201`,相同清单重试返回 `200`,冲突清单返回 `409`;服务端观察到同代次的其他启动 ID 时会先删除旧事务并只重试创建一次,删除返回 `404` 也视为旧事务已经消失。服务端构造 v5 清单时完整保留相互独立的可选 `cmdline` 与 `initramfs` 元数据,并把 Session 的兼容入口转换为 `__x86_64_efi_pe_entry`;v2/v3/v4 poll 响应继续返回原入口。`POST /api/v1/boot/jobs/{id}/start` 和 `PUT /api/v1/ota/image` 接受交接或升级后返回 `202`;`POST /api/v1/ota/confirm` 成功返回 `200`,代次、来源或升级 ID 不匹配返回 `409`。完整设备接口及状态机见 [axloader 网络控制与本地验证](axloader-network-control.md#21-启动事务)和[装载器升级](axloader-network-control.md#22-装载器升级)。设备没有可用 OTA 持久区时,`ota` 状态可以为空;服务端跳过升级,但仍可向已有 Session 推送普通启动事务。 +v5/v6 axloader 通过 UDP 广播自身地址,ostool-server 随后调用设备的 HTTP 接口;v5 保留识别与 OTA,自动启动要求 v6。服务器先从当前 UART 身份帧确认物理连线,以本次参数接管租约,再通过网络 continue 和 `X-Serial-Binding` 放行启动。设备 `POST /api/v1/boot/jobs` 创建事务返回 `201`,相同清单重试返回 `200`,冲突清单返回 `409`;服务端观察到同代次的其他启动 ID 时会先删除旧事务并只重试创建一次,删除返回 `404` 也视为旧事务已经消失。清单完整保留相互独立的可选 `cmdline` 与 `initramfs` 元数据,并把 Session 的兼容入口转换为 `__x86_64_efi_pe_entry`。`POST /api/v1/boot/jobs/{id}/start` 和 `PUT /api/v1/ota/image` 接受交接或升级后返回 `202`;`POST /api/v1/ota/confirm` 成功返回 `200`,代次、来源或升级 ID 不匹配返回 `409`。完整设备接口及状态机见 [axloader 网络控制与本地验证](axloader-network-control.md#21-启动事务)和[装载器升级](axloader-network-control.md#22-装载器升级)。设备没有可用 OTA 持久区时,`ota` 状态可以为空;服务端跳过升级,v6 仍可向已有 Session 推送普通启动事务。 装载器镜像库和指派任务使用下列 ostool-server 接口: @@ -760,7 +760,7 @@ Content-Type: application/json } ``` -`ws_url` 在开发板没有串口配置时为 `null`。为兼容包含路径前缀的 Base URL,认证后端应返回不以 `/` 开头的 Base URL 相对路径,或者返回包含完整路径前缀的同源绝对 `ws://`/`wss://` URL。以 `/` 开头的值是 origin-relative URL,只适用于 API 确实部署在域名根目录的情况。 +`ws_url` 在非 axloader 模式且没有串口配置时为 `null`。axloader 的 `serial: null` 仍提供 WebSocket,先建立接收通道再上电和自动绑定。为兼容包含路径前缀的 Base URL,认证后端应返回不以 `/` 开头的 Base URL 相对路径,或者返回包含完整路径前缀的同源绝对 `ws://`/`wss://` URL。以 `/` 开头的值是 origin-relative URL,只适用于 API 确实部署在域名根目录的情况。 `boot_mode` 可为 `uboot`、`pxe` 或 `httpboot`。 @@ -897,7 +897,7 @@ GET /api/v1/sessions/{session_id}/serial } ``` -没有串口时,`available` 和 `connected` 为 `false`,`port`、`baud_rate`、`ws_url` 均为 `null`。配置了串口但服务端无法把稳定标识解析为当前设备路径时返回 `503 Service Unavailable`。 +非 axloader 模式没有串口时,`available` 和 `connected` 为 `false`,`port`、`baud_rate`、`ws_url` 均为 `null`。axloader 连接后 `connected` 只表示 WebSocket;新增 `runtime` 包含 phase、port、parameters、boot_epoch、binding_id、error,`manager` 包含 pending、candidates、leased。实际绑定前 port/baud_rate 为空。配置了串口但服务端无法把稳定标识解析为当前设备路径时返回 `503 Service Unavailable`。 ### 获取 TFTP 状态 @@ -1069,7 +1069,7 @@ X-HttpBoot-Cmdline: # 可选,宿主内核命令行 } ``` -响应模型允许 `kernel_sha256` 为 `null`,但当前 `ostool-server` 会计算并返回 64 位小写十六进制 SHA-256。当前 `ostool board run` 的 HTTP Boot 流程固定发送 `remote_name=kernel.elf`、`image_format=elf64` 和 `entry_symbol=httpboot_entry`;配置 `initramfs` 时先上传为 `initramfs.cpio` 并设置对应路径 Header。v3 poll 响应包含可选的 `initramfs: {path, size, sha256}` 和 `cmdline`;loader 必须从当前 Session 下载归档并核对大小与摘要,校验失败不交接内核。v2 loader 仅能接收两个字段均未配置的启动。 +响应模型允许 `kernel_sha256` 为 `null`,但当前 `ostool-server` 会计算并返回 64 位小写十六进制 SHA-256。当前 `ostool board run` 的 HTTP Boot 流程固定发送 `remote_name=kernel.elf`、`image_format=elf64` 和 `entry_symbol=httpboot_entry`;配置 `initramfs` 时先上传为 `initramfs.cpio` 并设置对应路径 Header。服务器向 v6 设备推送时将兼容入口转换为 `__x86_64_efi_pe_entry`,完整保留独立的可选命令行和归档,核对文件摘要并携带当前串口绑定令牌。旧 poll 装载器需要升级后才能启动。 ## 串口 WebSocket API @@ -1089,9 +1089,9 @@ Upgrade: websocket Authorization: Bearer # 仅 required 模式 ``` -握手时会话不存在返回 `404 Not Found`;开发板没有串口、会话正在释放或已有串口 WebSocket 连接时返回 `409 Conflict`。每个会话同时只允许一个串口连接。 +握手时会话不存在返回 `404 Not Found`;非 axloader 板卡没有串口、会话正在释放或已有串口 WebSocket 连接时返回 `409 Conflict`。每个会话同时只允许一个串口连接。 -WebSocket 连接成功后服务端打开串口、发送 `opened` 控制消息并自动执行开发板上电。服务端将串口输出作为二进制帧发送,客户端也可直接通过二进制帧写入原始串口字节。 +WebSocket 连接成功后发送 `opened` 控制消息并自动执行开发板上电。U-Boot 先打开手动串口;axloader 先建立接收通道,网络就绪后自动发现并移交串口租约,再以绑定令牌确认和启动。等待时继续处理 Ping、Close 和心跳。服务端将串口输出作为二进制帧发送,客户端也可直接通过二进制帧写入原始串口字节。 服务端文本控制消息如下: diff --git a/docs/axloader-network-control.md b/docs/axloader-network-control.md index 7b297b8a..cffc202f 100644 --- a/docs/axloader-network-control.md +++ b/docs/axloader-network-control.md @@ -4,14 +4,14 @@ ### 1.1 入口与发现 -协议 v5 将 HTTP 控制入口放在 axloader。`network::Announcer` 在同一 UEFI 网卡 +协议 v6 将 HTTP 控制入口放在 axloader。`network::Announcer` 在同一 UEFI 网卡 向 UDP `2998` 单向广播 MAC、架构、启动代次和设备 TCP4 端口 `2999`; `direct::Listener` 在该端口处理启动与 OTA。ostool-server 的 -`loader::serve_udp_discovery()` 对 v5 广播调用 `device::reconcile()`,先反向 +`loader::serve_udp_discovery()` 对 v5/v6 广播调用 `device::reconcile()`,先反向 GET 设备状态,核对 MAC、代次和架构,才从板卡 TOML 中查找当前绑定。 发现不要求服务端响应:直连客户端知道设备 IP 时可独立控制装载器。 设备没有可写 ESP 或 OTA 状态区时,状态响应中的 `ota` 可以为空;服务端记录 -告警并跳过升级决策,已有 Session 的普通启动推送仍然可用。 +告警并跳过升级决策,v6 设备已有 Session 的普通启动推送仍然可用。 广播和 HTTP 没有身份认证。MAC 仅作板卡配置绑定键;报文和镜像的 SHA-256 仅检验一致性,本阶段限定可信隔离实验网。服务器对重复 MAC 且不同来源 IP @@ -25,7 +25,7 @@ PUT 上传,各自按清单长度及 SHA-256 检验,每个文件不超过 256 和 initramfs 相互独立且都可省略。`POST /api/v1/boot/jobs/{id}/start` 只接受 x86_64 ELF64 的 `__x86_64_efi_pe_entry`,把 cmdline 安装为 EFI LoadOptions, 并仅在归档存在时发布 TGOS `BootPayload` 配置表;回复 `ready_to_handoff` 后释放 -TCP4 与 UDP4 对象,进入 UEFI 内核交接。 +TCP4、UDP4 对象和 UART 身份计时器,进入 UEFI 内核交接。 没有 ESP 写入或 ostool-server 时,同样可以从设备 IP 直连完成启动。 ```mermaid @@ -34,9 +34,10 @@ sequenceDiagram participant S as ostool-server 或直连工具 L-->>S: UDP :2998 广播(可选) S->>L: GET /api/v1/status + S->>L: continue(bound 身份匹配或显式 direct) S->>L: POST /api/v1/boot/jobs(X-Boot-Epoch) S->>L: PUT kernel;可选 PUT initramfs - S->>L: POST /api/v1/boot/jobs/{id}/start + S->>L: POST /api/v1/boot/jobs/{id}/start(X-Serial-Binding) L-->>S: 202 ready_to_handoff L->>L: 释放网络对象并交接内核 ``` @@ -44,7 +45,7 @@ sequenceDiagram ostool-server 保留原有 Session、串口 WebSocket、启动清单和板卡租约。会话 上传文件后,`device::push_boot()` 从会话存储读取并重新核对长度与摘要, 再调用设备接口;它不把文件 URL 交给装载器。设备在同一 Session 内复位时, -服务器观察新启动代次并按原 `boot_id` 重新推送。串口只承载目标系统输出。 +服务器观察新启动代次并按原 `boot_id` 重新推送。串口在启动前承载本次身份帧,绑定后承载目标系统输出;每次上电都重新验证身份和参数。 若同一启动代次仍保留其他 `boot_id`,服务器在创建返回 `409` 后按状态中的旧 ID 删除该事务,再尝试创建一次;第二次仍冲突则停止本次推送,等待后续设备广播。 @@ -59,12 +60,14 @@ ostool-server 保留原有 Session、串口 WebSocket、启动清单和板卡租 | 方法与路径 | 结果 | | --- | --- | -| `GET /api/v1/status` | 返回 v5、MAC、启动代次、硬件、启动事务及 OTA 状态 | +| `GET /api/v1/status` | 返回 v6、MAC、启动代次、串口参数/绑定、硬件、启动事务及 OTA 状态 | +| `POST /api/v1/serial/continue` | 当前 epoch 的 `serial_id`、`binding_id` 和 `bound/direct`;相同请求幂等 | +| `DELETE /api/v1/serial/bindings/{binding_id}` | 撤销当前匹配绑定,尚在装载器时恢复身份帧 | | `POST /api/v1/boot/jobs` | 提交 `DeviceBootJob`;创建返回 `201`,幂等重试返回 `200` | | `GET /api/v1/boot/jobs/{id}` | 查询事务阶段及已接收文件 | | `PUT /api/v1/boot/jobs/{id}/kernel` | 原始内核,必须带定长和 `X-Image-Sha256` | | `PUT /api/v1/boot/jobs/{id}/initramfs` | 原始可选归档,采用相同校验规则 | -| `POST /api/v1/boot/jobs/{id}/start` | 文件齐备且装载成功返回 `202` 并交接 | +| `POST /api/v1/boot/jobs/{id}/start` | 当前 epoch/绑定令牌通过、文件齐备且装载成功返回 `202` 并交接 | | `DELETE /api/v1/boot/jobs/{id}` | 未交接时释放事务和文件 | 请求头限制 4 KiB;仅接受 `Content-Length`,连接空闲 30 秒后取消,短读和 @@ -134,19 +137,12 @@ ostool-server --config /etc/ostool-server/config.toml virtual-lab down 默认客户机网段为 `10.77.0.0/24`,服务端地址为 `10.77.0.1`,dnsmasq/bridge 地址为 `10.77.0.254`,DHCP 池为 `10.77.0.100-200`。三个子命令均可重复调用。 管理页面启动虚拟设备后,QEMU 必须通过真实 UDP 广播和设备 HTTP 接口出现在 -未绑定列表。绑定时 MAC、串口和电源配置必须引用同一个虚拟设备: +未绑定列表。绑定时 MAC 和电源配置必须引用同一个虚拟设备;串口 provider 从电源取得,板卡不保存串口配置: ```toml [network_identity] mac_address = "02:aa:bb:cc:dd:ee" -[serial] -baud_rate = 115200 - -[serial.key] -kind = "qemu" -value = "" - [power_management] kind = "qemu" virtual_device_id = "" @@ -163,7 +159,7 @@ hub 保留最近 64 KiB 输出并跨 QEMU 重启,新实例广播新的启动 验收顺序如下: 1. 启动未绑定 QEMU,确认它通过真实发现出现在管理页面。 -2. 填写板卡 ID、类型、QEMU 电源和串口,并选择探测到的 MAC。 +2. 填写板卡 ID、类型和 QEMU 电源,并选择探测到的 MAC。 3. 使用 `ostool` 创建 Session、上传内核及可选 initramfs/cmdline,确认设备 HTTP 交接和内核串口标志。 4. 保持 WebSocket 和 Session,执行虚拟板 `Off -> On`,确认新启动代次重新取得相同启动事务并再次交接。 5. 关闭 WebSocket,确认 Session 经 `releasing` 回到 `idle` 且 QEMU 退出。 @@ -173,38 +169,36 @@ hub 保留最近 64 KiB 输出并跨 QEMU 重启,新实例广播新的启动 ### 4.1 兼容边界 -ostool-server 继续接受旧装载器 v2/v3/v4 的 UDP Offer、 -`POST /api/v1/loaders/poll`、状态上报和下载 URL;`httpboot-protocol` 中的 -`PROTOCOL_VERSION` 继续标识这条旧路径,`DEVICE_PROTOCOL_VERSION` 标识 v5。 -服务端只在 `device::push_boot()` 构造 v5 设备清单时把 Session 的 -`httpboot_entry` 转换为 `__x86_64_efi_pe_entry`;v2/v3/v4 poll 继续收到原入口 -和原字段,旧装载器不需要解析新入口。 -v5 装载器只广播,不调用任何服务端 HTTP 接口。TGOS 当前依赖已发布 -`httpboot-protocol 0.3.0`;其 v5 设备请求类型在本地定义,两仓用实际 HTTP -契约测试核对,代码交付不依赖另一仓的绝对路径或发布新 crate。 +ostool-server 保留 v5 的设备识别与 OTA,以及 v2/v3/v4 的旧识别和升级入口。 +自动启动要求 v6,旧装载器在启动前收到明确升级错误,不回退手工串口。 +`PROTOCOL_VERSION` 继续标识旧 poll 路径,`DEVICE_PROTOCOL_VERSION` 为 6。 +TGOS 与 ostool 共用正式版本 `httpboot-protocol 0.5.0`;发布前本地联合验证通过 +忽略的 Cargo patch 指向协议工作树,正式交付先发布协议和 `ostool-serial 0.1.0`, +再更新 registry 锁文件。升级前排空 session,串口定位只在 RAM 保留。 + +串口 channel、独占租约、原始参数恢复、继电器排除和错误恢复的代码边界见 +[串口所有权](axloader-serial-ownership.md)。U-Boot 继续使用手动串口配置与原步骤。 ### 4.2 隔离测试 -`ostool-server/scripts/test-axloader-local.py` 启动本地 ostool-server 与 OVMF/QEMU, -在临时目录创建板卡 TOML、服务端配置、OVMF VARS 和真实 FAT 磁盘。管理入口 -使用独立 loopback 端口,关闭测速与系统 TFTP 接管;QEMU `hostfwd` 指向真实 -客户机 TCP4 监听。测试夹具仅转发 QEMU 广播帧并将公告端口换成本地 -`hostfwd` 端口,服务端必须直接 HTTP 请求客户机完成启动与 OTA。 -Session 启动使用 TGOS 已构建的真实 `arceos-helloworld`,同时上传可选 initramfs -并发送 cmdline;成功条件来自内核输出的 `HOST_CMDLINE`、 -`HOST_INITRAMFS_PASSED` 和 `Hello, world!`,不以装载器准备交接日志代替。 -默认内核来自 TGOS 的 axloader QEMU 测试产物,也可用 `--kernel` 和 -`--initramfs` 显式指定。 +`ostool-server/scripts/test-axloader-local.py` 调用隔离的 `qemu_serial` 集成测试, +运行真实 ostool-server router、HTTP/WebSocket 监听和 OVMF/QEMU。在临时目录 +创建服务存储、OVMF VARS 和 ESP;私有 PTY provider 不枚举或打开实体串口。 +QEMU UART 字节原样经过 PTY,`hostfwd` 指向真实客户机 TCP4;夹具直接把已核对 +的设备公告地址映射到本地端口,不模拟 UART 身份或设备 HTTP。 + +Session 配置为 `serial: null`,server 必须自动匹配 UART、应用上报参数、确认 +并上传真实 ArceOS UEFI ELF。成功条件来自 WebSocket 中的身份帧和内核 +`HOST_CMDLINE`、`HOST_INITRAMFS_PASSED`、`Hello, world!`;退出后等待真实租约归还。 +默认使用 TGOS axloader QEMU 测试产物,也可用 `--kernel`、`--initramfs` 指定。 ```bash cargo build -p ostool-server -python3 ostool-server/scripts/test-axloader-local.py \ - --tgos /path/to/tgoskits-dev \ - --server-bin target/debug/ostool-server +python3 ostool-server/scripts/test-axloader-local.py --tgos /path/to/tgoskits-dev ``` TGOS 单仓用 `cargo xtask axloader test qemu --target x86_64-unknown-uefi` 验证 同一真实 FAT 映像上的直连启动、镜像升级、坏摘要、短请求、待试复位和持久确认。 本地测试不调用安装、更新或 systemd 脚本,不连接 runner,也不刷写实体板卡。 -`hostfwd` 证明本地真实 HTTP 反向调用,不证明实体网络广播与反向路由;后者 +`hostfwd` 证明本地真实 HTTP 反向调用;隔离测试不证明实体网络广播与反向路由;后者 属于日后上线前的单独验收。 diff --git a/docs/axloader-serial-ownership.md b/docs/axloader-serial-ownership.md new file mode 100644 index 00000000..658e6166 --- /dev/null +++ b/docs/axloader-serial-ownership.md @@ -0,0 +1,129 @@ +# axloader 串口发现与会话所有权 + +## 1. 启动身份 + +`httpboot-protocol::LoaderSerialStatus`、`SerialParameters` 和 `SerialBinding` 定义 v6 的设备契约。设备每次上电产生新的 `boot_epoch` 和 32 位小写十六进制 `serial_id`。MAC 用于选择板卡,UART 身份帧用于证明物理连线;SMBIOS 序列号和 USB SN 均不是启动身份。 + +### 1.1 设备参数 + +`SerialBeacon` 从 `ConOut` 对应的唯一 `SerialIo` 读取生效参数。未知参数、多个匹配 UART、没有协议和宿主后端不能表示的参数均产生明确错误。服务器不猜测 115200,也不恢复手工串口字段。 + +| 字段 | 含义与约束 | +| --- | --- | +| `baud_rate` | 生效速率;宿主检查 u32 范围、驱动设置结果及读回值 | +| `data_bits` | ASCII 身份帧支持 7 或 8 位 | +| `parity` | none/odd/even/mark/space;当前宿主驱动拒绝 mark/space | +| `stop_bits` | one/one_point_five/two;当前宿主驱动拒绝 1.5 位 | +| `flow_control` | none 或 rts_cts;不复制瞬时 CTS 信号 | + +设备不报告宿主读取超时或缓冲大小。`NativeBackend` 保留自己的 20 ms 读取期限、缓冲和调度方式,设置后核对驱动读回值;参数不符不能继续启动。 + +### 1.2 网络门禁 + +HTTP 和 UART 就绪后,固件每 250 ms 尝试发送 `\r\nAXLOADER-SERIAL/1 \r\n`。`POST /api/v1/serial/continue` 使用当前 `X-Boot-Epoch` 和 `SerialBinding`,相同请求幂等,冲突请求拒绝。启动还需 `X-Serial-Binding`。上传、查询和 OTA 不受串口门禁限制。 + +`bound` 表示已经由宿主身份匹配并接管串口;`direct` 是直连客户端显式放行,没有串口隧道。`DELETE /api/v1/serial/bindings/{binding_id}` 只能撤销当前代次的匹配令牌;设备还在 axloader 时恢复发送身份。服务器取消请求或启动失败时,以代次和令牌进行有期限的尽力撤销。 + +## 2. 单一资源所有者 + +`AppState::serial_manager` 创建唯一 `ostool-serial::SerialManager`。共享组件不依赖 Axum。CLI 使用相同组件创建本地管理器;服务器中的会话、手动 U-Boot 串口和继电器操作均通过管理器协调端口。 + +### 2.1 发现与缓存 + +`BindRequest` 携带所有者、请求代次、MAC、启动代次、身份、参数和截止时间。`Discovery` actor 串行处理 channel 消息,端口枚举与打开在独立任务执行,避免阻塞取消和继电器保留请求。 + +有 RAM 定位时先按本次参数验证对应口 1 秒;失败清除定位后进入共享发现。多个请求共享候选 IO 和读取者;并行打开的端口按规范化路径去重。参数组合去重后每 1 秒轮换。扫描期限是 `max(5 秒, 组合数 × 2 秒)`,不超过请求剩余期限。设备等待上限 60 秒;无身份帧时返回错误,不无限占用串口。 + +`SerialLease::confirm()` 只在网络确认成功后提交 `MAC → PortLocator`。缓存不包含启动 ID、绑定令牌或句柄;session 结束保留定位,读写/验证出错清除定位,进程重启全部丢失。缓存每次仍须匹配新启动身份。 + +### 2.2 租约移交与释放 + +发现阶段拥有整个 IO、唯一接收线程和输入缓冲。匹配后 `SerialLease` 整体移动给 `SessionSerialRuntime`,不重新打开,不清空缓冲,不创建第二个读取者。身份帧紧跟的内核输出仍通过原读取者交给 WebSocket。 + +```mermaid +sequenceDiagram + participant W as WebSocket + participant S as SessionSerialRuntime + participant M as SerialManager + participant L as axloader + W->>S: 建立接收通道 + S->>L: 上电并等待网络身份 + L-->>S: 新 epoch、serial_id、参数 + S->>M: channel 绑定请求 + L-->>M: UART 身份帧 + M->>S: IO、读取者、缓冲和不可克隆租约 + S->>L: continue(epoch, serial_id, binding_id, bound) + S->>M: 确认 RAM 定位 + S->>L: 上传并 start(X-Serial-Binding) + L-->>W: 原读取者转发内核输出 + W->>S: Close 或会话结束 + S->>M: 句柄关闭且接收线程退出后归还 +``` + +`SerialLease::drop()` 在阻塞回收任务中关闭 IO,等待接收线程退出,然后发送带租约 token 的 Release。`wait_owner_released()` 等实际归还,不能用 WebSocket 的布尔状态替代。最后一个发现请求退出后,全部未出租候选关闭,迟到打开结果也先关闭再解除保留。 + +### 2.3 继电器与候选设置 + +全部板卡的 `ZhongshengRelay` 配置被转成 `PortSelector`,覆盖 SN、by-path 和规范化路径。配置保存前安装新排除集合并等待冲突候选关闭;保存失败恢复原集合。临时电源操作先 `reserve_power_serial()`,待 IO 完成再释放保留。已有 session 或 U-Boot 租约不能被发现抢占。 + +独占打开失败跳过。发现只读取身份,不发送探测命令,不以 DTR/RTS 切换探测。未绑定候选和归还的租约恢复原始线设置;Linux 保存 `termios2`,包含任意速率的实际值。短时打开、设置和读取仍可能影响不遵守独占机制的外部程序;“可打开”不等于“无干扰”。本地 CLI 无法获知另一服务器的未使用继电器配置,应在与实验设备对应的宿主运行,使用中的独占口会被跳过。 + +## 3. 会话与界面 + +`serial_connected` 只表示 WebSocket 已建立。实际 UART 状态由 `SerialRuntimeStatus` 表示;UI 经现有 SSE 订阅 `sessions` 和 `serial_manager`,展示参数、端口、状态、错误、发现请求数和候选数。 + +### 3.1 上电与错误恢复 + +上电或下电递增会话启动代次,取消旧绑定、网络确认和启动任务。现有租约可保留,但新启动必须重新读取参数并验证新 ID。等待绑定时仍处理 Close、Ping、心跳和取消。 + +串口读写错误清除定位并重新申请。已确认绑定的设备仍在 axloader 时,下一次网络协调撤销旧令牌,使身份帧恢复。已经进入内核而没有新的身份帧时,恢复等待最多 5 秒后失败并执行原会话清理;不会主动断电重启内核。 + +### 3.2 配置和命令 + +axloader 表单隐藏串口开关、SN/path 和线参数,保存 `serial: null`;编辑时切回 U-Boot 保留手动字段草稿。QEMU 的 provider 来自电源配置的虚拟设备 ID;仍必须解析实际身份帧。U-Boot 保留原手动串口和启动步骤。 + +```bash +ostool axloader run --device http://DEVICE:2999 --kernel kernel.elf \ + --initramfs initramfs.cpio --cmdline 'console=ttyS0' +ostool axloader continue --device http://DEVICE:2999 +``` + +`run` 自动配置本地口并输出串口,在交互终端转发键盘输入;Unix 非交互模式只输出,沿用现有终端行为。交互输入复用 `sterm::Input` 的可取消非阻塞读取,不留下等待换行的工作线程。Ctrl-C 归还租约,尚在装载器时尽力撤销绑定。`continue` 只执行 direct 放行,调用方随后上传和启动时仍须带设备状态中的匹配令牌。 + +## 4. 验证与迁移 + +管理器确定性交错测试覆盖多请求共享、身份隔离、迟到打开回收、继电器别名排除、定位复用、新参数及重启丢缓存。实际 PTY/HTTP/WebSocket 测试证明两次上电的无损移交和原设置恢复;QEMU 验证真实固件身份和真实内核输出。 + +### 4.1 可复现入口 + +ostool 使用相关 crate 测试、Clippy、server 构建和 pnpm 测试、构建、Playwright。QEMU 的隔离夹具只列出其私有 PTY,逐字节转发 OVMF UART 并把 guest TCP4 映射到 hostfwd;不接触系统服务或实体串口。 + +```bash +cargo test -p httpboot-protocol -p ostool-serial -p ostool-server +cargo clippy -p httpboot-protocol -p ostool-serial -p ostool-server -p ostool --all-targets -- -D warnings +python3 ostool-server/scripts/test-axloader-local.py --tgos /path/to/tgoskits-dev +``` + +TGOS 使用 `cargo fmt --package axloader --package axbuild`、`cargo xtask clippy --package axloader --package axbuild` 和 `cargo xtask axloader test qemu --target x86_64-unknown-uefi`。实体板卡的首次/再次上电、新会话、参数变化、重启与未使用端口释放需要单独验收;本地模拟不证明实体 USB 驱动或实验网路由。 + +### 4.2 发布顺序 + +设备协议依赖版本为 `httpboot-protocol 0.5.0`,共享组件为 `ostool-serial 0.1.0`。先审查并发布协议,再发布组件及 ostool,更新 TGOS 的 registry 锁文件后再协调升级。开发验证可用 Cargo 本地 patch,但不得把开发机器的路径依赖作为正式交付。 + +维护窗口先排空 session。server 保留 v5 识别与 OTA,旧 poll 设备仍可识别和升级,但自动启动明确要求 v6;不提供手动串口回退。启动身份和定位不持久化,重启后再次进入 axloader 才能绑定。当前验证范围是 x86_64 UEFI、Linux 宿主和可信实验网络;部署、实体板卡刷写和新架构支持独立授权。 + +### 4.3 本次本地验收记录 + +2026-10-08 的实现基于 TGOSKits `dev@135ad7af001a64d78d6b950411bc786a1c9221d7` 和 ostool `main@bbc9d08d75b750acbf37538220cae43de5e74a09`。以下结果来自当前开发工作树及协议本地 patch,尚不是发布版本或远程持续集成结论。最后同步的 `135ad7af00` 只修改无关的 Starry AIO 测试,不影响已验证的 axloader 和 ArceOS 启动路径。 + +| 验证 | 结果与证据边界 | +| --- | --- | +| 协议、管理器与 server | 协议 8 项、共享串口 6 项、server 单元 163 项,以及 PTY/HTTP/WebSocket、网络和会话集成均通过 | +| 回归敏感度 | 同一测试在跳过可支持参数、错误 USB 身份匹配、以电源就绪阻塞 WS 接收的实现上失败,恢复修复后通过 | +| 静态与构建 | 两仓库定向格式化、相关 crate Clippy、server 构建通过;TGOS Clippy 包含真实 UEFI 目标 | +| 固件启动与 OTA | 项目 QEMU 套件通过真实身份、bound/direct 门禁、四种内核载荷组合、真实 FAT OTA、试运行回滚和持久确认 | +| 自动隧道装配 | 独立 OVMF → 原样 UART → 私有 PTY → server HTTP/WebSocket → ArceOS,确认身份、参数、内核输出和 initramfs/cmdline | +| UI | 28 项单元测试、构建、2 项浏览器旅程通过,核对自动串口区、草稿、焦点、滚动和窄屏 | +| CLI | 330 项库测试通过,包含复用输入读取器的取消与标志恢复;`run` 命令入口构建通过,本地 HTTP 契约夹具验证 direct continue 可重复且无需 UART,实际 USB `run` 尚待板卡联调 | + +实体板卡、生产 UDP/netns 路径、非 Linux 宿主、正式依赖发布后的 registry 构建和最终提交持续集成尚未验收。协议与共享组件发布后,应去掉开发 patch,重新生成 registry 锁文件,再进行维护窗口升级。 diff --git a/httpboot-protocol/Cargo.toml b/httpboot-protocol/Cargo.toml index f94c2a8e..92eff4ff 100644 --- a/httpboot-protocol/Cargo.toml +++ b/httpboot-protocol/Cargo.toml @@ -7,7 +7,7 @@ keywords = ["httpboot", "uefi", "bootloader"] license = "MIT OR Apache-2.0" name = "httpboot-protocol" repository = "https://github.com/drivercraft/ostool" -version = "0.4.1" +version = "0.5.0" [features] default = ["std", "json"] diff --git a/httpboot-protocol/src/lib.rs b/httpboot-protocol/src/lib.rs index ab55482c..2a383fd0 100644 --- a/httpboot-protocol/src/lib.rs +++ b/httpboot-protocol/src/lib.rs @@ -5,12 +5,16 @@ extern crate alloc; use core::{fmt, str::FromStr}; +mod serial; +pub use serial::*; + #[cfg(feature = "alloc")] use alloc::{string::String, vec::Vec}; pub const PROTOCOL_VERSION: u16 = 4; -/// Loader-hosted HTTP protocol. v2-v4 remain valid for server-hosted polling. -pub const DEVICE_PROTOCOL_VERSION: u16 = 5; +/// Loader-hosted HTTP with per-boot serial binding. v5 supports OTA migration only. +pub const DEVICE_PROTOCOL_VERSION: u16 = 6; +pub const PREVIOUS_DEVICE_PROTOCOL_VERSION: u16 = 5; pub const PREVIOUS_PROTOCOL_VERSION: u16 = 3; pub const LEGACY_PROTOCOL_VERSION: u16 = 2; pub const MAX_HOST_CMDLINE_BYTES: usize = 4095; @@ -169,7 +173,7 @@ pub struct LoaderDiscoveryProbe { pub loader_version: String, } -/// A v5 loader advertises its own HTTP endpoint without registering with a server. +/// A v5/v6 loader advertises its own HTTP endpoint without registering with a server. #[cfg(feature = "alloc")] #[cfg_attr(feature = "json", derive(serde::Serialize, serde::Deserialize))] #[derive(Debug, Clone, PartialEq, Eq)] @@ -181,6 +185,10 @@ pub struct LoaderAnnouncement { pub loader_version: String, pub boot_epoch: String, pub http_port: u16, + #[cfg_attr(feature = "json", serde(default))] + pub serial_id: Option, + #[cfg_attr(feature = "json", serde(default))] + pub serial_ready: bool, } /// The caller owns the boot ID; the loader owns the current boot epoch. @@ -229,6 +237,8 @@ pub struct LoaderDeviceStatus { pub hardware: LoaderHardwareInfo, pub boot: Option, pub ota: Option, + #[cfg_attr(feature = "json", serde(default))] + pub serial: Option, } #[cfg(feature = "alloc")] diff --git a/httpboot-protocol/src/serial.rs b/httpboot-protocol/src/serial.rs new file mode 100644 index 00000000..0fa55f67 --- /dev/null +++ b/httpboot-protocol/src/serial.rs @@ -0,0 +1,238 @@ +//! Wire parameters and streaming identification shared by firmware and hosts. + +#[cfg(feature = "alloc")] +use alloc::string::String; + +pub const SERIAL_BEACON_PREFIX: &[u8] = b"AXLOADER-SERIAL/1 "; +pub const SERIAL_ID_BYTES: usize = 32; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr(feature = "json", derive(serde::Serialize, serde::Deserialize))] +pub struct SerialParameters { + pub baud_rate: u64, + pub data_bits: u8, + pub parity: SerialParity, + pub stop_bits: SerialStopBits, + pub flow_control: SerialFlowControl, +} + +impl SerialParameters { + /// Reject unknown parameters and formats that cannot carry the ASCII beacon. + pub fn validate(self) -> Result<(), SerialProtocolError> { + if self.baud_rate == 0 || !matches!(self.data_bits, 7 | 8) { + return Err(SerialProtocolError::InvalidParameters); + } + Ok(()) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr(feature = "json", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "json", serde(rename_all = "snake_case"))] +pub enum SerialParity { + None, + Odd, + Even, + Mark, + Space, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr(feature = "json", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "json", serde(rename_all = "snake_case"))] +pub enum SerialStopBits { + One, + OnePointFive, + Two, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr(feature = "json", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "json", serde(rename_all = "snake_case"))] +pub enum SerialFlowControl { + None, + RtsCts, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "json", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "json", serde(rename_all = "snake_case"))] +pub enum SerialBindingMode { + Bound, + Direct, +} + +#[cfg(feature = "alloc")] +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "json", derive(serde::Serialize, serde::Deserialize))] +pub struct SerialBinding { + pub serial_id: String, + pub binding_id: String, + pub mode: SerialBindingMode, +} + +#[cfg(feature = "alloc")] +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "json", derive(serde::Serialize, serde::Deserialize))] +pub struct LoaderSerialStatus { + pub serial_id: String, + pub ready: bool, + pub parameters: Option, + pub binding: Option, + pub error: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SerialProtocolError { + InvalidId, + InvalidParameters, + Conflict, + NotReady, +} + +impl core::fmt::Display for SerialProtocolError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str(match self { + Self::InvalidId => "serial ID must contain 32 lowercase hexadecimal characters", + Self::InvalidParameters => "unknown or unsupported serial parameters", + Self::Conflict => "serial binding belongs to another boot or owner", + Self::NotReady => "serial binding is not ready", + }) + } +} +impl core::error::Error for SerialProtocolError {} + +pub fn valid_serial_id(id: &str) -> bool { + id.len() == SERIAL_ID_BYTES + && id + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) +} + +#[cfg(feature = "alloc")] +impl LoaderSerialStatus { + /// Grant a binding only for this boot. An identical retry is idempotent. + pub fn grant(&mut self, binding: SerialBinding) -> Result<(), SerialProtocolError> { + if binding.serial_id != self.serial_id || !valid_serial_id(&binding.binding_id) { + return Err(SerialProtocolError::Conflict); + } + if binding.mode == SerialBindingMode::Bound && !self.ready { + return Err(SerialProtocolError::NotReady); + } + match &self.binding { + Some(current) if current != &binding => Err(SerialProtocolError::Conflict), + _ => { + self.binding = Some(binding); + Ok(()) + } + } + } + + /// A stale revocation must never revoke a newer owner's grant. + pub fn revoke(&mut self, binding_id: &str) -> Result<(), SerialProtocolError> { + if self + .binding + .as_ref() + .is_none_or(|b| b.binding_id != binding_id) + { + return Err(SerialProtocolError::Conflict); + } + self.binding = None; + Ok(()) + } + + pub fn permits_start(&self, binding_id: &str) -> bool { + self.binding + .as_ref() + .is_some_and(|b| b.binding_id == binding_id && b.serial_id == self.serial_id) + } +} + +/// Bounded line decoder. Oversized/noisy lines cannot grow host memory. +pub struct SerialFrameDecoder { + line: [u8; 64], + len: usize, + overflow: bool, +} + +impl Default for SerialFrameDecoder { + fn default() -> Self { + Self { + line: [0; 64], + len: 0, + overflow: false, + } + } +} + +impl SerialFrameDecoder { + /// Returns a complete ID; arbitrary chunk boundaries and CRLF are supported. + pub fn push(&mut self, byte: u8) -> Option<[u8; SERIAL_ID_BYTES]> { + if byte != b'\n' { + if self.len == self.line.len() { + self.overflow = true; + } else { + self.line[self.len] = byte; + self.len += 1; + } + return None; + } + let end = self.len - usize::from(self.len > 0 && self.line[self.len - 1] == b'\r'); + let result = if !self.overflow { + self.line[..end] + .strip_prefix(SERIAL_BEACON_PREFIX) + .and_then(|id| { + let text = core::str::from_utf8(id).ok()?; + valid_serial_id(text) + .then(|| id.try_into().expect("validated serial ID length")) + }) + } else { + None + }; + self.len = 0; + self.overflow = false; + result + } +} + +#[cfg(all(test, feature = "alloc"))] +mod tests { + use super::*; + #[test] + fn identification_and_grants_reject_stale_owners() { + let id = "0123456789abcdef0123456789abcdef"; + let mut decoder = SerialFrameDecoder::default(); + let bytes = alloc::format!( + "{}\n\r\nAXLOADER-SERIAL/1 {id}\r\nkernel\n", + "x".repeat(100) + ); + let ids: alloc::vec::Vec<_> = bytes.bytes().filter_map(|b| decoder.push(b)).collect(); + assert_eq!(ids, [*id.as_bytes().first_chunk::<32>().unwrap()]); + let mut status = LoaderSerialStatus { + serial_id: id.into(), + ready: false, + parameters: None, + binding: None, + error: Some("no UART".into()), + }; + let mut binding = SerialBinding { + serial_id: id.into(), + binding_id: "a".repeat(32), + mode: SerialBindingMode::Bound, + }; + assert!(!status.permits_start(&binding.binding_id)); + assert_eq!( + status.grant(binding.clone()), + Err(SerialProtocolError::NotReady) + ); + binding.mode = SerialBindingMode::Direct; + status.grant(binding.clone()).unwrap(); + status.grant(binding.clone()).unwrap(); + assert!(status.permits_start(&binding.binding_id)); + assert!(status.revoke(&"b".repeat(32)).is_err()); + status.revoke(&binding.binding_id).unwrap(); + assert!(!status.permits_start(&binding.binding_id)); + binding.serial_id = "c".repeat(32); + assert!(status.grant(binding).is_err()); + } +} diff --git a/ostool-serial/Cargo.toml b/ostool-serial/Cargo.toml new file mode 100644 index 00000000..a2efb60a --- /dev/null +++ b/ostool-serial/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "ostool-serial" +version = "0.1.0" +edition = "2024" +license = "MIT OR Apache-2.0" +description = "On-demand serial discovery and ownership for axloader sessions" +repository = "https://github.com/drivercraft/ostool" + +[dependencies] +httpboot-protocol.workspace = true +tokio.workspace = true +serialport = "4.6" +tokio-serial = "5.4" +futures-util = "0.3" +thiserror.workspace = true +log.workspace = true +serde.workspace = true + +[target.'cfg(unix)'.dependencies] +nix = { version = "0.30", features = ["term", "fs"] } +libc = "0.2" + +[dev-dependencies] +tempfile = "3" diff --git a/ostool-serial/src/lib.rs b/ostool-serial/src/lib.rs new file mode 100644 index 00000000..c1cc506b --- /dev/null +++ b/ostool-serial/src/lib.rs @@ -0,0 +1,276 @@ +//! A single on-demand discovery actor with transferable serial leases. +use httpboot_protocol::SerialFrameDecoder; +pub use httpboot_protocol::{ + MacAddress, SerialFlowControl, SerialParameters, SerialParity, SerialStopBits, +}; +use std::{ + collections::VecDeque, + future::Future, + io, + pin::Pin, + sync::Arc, + task::{Context, Poll}, +}; +use tokio::{ + io::{AsyncRead, AsyncWrite, ReadBuf}, + sync::{mpsc, oneshot, watch}, + time::Instant, +}; + +mod manager; +mod native; +#[cfg(unix)] +mod physical; +pub use native::{NativeBackend, validate_host_parameters}; +#[cfg(unix)] +pub use physical::PhysicalSerial; + +pub type BackendFuture<'a, T> = Pin> + Send + 'a>>; +/// IO owned by exactly one candidate or session. Configuration never adds a reader. +pub trait SerialIo: AsyncRead + AsyncWrite + Unpin + Send { + fn configure(&mut self, parameters: SerialParameters) -> io::Result<()>; +} +pub trait SerialBackend: Send + Sync + 'static { + fn ports(&self) -> BackendFuture<'_, Vec>; + fn open( + &self, + port: PortLocator, + parameters: SerialParameters, + ) -> BackendFuture<'_, Box>; +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PortLocator { + pub name: String, + pub aliases: Vec, + pub serial_number: Option, +} +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PortSelector { + Path(String), + SerialNumber(String), +} +impl PortLocator { + pub fn matches(&self, selector: &PortSelector) -> bool { + match selector { + PortSelector::SerialNumber(sn) => self.serial_number.as_ref() == Some(sn), + PortSelector::Path(path) => { + self.name == *path + || self.aliases.contains(path) + || std::fs::canonicalize(path).is_ok_and(|p| p.to_string_lossy() == self.name) + } + } + } +} + +#[derive(Debug, Clone)] +pub struct BindRequest { + pub owner: String, + pub generation: u64, + pub mac_address: MacAddress, + pub boot_epoch: String, + pub serial_id: String, + pub parameters: SerialParameters, + pub deadline: Instant, +} + +#[derive(Debug, thiserror::Error)] +pub enum BindError { + #[error("serial manager stopped")] + Stopped, + #[error("serial binding timed out")] + Timeout, + #[error("serial identity discovery failed: {0}")] + DiscoveryFailed(String), + #[error("serial resource is busy")] + Busy, + #[error("invalid serial identity or parameters")] + InvalidRequest, + #[error("serial operation failed: {0}")] + Io(#[from] io::Error), +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct ManagerSnapshot { + pub pending: usize, + pub candidates: usize, + pub leased: usize, +} + +/// Cloneable command channel; one actor owns all discovery and memory locations. +#[derive(Clone)] +pub struct SerialManager { + commands: mpsc::UnboundedSender, + snapshot: watch::Receiver, +} +impl SerialManager { + pub fn new(backend: Arc) -> Self { + manager::start(backend) + } + pub fn subscribe(&self) -> watch::Receiver { + self.snapshot.clone() + } + pub async fn bind(&self, request: BindRequest) -> Result { + if request.boot_epoch.is_empty() + || !httpboot_protocol::valid_serial_id(&request.serial_id) + || request.parameters.validate().is_err() + { + return Err(BindError::InvalidRequest); + } + let (reply, response) = oneshot::channel(); + self.commands + .send(manager::Command::Bind(request, reply)) + .map_err(|_| BindError::Stopped)?; + response.await.map_err(|_| BindError::Timeout)? + } + /// Update configured relay exclusions. Completion means conflicting candidate IO stopped. + pub async fn exclude(&self, selectors: Vec) -> Result<(), BindError> { + let (reply, response) = oneshot::channel(); + self.commands + .send(manager::Command::Exclude(selectors, reply)) + .map_err(|_| BindError::Stopped)?; + response.await.map_err(|_| BindError::Stopped)? + } + /// Reserve manual/relay IO before an external opener touches the device. + pub async fn reserve(&self, port: PortLocator) -> Result { + let (reply, response) = oneshot::channel(); + self.commands + .send(manager::Command::Reserve(port, reply)) + .map_err(|_| BindError::Stopped)?; + response.await.map_err(|_| BindError::Stopped)? + } + pub async fn wait_owner_released(&self, owner: &str) -> Result<(), BindError> { + let (reply, response) = oneshot::channel(); + self.commands + .send(manager::Command::WaitOwner(owner.into(), reply)) + .map_err(|_| BindError::Stopped)?; + response.await.map_err(|_| BindError::Stopped) + } + pub fn invalidate(&self, mac: MacAddress) { + let _ = self.commands.send(manager::Command::Invalidate(mac)); + } +} + +/// Prevents discovery from opening external IO. Close that IO before dropping this guard. +pub struct PortReservation { + port: String, + token: u64, + commands: mpsc::UnboundedSender, +} +impl Drop for PortReservation { + fn drop(&mut self) { + let _ = self + .commands + .send(manager::Command::Release(self.port.clone(), self.token)); + } +} + +/// Non-cloneable IO lease. Drop starts closing the reader before advertising availability. +/// Call `SerialManager::wait_owner_released` to await the actual asynchronous close. +pub struct SerialLease { + stream: Option>, + prefix: VecDeque, + locator: PortLocator, + token: u64, + mac: MacAddress, + commands: mpsc::UnboundedSender, + decoder: SerialFrameDecoder, + observed: Option<[u8; 32]>, +} +impl SerialLease { + pub fn locator(&self) -> &PortLocator { + &self.locator + } + /// Publish a location only after the device acknowledges this binding. + pub fn confirm(&self) { + let _ = self.commands.send(manager::Command::Confirm( + self.mac, + self.locator.clone(), + self.token, + )); + } + pub fn configure(&mut self, parameters: SerialParameters) -> io::Result<()> { + self.decoder = SerialFrameDecoder::default(); + self.observed = None; + self.stream + .as_mut() + .expect("live lease") + .configure(parameters) + } + /// Only fresh bytes from the current reader can prove a subsequent boot. + pub fn take_observed_id(&mut self) -> Option<[u8; 32]> { + self.observed.take() + } +} +impl Drop for SerialLease { + fn drop(&mut self) { + let stream = self.stream.take(); + let commands = self.commands.clone(); + let port = self.locator.name.clone(); + let token = self.token; + let close = move || { + drop(stream); + let _ = commands.send(manager::Command::Release(port, token)); + }; + if let Ok(runtime) = tokio::runtime::Handle::try_current() { + runtime.spawn_blocking(close); + } else { + close(); + } + } +} +impl AsyncRead for SerialLease { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + out: &mut ReadBuf<'_>, + ) -> Poll> { + if !self.prefix.is_empty() { + while out.remaining() > 0 { + let Some(b) = self.prefix.pop_front() else { + break; + }; + out.put_slice(&[b]); + } + return Poll::Ready(Ok(())); + } + let start = out.filled().len(); + let result = Pin::new(self.stream.as_mut().expect("live lease")).poll_read(cx, out); + if let Poll::Ready(Ok(())) = &result { + for b in &out.filled()[start..] { + if let Some(id) = self.decoder.push(*b) { + self.observed = Some(id); + } + } + } + if matches!(&result, Poll::Ready(Err(_))) + || matches!(&result, Poll::Ready(Ok(())) if out.filled().len() == start && out.remaining() > 0) + { + let _ = self.commands.send(manager::Command::Invalidate(self.mac)); + } + result + } +} +impl AsyncWrite for SerialLease { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + bytes: &[u8], + ) -> Poll> { + let result = Pin::new(self.stream.as_mut().expect("live lease")).poll_write(cx, bytes); + if matches!(result, Poll::Ready(Err(_))) { + let _ = self.commands.send(manager::Command::Invalidate(self.mac)); + } + result + } + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let result = Pin::new(self.stream.as_mut().expect("live lease")).poll_flush(cx); + if matches!(result, Poll::Ready(Err(_))) { + let _ = self.commands.send(manager::Command::Invalidate(self.mac)); + } + result + } + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(self.stream.as_mut().expect("live lease")).poll_shutdown(cx) + } +} diff --git a/ostool-serial/src/manager.rs b/ostool-serial/src/manager.rs new file mode 100644 index 00000000..a1f31883 --- /dev/null +++ b/ostool-serial/src/manager.rs @@ -0,0 +1,625 @@ +use crate::{ + BindError, BindRequest, ManagerSnapshot, PortLocator, PortReservation, PortSelector, + SerialBackend, SerialIo, SerialLease, SerialManager, +}; +use httpboot_protocol::{MacAddress, SerialFrameDecoder, SerialParameters}; +use std::{ + collections::{BTreeMap, BTreeSet, VecDeque}, + sync::Arc, + time::Duration, +}; +use tokio::{ + io::AsyncReadExt, + sync::{mpsc, oneshot, watch}, + task::JoinSet, + time::Instant, +}; + +pub(crate) enum Command { + Bind(BindRequest, oneshot::Sender>), + Exclude(Vec, oneshot::Sender>), + Reserve( + PortLocator, + oneshot::Sender>, + ), + Release(String, u64), + Confirm(MacAddress, PortLocator, u64), + Invalidate(MacAddress), + WaitOwner(String, oneshot::Sender<()>), +} +struct Pending { + request: BindRequest, + reply: oneshot::Sender>, + started: Instant, + scanning: bool, + scan_deadline: Instant, +} +struct Candidate { + generation: u64, + locator: PortLocator, + stop: watch::Sender, + closing: bool, +} +struct Owner { + token: u64, + name: String, + locator: PortLocator, +} +enum Event { + Found { + locator: PortLocator, + generation: u64, + id: String, + stream: Box, + prefix: VecDeque, + }, + Closed(String, u64), + Inventory(std::io::Result>), + Fault(String, String), +} +struct Discovery { + backend: Arc, + commands: mpsc::WeakUnboundedSender, + events: mpsc::UnboundedSender, + snapshot: watch::Sender, + expected: watch::Sender>, + pending: Vec, + candidates: BTreeMap, + leased: BTreeMap, + locations: BTreeMap, + exclusions: Vec, + excluded_replies: Vec>>, + reservations: Vec<( + PortLocator, + oneshot::Sender>, + )>, + release_waiters: Vec<(String, oneshot::Sender<()>)>, + failed: BTreeSet, + errors: BTreeMap, + sequence: u64, + ports: Vec, + enumerating: bool, + next_enumeration: Instant, + tasks: JoinSet<()>, +} + +pub(crate) fn start(backend: Arc) -> SerialManager { + let (commands, mut requests) = mpsc::unbounded_channel(); + let (events, mut observations) = mpsc::unbounded_channel(); + let (snapshot, current) = watch::channel(ManagerSnapshot::default()); + let (expected, _) = watch::channel(Vec::new()); + let mut state = Discovery { + backend, + commands: commands.downgrade(), + events, + snapshot, + expected, + pending: Vec::new(), + candidates: BTreeMap::new(), + leased: BTreeMap::new(), + locations: BTreeMap::new(), + exclusions: Vec::new(), + excluded_replies: Vec::new(), + reservations: Vec::new(), + release_waiters: Vec::new(), + failed: BTreeSet::new(), + errors: BTreeMap::new(), + sequence: 0, + ports: Vec::new(), + enumerating: false, + next_enumeration: Instant::now(), + tasks: JoinSet::new(), + }; + tokio::spawn(async move { + let mut tick = tokio::time::interval(Duration::from_millis(25)); + loop { + tokio::select! { + command = requests.recv() => match command { Some(c) => state.command(c), None => break }, + event = observations.recv() => if let Some(event) = event { state.event(event); }, + _ = tick.tick() => {}, + result = state.tasks.join_next(), if !state.tasks.is_empty() => { + if let Some(Err(error)) = result { log::error!("serial discovery worker failed: {error}"); } + } + } + state.reconcile(); + } + for c in state.candidates.values() { + let _ = c.stop.send(true); + } + while state.tasks.join_next().await.is_some() {} + }); + SerialManager { + commands, + snapshot: current, + } +} + +impl Discovery { + fn command(&mut self, command: Command) { + match command { + Command::Bind(request, reply) => { + if reply.is_closed() { + return; + } + // Superseding boots cancel old requests without allowing stale completions. + self.pending.retain(|p| { + !(p.request.owner == request.owner + && p.request.mac_address == request.mac_address + && p.request.generation < request.generation) + }); + if self.pending.iter().any(|p| { + p.request.mac_address == request.mac_address + || p.request.serial_id == request.serial_id + }) { + let _ = reply.send(Err(BindError::Busy)); + return; + } + if self.pending.is_empty() { + self.errors.clear(); + } + if !self + .pending + .iter() + .any(|p| p.request.parameters == request.parameters) + { + self.failed.clear(); + } + let scanning = !self.locations.contains_key(&request.mac_address); + let started = Instant::now(); + self.pending.push(Pending { + scan_deadline: request.deadline.min(started + Duration::from_secs(5)), + request, + reply, + started, + scanning, + }); + } + Command::Exclude(selectors, reply) => { + if selectors.iter().any(|selector| { + !self.exclusions.contains(selector) + && self + .leased + .values() + .any(|owner| owner.locator.matches(selector)) + }) { + let _ = reply.send(Err(BindError::Busy)); + return; + } + self.exclusions = selectors; + self.excluded_replies.push(reply); + } + Command::Reserve(port, reply) => { + if self.leased.contains_key(&port.name) + || self.reservations.iter().any(|(p, _)| p.name == port.name) + { + let _ = reply.send(Err(BindError::Busy)); + } else { + self.reservations.push((port, reply)); + } + } + Command::Release(port, token) => { + if self + .leased + .get(&port) + .is_some_and(|owner| owner.token == token) + { + self.leased.remove(&port); + } + } + Command::Confirm(mac, locator, token) => { + if self + .leased + .get(&locator.name) + .is_some_and(|lease| lease.token == token) + { + self.locations.insert(mac, locator); + } + } + Command::Invalidate(mac) => { + self.locations.remove(&mac); + } + Command::WaitOwner(owner, reply) => { + self.release_waiters.push((owner, reply)); + } + } + } + fn next_token(&mut self) -> u64 { + self.sequence = self + .sequence + .checked_add(1) + .expect("serial lease sequence exhausted"); + self.sequence + } + fn event(&mut self, event: Event) { + match event { + Event::Fault(port, error) => { + self.errors.insert(port, error); + } + Event::Inventory(result) => { + self.enumerating = false; + match result { + Ok(ports) => self.ports = ports, + Err(e) => log::debug!("serial inventory: {e}"), + } + } + Event::Closed(name, generation) => { + if self + .candidates + .get(&name) + .is_some_and(|c| c.generation == generation) + { + let candidate = self.candidates.remove(&name).expect("matching candidate"); + if !candidate.closing { + self.failed.insert(name); + } + } + } + Event::Found { + locator, + generation, + id, + stream, + prefix, + } => { + let current = self + .candidates + .get(&locator.name) + .is_some_and(|c| c.generation == generation && !c.closing); + if self + .candidates + .get(&locator.name) + .is_some_and(|c| c.generation == generation) + { + self.candidates.remove(&locator.name); + } + let position = self.pending.iter().position(|p| { + p.request.serial_id == id + && !p.reply.is_closed() + && p.request.deadline > Instant::now() + }); + if current + && let Some(position) = position + && let Some(commands) = self.commands.upgrade() + { + let pending = self.pending.remove(position); + let token = self.next_token(); + self.leased.insert( + locator.name.clone(), + Owner { + token, + name: pending.request.owner.clone(), + locator: locator.clone(), + }, + ); + let lease = SerialLease { + stream: Some(stream), + prefix, + locator, + + token, + mac: pending.request.mac_address, + commands, + decoder: SerialFrameDecoder::default(), + observed: None, + }; + // A dropped receiver drops the returned lease and closes IO before release. + let _ = pending.reply.send(Ok(lease)); + } else { + // Keep the name reserved until this stale stream actually closes. + let stop = watch::channel(true).0; + self.candidates.insert( + locator.name.clone(), + Candidate { + generation, + locator: locator.clone(), + stop, + closing: true, + }, + ); + let events = self.events.clone(); + self.tasks.spawn(async move { + let _ = tokio::task::spawn_blocking(move || drop(stream)).await; + let _ = events.send(Event::Closed(locator.name, generation)); + }); + } + } + } + } + fn reconcile(&mut self) { + let now = Instant::now(); + let count = self + .pending + .iter() + .map(|p| p.request.parameters) + .collect::>() + .len(); + let scan_window = Duration::from_secs(5.max(count as u64 * 2)); + let mut remaining = Vec::new(); + for mut p in std::mem::take(&mut self.pending) { + if p.reply.is_closed() { + continue; + } + if !p.scanning && now.duration_since(p.started) >= Duration::from_secs(1) { + self.locations.remove(&p.request.mac_address); + p.scanning = true; + p.scan_deadline = p.request.deadline.min(now + scan_window); + } + if p.scanning { + p.scan_deadline = p + .scan_deadline + .max((p.started + scan_window).min(p.request.deadline)); + } + if p.request.deadline <= now || (p.scanning && p.scan_deadline <= now) { + self.locations.remove(&p.request.mac_address); + let error = if self.errors.is_empty() { + BindError::Timeout + } else { + BindError::DiscoveryFailed( + self.errors + .iter() + .map(|(port, error)| format!("{port}: {error}")) + .collect::>() + .join("; "), + ) + }; + let _ = p.reply.send(Err(error)); + } else { + remaining.push(p); + } + } + self.pending = remaining; + let expected = self + .pending + .iter() + .map(|p| (p.request.serial_id.clone(), p.request.parameters)) + .collect::>(); + self.expected.send_if_modified(|old| { + if *old == expected { + false + } else { + *old = expected; + true + } + }); + let excluded = &self.exclusions; + for c in self.candidates.values_mut() { + if self.pending.is_empty() + || excluded.iter().any(|p| c.locator.matches(p)) + || self + .reservations + .iter() + .any(|(p, _)| p.name == c.locator.name) + { + c.closing = true; + let _ = c.stop.send(true); + } + } + if !self + .candidates + .values() + .any(|c| self.exclusions.iter().any(|p| c.locator.matches(p))) + { + for reply in self.excluded_replies.drain(..) { + let _ = reply.send(Ok(())); + } + } + let mut waiting = Vec::new(); + for (port, reply) in std::mem::take(&mut self.reservations) { + if reply.is_closed() { + continue; + } + if self.candidates.contains_key(&port.name) { + waiting.push((port, reply)); + continue; + } + if let Some(commands) = self.commands.upgrade() { + let token = self.next_token(); + self.leased.insert( + port.name.clone(), + Owner { + token, + name: "manual".into(), + locator: port.clone(), + }, + ); + let _ = reply.send(Ok(PortReservation { + port: port.name, + token, + commands, + })); + } + } + self.reservations = waiting; + let mut waiters = Vec::new(); + for (owner, reply) in std::mem::take(&mut self.release_waiters) { + if reply.is_closed() { + continue; + } + if self.leased.values().any(|l| l.name == owner) + || self.pending.iter().any(|p| p.request.owner == owner) + || (self.pending.is_empty() && !self.candidates.is_empty()) + { + waiters.push((owner, reply)); + } else { + let _ = reply.send(()); + } + } + self.release_waiters = waiters; + if !self.pending.is_empty() { + if !self.enumerating && now >= self.next_enumeration { + self.enumerating = true; + self.next_enumeration = now + Duration::from_millis(250); + let backend = self.backend.clone(); + let events = self.events.clone(); + self.tasks.spawn(async move { + let _ = events.send(Event::Inventory(backend.ports().await)); + }); + } + let ports = self.ports.clone(); + { + let full = self.pending.iter().any(|p| p.scanning); + let mut serial_counts = BTreeMap::new(); + for port in &ports { + *serial_counts.entry(port.serial_number.clone()).or_insert(0) += 1; + } + for locator in ports { + if self.candidates.contains_key(&locator.name) + || self.leased.contains_key(&locator.name) + || self.failed.contains(&locator.name) + || self.exclusions.iter().any(|p| locator.matches(p)) + || self + .reservations + .iter() + .any(|(p, _)| p.name == locator.name) + { + continue; + } + let cached = self.pending.iter().any(|p| { + self.locations + .get(&p.request.mac_address) + .is_some_and(|old| { + old.name == locator.name + || old + .aliases + .iter() + .any(|alias| locator.aliases.contains(alias)) + || old.serial_number.is_some() + && old.serial_number == locator.serial_number + && serial_counts.get(&locator.serial_number) == Some(&1) + }) + }); + if !full && !cached { + continue; + } + let generation = self.next_token(); + let (stop, cancellation) = watch::channel(false); + self.candidates.insert( + locator.name.clone(), + Candidate { + generation, + locator: locator.clone(), + stop, + closing: false, + }, + ); + let backend = self.backend.clone(); + let events = self.events.clone(); + let expected = self.expected.subscribe(); + self.tasks.spawn(scan( + backend, + locator, + generation, + expected, + cancellation, + events, + )); + } + } + } + self.snapshot.send_if_modified(|s| { + let next = ManagerSnapshot { + pending: self.pending.len(), + candidates: self.candidates.len(), + leased: self.leased.len(), + }; + if *s == next { + false + } else { + *s = next; + true + } + }); + } +} + +async fn scan( + backend: Arc, + locator: PortLocator, + generation: u64, + mut expected: watch::Receiver>, + mut stop: watch::Receiver, + events: mpsc::UnboundedSender, +) { + let parameters = expected + .borrow() + .iter() + .map(|p| p.1) + .collect::>(); + let mut opened = None; + for parameters in parameters { + if *stop.borrow() { + break; + } + // Do not abort an in-flight open: a late native descriptor must close. + match backend.open(locator.clone(), parameters).await { + Ok(stream) => { + opened = Some((stream, parameters)); + break; + } + Err(error) => { + let skip = matches!( + error.kind(), + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::PermissionDenied + ); + let _ = events.send(Event::Fault( + locator.name.clone(), + format!("open/configure {parameters:?}: {error}"), + )); + if skip { + break; + } + } + } + } + let Some((mut stream, first)) = opened else { + let _ = events.send(Event::Closed(locator.name, generation)); + return; + }; + let mut decoder = SerialFrameDecoder::default(); + let mut history = VecDeque::new(); + let mut bytes = [0; 4096]; + let mut rotation = tokio::time::interval(Duration::from_secs(1)); + rotation.tick().await; + let mut parameter_index = 0; + let mut active_parameters = Some(first); + loop { + if *stop.borrow() { + break; + } + tokio::select! { + changed = stop.changed() => { if changed.is_err() || *stop.borrow() { break; } }, + changed = expected.changed() => { if changed.is_err() || expected.borrow().is_empty() { break; } }, + _ = rotation.tick() => { + let parameters = expected.borrow().iter().map(|p|p.1).collect::>().into_iter().collect::>(); + if parameters.is_empty() { break; } + let next = parameters[parameter_index % parameters.len()]; + if Some(next) != active_parameters { + active_parameters = match stream.configure(next) { + Ok(()) => Some(next), + Err(error) => { let _ = events.send(Event::Fault(locator.name.clone(), format!("configure {next:?}: {error}"))); None } + }; + decoder = SerialFrameDecoder::default(); + } + parameter_index += 1; + } + result = stream.read(&mut bytes) => match result { + Ok(0) => { let _ = events.send(Event::Fault(locator.name.clone(), "serial reader closed".into())); break; } + Err(error) => { let _ = events.send(Event::Fault(locator.name.clone(), error.to_string())); break; } + Ok(size) => { + history.extend(&bytes[..size]); + if history.len() > 64*1024 { history.drain(..history.len()-64*1024); } + for byte in &bytes[..size] { + if let Some(id) = decoder.push(*byte) { + let id = String::from_utf8(id.to_vec()).expect("ASCII decoder"); + if expected.borrow().iter().any(|p|p.0 == id && Some(p.1) == active_parameters) { + let _ = events.send(Event::Found { locator, generation, id, stream, prefix: history }); return; + } + } + } + } + } + } + } + let _ = tokio::task::spawn_blocking(move || drop(stream)).await; + let _ = events.send(Event::Closed(locator.name, generation)); +} diff --git a/ostool-serial/src/native.rs b/ostool-serial/src/native.rs new file mode 100644 index 00000000..de8c9510 --- /dev/null +++ b/ostool-serial/src/native.rs @@ -0,0 +1,229 @@ +use crate::{BackendFuture, PortLocator, SerialBackend, SerialIo}; +use httpboot_protocol::{SerialFlowControl, SerialParameters, SerialParity, SerialStopBits}; +use serialport::{DataBits, FlowControl, Parity, SerialPort, StopBits}; +use std::{io, path::PathBuf}; + +#[derive(Default)] +pub struct NativeBackend; + +impl SerialBackend for NativeBackend { + fn ports(&self) -> BackendFuture<'_, Vec> { + Box::pin(async { + tokio::task::spawn_blocking(enumerate) + .await + .map_err(io::Error::other)? + }) + } + fn open( + &self, + port: PortLocator, + parameters: SerialParameters, + ) -> BackendFuture<'_, Box> { + Box::pin(async move { + tokio::task::spawn_blocking(move || open(&port.name, parameters)) + .await + .map_err(io::Error::other)? + }) + } +} + +fn enumerate() -> io::Result> { + let mut ports = serialport::available_ports() + .map_err(io::Error::other)? + .into_iter() + .map(|p| { + let serial_number = match p.port_type { + serialport::SerialPortType::UsbPort(usb) => usb.serial_number, + _ => None, + }; + let canonical = + std::fs::canonicalize(&p.port_name).unwrap_or_else(|_| PathBuf::from(&p.port_name)); + PortLocator { + name: canonical.to_string_lossy().into_owned(), + aliases: vec![p.port_name], + serial_number, + } + }) + .collect::>(); + if let Ok(entries) = std::fs::read_dir("/dev/serial/by-path") { + for entry in entries.flatten() { + if let Ok(path) = std::fs::canonicalize(entry.path()) + && let Some(port) = ports.iter_mut().find(|p| p.name == path.to_string_lossy()) + { + port.aliases + .push(entry.path().to_string_lossy().into_owned()); + } + } + } + ports.sort_by(|a, b| a.name.cmp(&b.name)); + ports.dedup_by(|a, b| a.name == b.name); + Ok(ports) +} + +/// Reject line settings the portable host driver cannot represent before opening any port. +pub fn validate_host_parameters(p: SerialParameters) -> io::Result<()> { + p.validate().map_err(io::Error::other)?; + u32::try_from(p.baud_rate).map_err(io::Error::other)?; + if matches!(p.parity, SerialParity::Mark | SerialParity::Space) + || p.stop_bits == SerialStopBits::OnePointFive + { + return Err(io::Error::new( + io::ErrorKind::Unsupported, + "host serial backend does not support reported parity or stop bits", + )); + } + Ok(()) +} + +pub(crate) fn configure(port: &mut dyn SerialPort, p: SerialParameters) -> io::Result<()> { + validate_host_parameters(p)?; + let baud = u32::try_from(p.baud_rate).map_err(io::Error::other)?; + let parity = match p.parity { + SerialParity::None => Parity::None, + SerialParity::Odd => Parity::Odd, + SerialParity::Even => Parity::Even, + _ => { + return Err(io::Error::new( + io::ErrorKind::Unsupported, + "host does not support mark/space parity", + )); + } + }; + let stops = match p.stop_bits { + SerialStopBits::One => StopBits::One, + SerialStopBits::Two => StopBits::Two, + _ => { + return Err(io::Error::new( + io::ErrorKind::Unsupported, + "host does not support 1.5 stop bits", + )); + } + }; + port.set_baud_rate(baud).map_err(io::Error::other)?; + let bits = if p.data_bits == 7 { + DataBits::Seven + } else { + DataBits::Eight + }; + port.set_data_bits(bits).map_err(io::Error::other)?; + port.set_parity(parity).map_err(io::Error::other)?; + port.set_stop_bits(stops).map_err(io::Error::other)?; + let flow = match p.flow_control { + SerialFlowControl::None => FlowControl::None, + SerialFlowControl::RtsCts => FlowControl::Hardware, + }; + port.set_flow_control(flow).map_err(io::Error::other)?; + if port.baud_rate().map_err(io::Error::other)? != baud + || port.data_bits().map_err(io::Error::other)? != bits + || port.parity().map_err(io::Error::other)? != parity + || port.stop_bits().map_err(io::Error::other)? != stops + || port.flow_control().map_err(io::Error::other)? != flow + { + return Err(io::Error::new( + io::ErrorKind::Unsupported, + "serial driver did not apply reported line parameters", + )); + } + Ok(()) +} + +#[cfg(unix)] +pub(crate) struct RestoreSettings { + file: std::fs::File, + #[cfg(not(target_os = "linux"))] + settings: nix::sys::termios::Termios, + #[cfg(target_os = "linux")] + settings: libc::termios2, +} +#[cfg(unix)] +impl Drop for RestoreSettings { + fn drop(&mut self) { + #[cfg(not(target_os = "linux"))] + let result = nix::sys::termios::tcsetattr( + &self.file, + nix::sys::termios::SetArg::TCSANOW, + &self.settings, + ) + .map_err(io::Error::other); + #[cfg(target_os = "linux")] + let result = { + use std::os::fd::AsRawFd; + // SAFETY: the duplicate descriptor remains live until Drop finishes; + // TCSETS2 reads a complete captured termios2, retaining no pointer. + if unsafe { libc::ioctl(self.file.as_raw_fd(), libc::TCSETS2, &self.settings) } < 0 { + Err(io::Error::last_os_error()) + } else { + Ok(()) + } + }; + if let Err(error) = result { + log::warn!("failed to restore serial settings: {error}"); + } + } +} + +#[cfg(unix)] +fn open(name: &str, parameters: SerialParameters) -> io::Result> { + use std::os::{ + fd::{AsRawFd, FromRawFd, IntoRawFd}, + unix::fs::OpenOptionsExt, + }; + let file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .custom_flags(libc::O_NOCTTY | libc::O_NONBLOCK) + .open(name)?; + let original = nix::sys::termios::tcgetattr(&file).map_err(io::Error::other)?; + #[cfg(target_os = "linux")] + let settings = { + let mut settings = std::mem::MaybeUninit::::uninit(); + // SAFETY: TCGETS2 initializes the entire correctly aligned object on success; + // file is live and the kernel retains no pointer after ioctl returns. + if unsafe { libc::ioctl(file.as_raw_fd(), libc::TCGETS2, settings.as_mut_ptr()) } < 0 { + return Err(io::Error::last_os_error()); + } + // SAFETY: successful TCGETS2 initialized every field. + unsafe { settings.assume_init() } + }; + // SAFETY: file owns a live tty descriptor. Nonblocking flock has no retained pointer. + if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } != 0 { + return Err(io::Error::last_os_error()); + } + let restore = RestoreSettings { + file: file.try_clone()?, + #[cfg(not(target_os = "linux"))] + settings: original.clone(), + #[cfg(target_os = "linux")] + settings, + }; + // SAFETY: ownership of the validated live tty descriptor moves exactly once into TTYPort. + let mut port = unsafe { serialport::TTYPort::from_raw_fd(file.into_raw_fd()) }; + port.set_exclusive(true).map_err(io::Error::other)?; + let mut raw = original; + nix::sys::termios::cfmakeraw(&mut raw); + raw.control_flags + .insert(nix::sys::termios::ControlFlags::CLOCAL | nix::sys::termios::ControlFlags::CREAD); + nix::sys::termios::tcsetattr(&restore.file, nix::sys::termios::SetArg::TCSANOW, &raw) + .map_err(io::Error::other)?; + configure(&mut port, parameters)?; + Ok(Box::new(crate::physical::PhysicalSerial::restoring( + port, restore, + )?)) +} + +#[cfg(not(unix))] +fn open(name: &str, parameters: SerialParameters) -> io::Result> { + use tokio_serial::SerialPortBuilderExt; + let baud = u32::try_from(parameters.baud_rate).map_err(io::Error::other)?; + let mut port = tokio_serial::new(name, baud) + .open_native_async() + .map_err(io::Error::other)?; + configure(&mut port, parameters)?; + Ok(Box::new(port)) +} +#[cfg(not(unix))] +impl SerialIo for tokio_serial::SerialStream { + fn configure(&mut self, parameters: SerialParameters) -> io::Result<()> { + configure(self, parameters) + } +} diff --git a/ostool-serial/src/physical.rs b/ostool-serial/src/physical.rs new file mode 100644 index 00000000..74d9ef5e --- /dev/null +++ b/ostool-serial/src/physical.rs @@ -0,0 +1,236 @@ +//! Physical RX keeps draining even when the HTTP/WebSocket executor is busy. + +use std::{ + collections::VecDeque, + io::{self, Read}, + pin::Pin, + sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, + }, + task::{Context, Poll}, + thread::JoinHandle, + time::Duration, +}; + +use futures_util::task::AtomicWaker; +use serialport::{SerialPort, TTYPort}; +use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; + +const CHUNK_SIZE: usize = 4096; +const QUEUE_BYTES: usize = CHUNK_SIZE * 64; +const READ_TIMEOUT: Duration = Duration::from_millis(20); + +#[derive(Default)] +struct ReceiveState { + bytes: VecDeque, + closed: bool, + error: Option, +} + +#[derive(Default)] +struct ReceiveBuffer { + state: Mutex, + waker: AtomicWaker, + stopped: AtomicBool, +} + +/// One blocking native reader feeds an async console without waiting for network IO. +/// Drop joins that reader before restoring the original line settings and closing IO. +pub struct PhysicalSerial { + pub(crate) writer: tokio_serial::SerialStream, + receive: Arc, + reader: Option>, + restore: Option, +} + +impl PhysicalSerial { + /// Start the sole receive worker without clearing any already buffered input. + pub fn new(mut port: TTYPort) -> io::Result { + port.set_timeout(READ_TIMEOUT)?; + + let writer = port.try_clone_native()?.try_into()?; + let receive = Arc::new(ReceiveBuffer::default()); + let shared = receive.clone(); + let reader = std::thread::Builder::new() + .name("serial-rx".into()) + .spawn(move || { + let mut buffer = [0; CHUNK_SIZE]; + let error = loop { + if shared.stopped.load(Ordering::Acquire) { + break None; + } + match port.read(&mut buffer) { + Ok(0) => break None, + Ok(size) => { + let mut state = shared.state.lock().expect("serial RX mutex poisoned"); + if size > QUEUE_BYTES - state.bytes.len() { + break Some(io::Error::other("physical serial RX buffer full")); + } + state.bytes.extend(&buffer[..size]); + drop(state); + shared.waker.wake(); + } + Err(error) + if matches!( + error.kind(), + io::ErrorKind::TimedOut + | io::ErrorKind::WouldBlock + | io::ErrorKind::Interrupted + ) => {} + Err(error) => break Some(error), + } + }; + let mut state = shared.state.lock().expect("serial RX mutex poisoned"); + state.error = error; + state.closed = true; + drop(state); + shared.waker.wake(); + })?; + Ok(Self { + writer, + receive, + reader: Some(reader), + restore: None, + }) + } + + /// Stop and join the reader; this can block for its finite native read timeout. + pub fn stop_reader(&mut self) { + self.receive.stopped.store(true, Ordering::Release); + if let Some(reader) = self.reader.take() { + // Never wait for queue capacity or network I/O. The worker's only + // blocking operation is the finite serial read timeout. Join before + // releasing the lease, including cancellation, to prevent an old + // reader consuming bytes from the next owner's session. + if reader.join().is_err() { + log::error!("physical serial receive worker panicked"); + } + } + } + /// Inspect output queued in the native driver without performing tcdrain. + pub fn bytes_to_write(&self) -> io::Result { + self.writer.bytes_to_write().map_err(io::Error::other) + } + /// Explicit manual-console cleanup; discovery never calls this operation. + pub fn clear(&self, buffer: serialport::ClearBuffer) -> io::Result<()> { + self.writer.clear(buffer).map_err(io::Error::other) + } + #[cfg(test)] + fn test_receive_snapshotter( + &self, + ) -> impl Fn() -> (Vec, bool, bool) + Send + Sync + 'static { + let receive = self.receive.clone(); + move || { + let state = receive.state.lock().expect("serial RX mutex poisoned"); + ( + state.bytes.iter().copied().collect(), + state.closed, + state.error.is_some(), + ) + } + } +} + +impl Drop for PhysicalSerial { + fn drop(&mut self) { + self.stop_reader(); + drop(self.restore.take()); + } +} + +impl AsyncRead for PhysicalSerial { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + output: &mut ReadBuf<'_>, + ) -> Poll> { + if output.remaining() == 0 { + return Poll::Ready(Ok(())); + } + // Register before inspecting state; producer publication and wake cannot + // be lost between an empty check and returning Pending. + self.receive.waker.register(cx.waker()); + let mut state = self.receive.state.lock().expect("serial RX mutex poisoned"); + if !state.bytes.is_empty() { + let size = output.remaining().min(state.bytes.len()); + let (first, second) = state.bytes.as_slices(); + let first_size = size.min(first.len()); + output.put_slice(&first[..first_size]); + output.put_slice(&second[..size - first_size]); + state.bytes.drain(..size); + return Poll::Ready(Ok(())); + } + if state.closed { + return Poll::Ready(state.error.take().map_or(Ok(()), Err)); + } + Poll::Pending + } +} + +impl AsyncWrite for PhysicalSerial { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + bytes: &[u8], + ) -> Poll> { + Pin::new(&mut self.writer).poll_write(cx, bytes) + } + fn poll_flush(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll> { + // Unbuffered writes: never invoke tcdrain on the network executor. + Poll::Ready(Ok(())) + } + fn poll_shutdown(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } +} + +impl crate::SerialIo for PhysicalSerial { + fn configure( + &mut self, + parameters: httpboot_protocol::SerialParameters, + ) -> std::io::Result<()> { + super::native::configure(&mut self.writer, parameters) + } +} + +impl PhysicalSerial { + pub(crate) fn restoring( + port: TTYPort, + restore: super::native::RestoreSettings, + ) -> io::Result { + let mut stream = Self::new(port)?; + stream.restore = Some(restore); + Ok(stream) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tokio::io::AsyncReadExt; + + #[tokio::test] + async fn receive_overflow_preserves_all_accepted_bytes_before_error() { + let (mut board, port) = TTYPort::pair().unwrap(); + board.set_timeout(Duration::from_millis(200)).unwrap(); + let mut serial = PhysicalSerial::new(port).unwrap(); + let snapshot = serial.test_receive_snapshotter(); + let writer = std::thread::spawn(move || { + let _ = (0..4096).try_for_each(|_| board.write_all(&[0x5a; 128])); + board + }); + let _board = writer.join().unwrap(); + let (expected, closed, error) = snapshot(); + assert!(closed && error && !expected.is_empty()); + assert!(expected.len() <= QUEUE_BYTES); + let mut actual = Vec::new(); + let error = tokio::time::timeout(Duration::from_secs(2), serial.read_to_end(&mut actual)) + .await + .unwrap() + .unwrap_err(); + assert!(error.to_string().contains("physical serial RX buffer full")); + assert_eq!(actual, expected); + } +} diff --git a/ostool-serial/tests/manager.rs b/ostool-serial/tests/manager.rs new file mode 100644 index 00000000..010e08d2 --- /dev/null +++ b/ostool-serial/tests/manager.rs @@ -0,0 +1,370 @@ +//! Discovery, lease transfer and cancellation through the shared public API. +use ostool_serial::{ + BackendFuture, BindRequest, ManagerSnapshot, PortLocator, PortSelector, SerialBackend, + SerialIo, SerialManager, SerialParameters, +}; +use std::{ + collections::BTreeMap, + io, + pin::Pin, + sync::{ + Arc, Mutex, + atomic::{AtomicUsize, Ordering}, + }, + task::{Context, Poll}, + time::Duration, +}; +use tokio::{ + io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, DuplexStream, ReadBuf}, + sync::{mpsc, watch}, + time::Instant, +}; + +struct TestIo { + io: DuplexStream, + closed: Arc, + parameters: Arc>>, +} +impl Drop for TestIo { + fn drop(&mut self) { + self.closed.fetch_add(1, Ordering::SeqCst); + } +} +impl AsyncRead for TestIo { + fn poll_read( + mut self: Pin<&mut Self>, + c: &mut Context<'_>, + b: &mut ReadBuf<'_>, + ) -> Poll> { + Pin::new(&mut self.io).poll_read(c, b) + } +} +impl AsyncWrite for TestIo { + fn poll_write( + mut self: Pin<&mut Self>, + c: &mut Context<'_>, + b: &[u8], + ) -> Poll> { + Pin::new(&mut self.io).poll_write(c, b) + } + fn poll_flush(mut self: Pin<&mut Self>, c: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.io).poll_flush(c) + } + fn poll_shutdown(mut self: Pin<&mut Self>, c: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.io).poll_shutdown(c) + } +} +impl SerialIo for TestIo { + fn configure(&mut self, p: SerialParameters) -> io::Result<()> { + self.parameters.lock().unwrap().push(p); + Ok(()) + } +} +struct TestBackend { + ports: Vec, + streams: Mutex>, + opens: mpsc::UnboundedSender, + gate: watch::Receiver, + closed: Arc, + parameters: Arc>>, +} +impl SerialBackend for TestBackend { + fn ports(&self) -> BackendFuture<'_, Vec> { + Box::pin(async { Ok(self.ports.clone()) }) + } + fn open( + &self, + p: PortLocator, + config: SerialParameters, + ) -> BackendFuture<'_, Box> { + Box::pin(async move { + self.opens.send(p.name.clone()).unwrap(); + let mut gate = self.gate.clone(); + gate.wait_for(|v| *v).await.map_err(io::Error::other)?; + let io = self + .streams + .lock() + .unwrap() + .remove(&p.name) + .ok_or_else(|| io::Error::other("port already opened"))?; + self.parameters.lock().unwrap().push(config); + Ok(Box::new(TestIo { + io, + closed: self.closed.clone(), + parameters: self.parameters.clone(), + }) as Box) + }) + } +} +fn parameters() -> SerialParameters { + SerialParameters { + baud_rate: 57600, + data_bits: 8, + parity: httpboot_protocol::SerialParity::None, + stop_bits: httpboot_protocol::SerialStopBits::One, + flow_control: httpboot_protocol::SerialFlowControl::None, + } +} +fn request(owner: &str, n: u8) -> BindRequest { + BindRequest { + owner: owner.into(), + generation: 1, + mac_address: format!("02:00:00:00:00:{n:02x}").parse().unwrap(), + boot_epoch: format!("{n:032x}"), + serial_id: format!("{n:032x}"), + parameters: parameters(), + deadline: Instant::now() + Duration::from_secs(60), + } +} +fn backend( + names: &[&str], + ready: bool, +) -> ( + Arc, + Vec, + mpsc::UnboundedReceiver, + watch::Sender, +) { + let mut streams = BTreeMap::new(); + let mut peers = Vec::new(); + for name in names { + let (a, b) = tokio::io::duplex(65536); + streams.insert(name.to_string(), a); + peers.push(b); + } + let (opens, rx) = mpsc::unbounded_channel(); + let (gate, wait) = watch::channel(ready); + ( + Arc::new(TestBackend { + ports: names + .iter() + .map(|n| PortLocator { + name: n.to_string(), + aliases: vec![format!("alias/{n}")], + serial_number: None, + }) + .collect(), + streams: Mutex::new(streams), + opens, + gate: wait, + closed: Arc::default(), + parameters: Arc::default(), + }), + peers, + rx, + gate, + ) +} +async fn snapshot(manager: &SerialManager, predicate: impl Fn(&ManagerSnapshot) -> bool) { + let mut rx = manager.subscribe(); + tokio::time::timeout(Duration::from_secs(2), rx.wait_for(predicate)) + .await + .unwrap() + .unwrap(); +} +#[tokio::test] +async fn parallel_discovery_transfers_tail_and_waits_for_actual_release() { + let (backend, mut peers, mut opens, _gate) = backend(&["a", "b"], true); + let manager = SerialManager::new(backend.clone()); + let m = manager.clone(); + let first = tokio::spawn(async move { m.bind(request("first", 1)).await.unwrap() }); + let m = manager.clone(); + let second = tokio::spawn(async move { m.bind(request("second", 2)).await.unwrap() }); + let a = opens.recv().await.unwrap(); + let b = opens.recv().await.unwrap(); + assert_ne!(a, b); + let one = format!("\r\nAXLOADER-SERIAL/1 {:032x}\r\nKERNEL_ONE\n", 1); + let two = format!("\r\nAXLOADER-SERIAL/1 {:032x}\r\nKERNEL_TWO\n", 2); + peers[0].write_all(one.as_bytes()).await.unwrap(); + peers[1].write_all(two.as_bytes()).await.unwrap(); + let mut first = first.await.unwrap(); + let mut second = second.await.unwrap(); + let mut bytes = vec![0; one.len()]; + first.read_exact(&mut bytes).await.unwrap(); + assert_eq!(bytes, one.as_bytes()); + let mut bytes = vec![0; two.len()]; + second.read_exact(&mut bytes).await.unwrap(); + assert_eq!(bytes, two.as_bytes()); + let m = manager.clone(); + let waiter = tokio::spawn(async move { m.wait_owner_released("first").await }); + snapshot(&manager, |s| s.leased == 2).await; + assert!(!waiter.is_finished()); + drop(first); + assert!(waiter.await.unwrap().is_ok()); + assert_eq!(backend.closed.load(Ordering::SeqCst), 1); + drop(second); + snapshot(&manager, |s| s.leased == 0).await; + assert_eq!(backend.closed.load(Ordering::SeqCst), 2); +} +#[tokio::test] +async fn cancelled_open_and_relay_reservation_reclaim_late_descriptors() { + let (backend, _peers, mut opens, gate) = backend(&["a"], false); + let manager = SerialManager::new(backend.clone()); + let m = manager.clone(); + let pending = tokio::spawn(async move { m.bind(request("cancelled", 1)).await }); + assert_eq!(opens.recv().await.unwrap(), "a"); + pending.abort(); + let _ = pending.await; + let m = manager.clone(); + let locator = backend.ports[0].clone(); + let reserve = tokio::spawn(async move { m.reserve(locator).await.unwrap() }); + snapshot(&manager, |s| s.pending == 0).await; + assert!(!reserve.is_finished()); + gate.send_replace(true); + let guard = reserve.await.unwrap(); + assert_eq!(backend.closed.load(Ordering::SeqCst), 1); + drop(guard); + snapshot(&manager, |s| s.leased == 0 && s.candidates == 0).await; +} +#[tokio::test] +async fn configured_relay_alias_never_opens() { + let (backend, _peers, mut opens, _gate) = backend(&["relay"], true); + let manager = SerialManager::new(backend); + manager + .exclude(vec![PortSelector::Path("alias/relay".into())]) + .await + .unwrap(); + let m = manager.clone(); + let pending = tokio::spawn(async move { m.bind(request("board", 1)).await }); + snapshot(&manager, |s| s.pending == 1).await; + assert!(opens.try_recv().is_err()); + pending.abort(); + let _ = pending.await; + snapshot(&manager, |s| s.pending == 0 && s.candidates == 0).await; +} + +#[tokio::test] +async fn confirmed_location_reopens_only_known_port_with_current_parameters_and_id() { + let (backend, mut peers, mut opens, _gate) = backend(&["a", "unused"], true); + let manager = SerialManager::new(backend.clone()); + let m = manager.clone(); + let first = tokio::spawn(async move { m.bind(request("first", 1)).await.unwrap() }); + let a = opens.recv().await.unwrap(); + let b = opens.recv().await.unwrap(); + assert_ne!(a, b); + peers[0] + .write_all(format!("\r\nAXLOADER-SERIAL/1 {:032x}\r\n", 1).as_bytes()) + .await + .unwrap(); + let lease = first.await.unwrap(); + lease.confirm(); + drop(lease); + manager.wait_owner_released("first").await.unwrap(); + snapshot(&manager, |s| s.leased == 0 && s.candidates == 0).await; + let (io, mut peer) = tokio::io::duplex(65536); + backend.streams.lock().unwrap().insert("a".into(), io); + let mut reboot = request("second", 1); + reboot.serial_id = format!("{:032x}", 3); + reboot.boot_epoch = reboot.serial_id.clone(); + reboot.parameters.baud_rate = 921600; + let m = manager.clone(); + let second = tokio::spawn(async move { m.bind(reboot).await.unwrap() }); + assert_eq!(opens.recv().await.unwrap(), "a"); + peer.write_all(format!("\r\nAXLOADER-SERIAL/1 {:032x}\r\n", 1).as_bytes()) + .await + .unwrap(); + assert!(!second.is_finished()); + peer.write_all(format!("\r\nAXLOADER-SERIAL/1 {:032x}\r\n", 3).as_bytes()) + .await + .unwrap(); + let lease = second.await.unwrap(); + assert_eq!(lease.locator().name, "a"); + assert!(opens.try_recv().is_err()); + assert_eq!( + backend.parameters.lock().unwrap().last().unwrap().baud_rate, + 921600 + ); + drop(lease); + manager.wait_owner_released("second").await.unwrap(); + // A fresh manager has no location and must discover again. + drop(manager); + let (io, mut peer) = tokio::io::duplex(65536); + backend.streams.lock().unwrap().insert("a".into(), io); + let manager = SerialManager::new(backend.clone()); + let m = manager.clone(); + let third = tokio::spawn(async move { m.bind(request("third", 1)).await.unwrap() }); + let a = opens.recv().await.unwrap(); + let b = opens.recv().await.unwrap(); + assert_ne!(a, b); + peer.write_all(format!("\r\nAXLOADER-SERIAL/1 {:032x}\r\n", 1).as_bytes()) + .await + .unwrap(); + drop(third.await.unwrap()); + manager.wait_owner_released("third").await.unwrap(); +} + +struct SelectiveBackend { + backend: Arc, + inventory: watch::Receiver, +} +impl SerialBackend for SelectiveBackend { + fn ports(&self) -> BackendFuture<'_, Vec> { + Box::pin(async { + let mut inventory = self.inventory.clone(); + inventory + .wait_for(|ready| *ready) + .await + .map_err(io::Error::other)?; + self.backend.ports().await + }) + } + fn open( + &self, + port: PortLocator, + parameters: SerialParameters, + ) -> BackendFuture<'_, Box> { + Box::pin(async move { + if parameters.data_bits == 7 { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "driver rejects seven data bits", + )) + } else { + self.backend.open(port, parameters).await + } + }) + } +} +#[tokio::test] +async fn unsupported_combination_does_not_starve_other_bindings() { + let (backend, mut peers, mut opens, _gate) = backend(&["uart"], true); + let (inventory, waiting) = watch::channel(false); + let manager = SerialManager::new(Arc::new(SelectiveBackend { + backend, + inventory: waiting, + })); + let mut unsupported = request("unsupported", 1); + unsupported.parameters.data_bits = 7; + unsupported.deadline = Instant::now() + Duration::from_secs(2); + let m = manager.clone(); + let bad = tokio::spawn(async move { m.bind(unsupported).await }); + let m = manager.clone(); + let good = tokio::spawn(async move { m.bind(request("supported", 2)).await }); + snapshot(&manager, |s| s.pending == 2).await; + inventory.send(true).unwrap(); + tokio::time::timeout(Duration::from_secs(1), opens.recv()) + .await + .expect("supported settings were never tried") + .unwrap(); + peers[0] + .write_all(format!("\r\nAXLOADER-SERIAL/1 {:032x}\r\n", 2).as_bytes()) + .await + .unwrap(); + let lease = tokio::time::timeout(Duration::from_secs(1), good) + .await + .unwrap() + .unwrap() + .unwrap(); + let error = bad + .await + .unwrap() + .err() + .expect("unsupported configuration was accepted"); + assert!( + error.to_string().contains("driver rejects seven data bits"), + "{error}" + ); + drop(lease); + manager.wait_owner_released("supported").await.unwrap(); + snapshot(&manager, |s| s.candidates == 0 && s.leased == 0).await; +} diff --git a/ostool-server/Cargo.toml b/ostool-server/Cargo.toml index fb37c190..64ef24f9 100644 --- a/ostool-server/Cargo.toml +++ b/ostool-server/Cargo.toml @@ -33,6 +33,7 @@ env_logger = {workspace = true} futures-util = "0.3" fs4 = "1.1" httpboot-protocol = { workspace = true } +ostool-serial = { workspace = true } log = {workspace = true} mime_guess = "2" network-interface = "2" @@ -61,6 +62,7 @@ zbus = { version = "5", default-features = false, features = ["tokio"] } nix = { version = "0.30", features = ["signal", "process"] } [dev-dependencies] +nix = {version="0.30", features=["term"]} tempfile = "3" tokio-modbus = {version = "0.17.0", default-features = false, features = ["rtu", "rtu-server"]} tokio-tungstenite = "0.28" diff --git a/ostool-server/scripts/test-axloader-local.py b/ostool-server/scripts/test-axloader-local.py index 12cbf9fe..29485989 100644 --- a/ostool-server/scripts/test-axloader-local.py +++ b/ostool-server/scripts/test-axloader-local.py @@ -1,317 +1,39 @@ #!/usr/bin/env python3 -"""Isolated QEMU + real ostool-server v5 integration, without host network privileges.""" - +"""Isolated v6 integration: real OVMF UART, managed PTY, HTTP and WebSocket.""" import argparse -import hashlib import json import os from pathlib import Path -import shutil -import socket -import struct import subprocess import tempfile -import threading -import time -import urllib.error -import urllib.request - -MAC = "02:00:00:00:00:01" - - -def free_port(): - with socket.socket() as sock: - sock.bind(("127.0.0.1", 0)) - return sock.getsockname()[1] - - -def request(url, method="GET", body=None, headers=None, timeout=12): - if isinstance(body, dict): - body = json.dumps(body).encode() - headers = {**(headers or {}), "Content-Type": "application/json"} - req = urllib.request.Request(url, data=body, headers=headers or {}, method=method) - with urllib.request.urlopen(req, timeout=timeout) as response: - data = response.read() - return json.loads(data) if data else {} - - -def wait_for(label, probe, seconds=110): - end = time.monotonic() + seconds - last = "" - while time.monotonic() < end: - try: - result = probe() - if result: - print("local v5:", label) - return result - except (OSError, ValueError, KeyError, urllib.error.HTTPError) as error: - last = str(error) - time.sleep(0.25) - raise RuntimeError("timed out waiting for {}: {}".format(label, last)) - - -def run(*args, cwd=None): - subprocess.run(args, cwd=cwd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) - - -def relay_beacons(capture, guest_port, stopped): - capture.settimeout(0.5) - outbound = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - while not stopped.is_set(): - try: - stream, _ = capture.accept() - except socket.timeout: - continue - except OSError: - if stopped.is_set(): - break - raise - with stream: - stream.settimeout(0.5) - frames = bytearray() - while not stopped.is_set(): - try: - chunk = stream.recv(65536) - except socket.timeout: - continue - if not chunk: - break - frames.extend(chunk) - while len(frames) >= 4: - length = struct.unpack_from(">I", frames)[0] - if length < 42 or length > 65535: - frames.clear() - break - if len(frames) < 4 + length: - break - frame = bytes(frames[4:4 + length]) - del frames[:4 + length] - if frame[12:14] != b"\x08\x00" or frame[23] != 17: - continue - ip_header = (frame[14] & 15) * 4 - offset = 14 + ip_header - if len(frame) < offset + 8 or struct.unpack_from(">H", frame, offset + 2)[0] != 2998: - continue - udp_length = struct.unpack_from(">H", frame, offset + 4)[0] - if udp_length < 8 or len(frame) < offset + udp_length: - continue - try: - announcement = json.loads(frame[offset + 8:offset + udp_length].decode()) - except (UnicodeDecodeError, ValueError): - continue - if announcement.get("protocol_version") != 5 or announcement.get("mac_address") != MAC: - continue - # Only the test endpoint changes: device status still comes from real QEMU TCP4. - announcement["http_port"] = guest_port - outbound.sendto(json.dumps(announcement).encode(), ("127.0.0.1", 2998)) - outbound.close() - - -def build_disk(root, tgos, loader): - (root / "A.EFI").write_bytes(loader) - (root / "B.EFI").write_bytes(loader + b"\x01") - shutil.copy2(tgos / "target/x86_64-unknown-uefi/release/axloader-launcher.efi", root / "BOOTX64.EFI") - state = subprocess.run(["python3", str(tgos / "bootloader/axloader/scripts/init-ota-state.py"), - "--stable", "A.EFI", "--trial", "B.EFI", "--output", "."], - cwd=root, check=True, text=True, capture_output=True) - initial_id = state.stdout.strip().removeprefix("first trial update_id=") - disk = root / "esp.img" - run("truncate", "-s", "128M", str(disk)) - run("mkfs.vfat", "-F", "32", "-n", "OSTOOLBOOT", str(disk)) - run("mmd", "-i", str(disk), "::EFI", "::EFI/BOOT", "::EFI/AXLOADER") - run("mcopy", "-i", str(disk), str(root / "BOOTX64.EFI"), "::EFI/BOOT/BOOTX64.EFI") - for filename in ("A.EFI", "B.EFI", "STATE0.BIN", "STATE1.BIN"): - run("mcopy", "-i", str(disk), str(root / filename), "::EFI/AXLOADER/" + filename) - return disk, initial_id def main(): parser = argparse.ArgumentParser() parser.add_argument("--tgos", type=Path, required=True) - parser.add_argument("--server-bin", type=Path, required=True) parser.add_argument("--kernel", type=Path) parser.add_argument("--initramfs", type=Path) - parser.add_argument("--ovmf-code", type=Path, default=Path("/usr/share/OVMF/OVMF_CODE_4M.fd")) - parser.add_argument("--ovmf-vars", type=Path, default=Path("/usr/share/OVMF/OVMF_VARS_4M.fd")) args = parser.parse_args() tgos = args.tgos.resolve() - root = Path(tempfile.mkdtemp(prefix="axloader-v5-local-")) - print("local v5 artifacts:", root, flush=True) - kernel_path = ( - args.kernel - or tgos / "target/x86_64-unknown-linux-musl/release/arceos-helloworld" - ).resolve() - kernel = kernel_path.read_bytes() - if args.initramfs: - initramfs_path = args.initramfs.resolve() - else: - initramfs_path = root / "host-initramfs.cpio" - run( - str(tgos / "target/debug/tg-xtask"), - "image", - "pack-initramfs", - str(tgos / "test-suit/host-initramfs"), - str(initramfs_path), - cwd=tgos, - ) - initramfs = initramfs_path.read_bytes() - loader = (tgos / "target/x86_64-unknown-uefi/release/axloader.efi").read_bytes() - disk, initial_id = build_disk(root, tgos, loader) - shutil.copy2(args.ovmf_vars, root / "vars.fd") - server_port, device_port, capture_port = free_port(), free_port(), free_port() - data = root / "server-data" - boards = data / "boards" - boards.mkdir(parents=True) - (boards / "qemu-v5.toml").write_text('''id = "qemu-v5" -board_type = "qemu-v5" -disabled = false -[network_identity] -mac_address = "02:00:00:00:00:01" -[serial] -baud_rate = 115200 -[serial.key] -kind = "serial_number" -value = "qemu-v5-local" -[power_management] -kind = "custom" -power_on_cmd = "true" -power_off_cmd = "true" -[boot] -kind = "httpboot" -boot_arch = "x86_64" -''') - config = root / "server.toml" - config.write_text('''listen_addr = "127.0.0.1:{server_port}" -data_dir = "{data}" -board_dir = "{boards}" -dtb_dir = "{data}/dtbs" -[network_test] -enabled = false -[tftp] -provider = "builtin" -enabled = false -root_dir = "{data}/tftp" -bind_addr = "127.0.0.1:6969" -[network] -interface = "lo" -[http_boot] -enabled = true -root_dir = "{data}/http-boot" -public_base_url = "http://127.0.0.1:{server_port}" -[loader_network] -enabled = true -bind_addr = "127.0.0.1:2998" -'''.format(server_port=server_port, data=data, boards=boards)) - capture = socket.socket() - capture.bind(("127.0.0.1", capture_port)) - capture.listen() - stopped = threading.Event() - relay = threading.Thread(target=relay_beacons, args=(capture, device_port, stopped), daemon=True) - relay.start() - children = [] - with (root / "ostool-server.log").open("wb") as server_log: - server = subprocess.Popen([str(args.server_bin.resolve()), "--config", str(config)], - stdout=server_log, stderr=subprocess.STDOUT) - children.append(server) - base = "http://127.0.0.1:{}".format(server_port) - device = "http://127.0.0.1:{}".format(device_port) - def start_guest(index): - out = (root / "qemu-{}.log".format(index)).open("wb") - guest = subprocess.Popen([ - "qemu-system-x86_64", "-m", "512M", "-smp", "1", "-machine", "q35", - "-accel", "kvm", "-cpu", "host", "-display", "none", "-monitor", "none", - "-serial", "stdio", "-netdev", "user,id=user0,hostfwd=tcp:127.0.0.1:{}-:2999".format(device_port), - "-chardev", "socket,id=discovery_capture,host=127.0.0.1,port={},reconnect-ms=100".format(capture_port), - "-object", "filter-mirror,id=discovery_mirror,netdev=user0,queue=rx,outdev=discovery_capture", - "-device", "virtio-net-pci,netdev=user0,mac=" + MAC, - "-drive", "if=pflash,format=raw,readonly=on,file=" + str(args.ovmf_code), - "-drive", "if=pflash,format=raw,file=" + str(root / "vars.fd"), - "-drive", "format=raw,if=ide,file=" + str(disk), - ], stdin=subprocess.DEVNULL, stdout=out, stderr=subprocess.STDOUT) - children.append(guest) - return guest - def stop_guest(guest): - guest.terminate() - try: - guest.wait(timeout=5) - except subprocess.TimeoutExpired: - guest.kill() - guest.wait() - try: - wait_for("local server listening", lambda: request(base + "/api/v1/admin/boards")) - guest = start_guest(1) - trial = wait_for("first B trial", lambda: (lambda status: status if status["ota"]["pending_update_id"] == initial_id else None)(request(device + "/api/v1/status"))) - request(device + "/api/v1/ota/confirm", "POST", {"update_id": initial_id}, {"X-Boot-Epoch": trial["boot_epoch"]}) - wait_for("server discovered v5 device", lambda: (lambda body: body if "qemu-v5" in json.dumps(body) and MAC in json.dumps(body) else None)(request(base + "/api/v1/admin/loader-devices"))) - session = request(base + "/api/v1/sessions", "POST", {"board_type": "qemu-v5", "board_id": "qemu-v5", "required_tags": []}) - session_id = session["session_id"] - request(base + "/api/v1/sessions/{}/http-boot/files".format(session_id), "PUT", initramfs, - {"X-File-Path": "initramfs.cpio"}) - request(base + "/api/v1/sessions/{}/http-boot/kernel".format(session_id), "PUT", kernel, - {"X-HttpBoot-Arch": "x86_64", "X-HttpBoot-Image-Format": "elf64", - "X-HttpBoot-Remote-Name": "kernel.elf", - "X-HttpBoot-Entry-Symbol": "httpboot_entry", - "X-HttpBoot-Initramfs-Path": "initramfs.cpio", - "X-HttpBoot-Cmdline": "axloader.cmdline=ostool-local"}) - wait_for("server pushed optional payloads into a real ArceOS kernel", lambda: ( - b'HOST_CMDLINE: axloader.cmdline=ostool-local' in (root / "qemu-1.log").read_bytes() - and b'HOST_INITRAMFS_PASSED' in (root / "qemu-1.log").read_bytes() - and b'Hello, world!' in (root / "qemu-1.log").read_bytes()), 120) - try: - request(base + "/api/v1/sessions/" + session_id, "DELETE") - except urllib.error.HTTPError as error: - if error.code != 404: - raise - wait_for("board released", lambda: (lambda value: value if value["lease_state"] == "idle" else None)( - request(base + "/api/v1/admin/boards/qemu-v5/runtime-status"))) - stop_guest(guest) - guest = start_guest(2) - wait_for("stable B after boot", lambda: (lambda value: value if value["ota"]["active_sha256"] == hashlib.sha256(loader + b"\x01").hexdigest() else None)(request(device + "/api/v1/status"))) - changed = loader - image = request(base + "/api/v1/admin/loader-images", "POST", changed, - {"Content-Length": str(len(changed)), "X-Image-Version": "local-v5"}) - assignment = request(base + "/api/v1/admin/boards/qemu-v5/loader-updates", "POST", - {"image_sha256": image["sha256"]}) - task_id = assignment["update_id"] - server.terminate() - server.wait(timeout=8) - restarted_log = (root / "ostool-server-restarted.log").open("wb") - server = subprocess.Popen([str(args.server_bin.resolve()), "--config", str(config)], - stdout=restarted_log, stderr=subprocess.STDOUT) - children.append(server) - wait_for("server restarted with persistent OTA task", lambda: request(base + "/api/v1/admin/boards")) - wait_for("server pushed and confirmed OTA", lambda: (lambda jobs: jobs if task_id in json.dumps(jobs) and 'succeeded' in json.dumps(jobs) else None)( - request(base + "/api/v1/admin/boards/qemu-v5/loader-updates")), 150) - status = wait_for("confirmed A slot", lambda: (lambda value: value if value["ota"]["active_sha256"] == hashlib.sha256(loader).hexdigest() and value["ota"]["pending_update_id"] is None else None)(request(device + "/api/v1/status"))) - assert status["ota"]["active_sha256"] == hashlib.sha256(loader).hexdigest() - stop_guest(guest) - guest = start_guest(3) - wait_for("confirmed A survives reboot", lambda: (lambda value: value if value["ota"]["active_sha256"] == hashlib.sha256(loader).hexdigest() and value["ota"]["running_sha256"] == value["ota"]["active_sha256"] else None)(request(device + "/api/v1/status"))) - stop_guest(guest) - (root / "result.json").write_text(json.dumps({ - "result": "passed", - "loader_sha256": hashlib.sha256(loader).hexdigest(), - "kernel_sha256": hashlib.sha256(kernel).hexdigest(), - "initramfs_sha256": hashlib.sha256(initramfs).hexdigest(), - "server_sha256": hashlib.sha256(args.server_bin.read_bytes()).hexdigest(), - "task_id": task_id, - "fat_disk": str(disk), - "guest_logs": [str(root / "qemu-{}.log".format(n)) for n in (1, 2, 3)], - "server_logs": [str(root / "ostool-server.log"), str(root / "ostool-server-restarted.log")], - }, indent=2) + "\n") - print("local v5: real ostool-server, QEMU boot and OTA confirmed across FAT reboots") - finally: - stopped.set() - capture.close() - for child in reversed(children): - if child.poll() is None: - child.terminate() - try: - child.wait(timeout=5) - except subprocess.TimeoutExpired: - child.kill() - child.wait() - relay.join(timeout=2) + workspace = Path(__file__).resolve().parents[2] + artifacts = Path(tempfile.mkdtemp(prefix="axloader-v6-local-")) + print("local v6 artifacts:", artifacts, flush=True) + kernel = (args.kernel or tgos / "target/x86_64-unknown-linux-musl/release/arceos-helloworld").resolve() + loader = tgos / "target/x86_64-unknown-uefi/release/axloader.efi" + if not kernel.is_file() or not loader.is_file(): + parser.error("first run cargo xtask axloader test qemu --target x86_64-unknown-uefi in TGOSKits") + initramfs = args.initramfs.resolve() if args.initramfs else artifacts / "initramfs.cpio" + if not args.initramfs: + subprocess.run(["cargo", "xtask", "image", "pack-initramfs", str(tgos / "test-suit/host-initramfs"), str(initramfs)], cwd=tgos, check=True) + env = {**os.environ, "OSTOOL_QEMU_TGOS": str(tgos), "OSTOOL_QEMU_KERNEL": str(kernel), + "OSTOOL_QEMU_INITRAMFS": str(initramfs), "OSTOOL_QEMU_ARTIFACTS": str(artifacts)} + # The Rust harness runs the real server router in its own listener and owns + # every child, socket and PTY. No system service or physical port is opened. + subprocess.run(["cargo", "test", "-p", "ostool-server", "--test", "qemu_serial", "--", "--ignored", "--nocapture"], cwd=workspace, env=env, check=True) + (artifacts / "result.json").write_text(json.dumps({"result": "passed", "serial_config": None, + "kernel": str(kernel), "loader": str(loader), "initramfs": str(initramfs), + "kernel_output": str(artifacts / "kernel-serial.log")}, indent=2) + "\n") + print("local v6: UART identity, automatic parameters, network continue and real kernel output passed") if __name__ == "__main__": diff --git a/ostool-server/src/admin_events.rs b/ostool-server/src/admin_events.rs index 0f508721..28fda014 100644 --- a/ostool-server/src/admin_events.rs +++ b/ostool-server/src/admin_events.rs @@ -26,6 +26,7 @@ pub const TOPICS: &[&str] = &[ "virtual", "dtbs", "serial", + "serial_manager", "network", "server", "tftp", diff --git a/ostool-server/src/admin_power.rs b/ostool-server/src/admin_power.rs index a779da27..f439a64e 100644 --- a/ostool-server/src/admin_power.rs +++ b/ostool-server/src/admin_power.rs @@ -210,6 +210,9 @@ async fn run(state: &AppState, request: &PowerRequest) -> anyhow::Result Action::On => PowerAction::On, Action::Off => PowerAction::Off, }; + let _serial_reservation = state + .reserve_power_serial(&request.power_management) + .await?; match &request.power_management { #[cfg(target_os = "linux")] PowerManagementConfig::Custom(config) => { diff --git a/ostool-server/src/api/models.rs b/ostool-server/src/api/models.rs index 5e6d697b..3ff5319c 100644 --- a/ostool-server/src/api/models.rs +++ b/ostool-server/src/api/models.rs @@ -69,6 +69,8 @@ pub struct SessionDetailResponse { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SerialStatusResponse { + pub runtime: crate::serial::runtime::SerialRuntimeStatus, + pub manager: ostool_serial::ManagerSnapshot, pub available: bool, pub connected: bool, pub port: Option, diff --git a/ostool-server/src/api/router.rs b/ostool-server/src/api/router.rs index 9b0b788b..42e436bc 100644 --- a/ostool-server/src/api/router.rs +++ b/ostool-server/src/api/router.rs @@ -10,9 +10,8 @@ use axum::{ }; use futures_util::future::join_all; use httpboot_protocol::{ - BootArch, BootFile, ImageFormat, LEGACY_PROTOCOL_VERSION, LoaderPollRequest, - LoaderPollResponse, LoaderStatusReport, LoaderStatusResponse, MAX_HTTP_BOOT_INITRAMFS_BYTES, - MacAddress, valid_host_cmdline, + BootArch, BootFile, ImageFormat, LoaderPollRequest, LoaderPollResponse, LoaderStatusReport, + LoaderStatusResponse, MAX_HTTP_BOOT_INITRAMFS_BYTES, MacAddress, valid_host_cmdline, }; use mime_guess::from_path; use sha2::{Digest, Sha256}; @@ -390,6 +389,17 @@ async fn create_dtb( )) } +fn serial_exclusion_error(error: anyhow::Error) -> ApiError { + if error + .downcast_ref::() + .is_some_and(|error| matches!(error, ostool_serial::BindError::Busy)) + { + ApiError::conflict("relay port is already leased by a session or manual operation") + } else { + error.into() + } +} + async fn create_board( State(state): State, axum::Json(request): axum::Json, @@ -413,13 +423,21 @@ async fn create_board( ensure_unique_network_identity(&boards, &board, None)?; } - state.board_store.write_board(&board).await?; + state + .prepare_board_serial_exclusions(&board, None) + .await + .map_err(serial_exclusion_error)?; + if let Err(error) = state.board_store.write_board(&board).await { + state.refresh_serial_exclusions().await?; + return Err(error.into()); + } state .boards .write() .await .insert(board.id.clone(), board.clone()); state.sync_board_runtime_states().await; + state.refresh_serial_exclusions().await?; Ok((StatusCode::CREATED, axum::Json(board))) } @@ -470,10 +488,18 @@ async fn update_board( ))); } - state.board_store.write_board(&board).await?; + state + .prepare_board_serial_exclusions(&board, Some(&board_id)) + .await + .map_err(serial_exclusion_error)?; + if let Err(error) = state.board_store.write_board(&board).await { + state.refresh_serial_exclusions().await?; + return Err(error.into()); + } if board.id != board_id && let Err(error) = state.board_store.delete_board(&board_id).await { + state.refresh_serial_exclusions().await?; if let Err(rollback_error) = state.board_store.delete_board(&board.id).await { return Err(ApiError::internal(format!( "failed to remove old board config `{board_id}`: {error:#}; \ @@ -490,6 +516,7 @@ async fn update_board( boards.insert(board.id.clone(), board.clone()); } state.sync_board_runtime_states().await; + state.refresh_serial_exclusions().await?; Ok(axum::Json(board)) } @@ -780,27 +807,15 @@ async fn poll_loader( retry_after_ms: None, })); } - if request.protocol_version == LEGACY_PROTOCOL_VERSION - && (command.initramfs.is_some() || command.cmdline.is_some()) - { - return Ok(axum::Json(LoaderPollResponse::Reject { - code: "boot_payload_unsupported".into(), - message: "this boot requires a loader supporting host initramfs and cmdline".into(), - retry_after_ms: None, - })); - } - Ok(axum::Json(LoaderPollResponse::Boot { - board_id, - session_id, - boot_id: command.boot_id, - kernel_path: command.kernel_path, - kernel_size: command.kernel_size, - kernel_sha256: command.kernel_sha256, - arch: command.arch, - image_format: command.image_format, - entry_symbol: command.entry_symbol, - initramfs: command.initramfs, - cmdline: command.cmdline, + session + .serial_runtime + .fail("automatic serial binding requires axloader protocol v6; upgrade the loader".into()); + Ok(axum::Json(LoaderPollResponse::Reject { + code: "serial_protocol_upgrade_required".into(), + message: + "automatic boot requires axloader protocol v6; identification and OTA remain available" + .into(), + retry_after_ms: None, })) } @@ -964,6 +979,9 @@ fn normalize_board_upsert_request( normalize_required_string(&mut request.board_type, "board_type")?; normalize_optional_string(&mut request.notes); normalize_tags(&mut request.tags); + if matches!(request.boot, BootConfig::UefiHttp(_)) { + request.serial = None; + } normalize_serial_config(request.serial.as_mut())?; normalize_power_management_config(&mut request.power_management)?; normalize_boot_config(&mut request.boot)?; @@ -1247,6 +1265,7 @@ async fn delete_board( state.board_store.delete_board(&board_id).await?; state.boards.write().await.remove(&board_id); state.sync_board_runtime_states().await; + state.refresh_serial_exclusions().await?; Ok(StatusCode::NO_CONTENT) } @@ -1444,17 +1463,16 @@ async fn create_session( .await .ok_or_else(|| ApiError::not_found("allocated board disappeared"))?; let ws_url = board - .serial - .as_ref() - .map(|_| format!("/api/v1/sessions/{}/serial/ws", session.id)); + .serial_available() + .then(|| format!("/api/v1/sessions/{}/serial/ws", session.id)); Ok(( StatusCode::CREATED, axum::Json(SessionCreatedResponse { session_id: session.id, - board_id: board.id, + board_id: board.id.clone(), lease_expires_at: session.expires_at, - serial_available: board.serial.is_some(), + serial_available: board.serial_available(), boot_mode: board.boot.kind_name().to_string(), ws_url, }), @@ -1476,7 +1494,7 @@ async fn get_session( Ok(axum::Json(SessionDetailResponse { session, board: board.clone(), - serial_available: board.serial.is_some(), + serial_available: board.serial_available(), serial_connected: connected, files, })) @@ -1629,7 +1647,25 @@ async fn get_serial_status( .await .map(|session| session.serial_connected) .unwrap_or(false); - let response = if let Some(serial) = board.serial { + let runtime = state + .session_state(&session_id) + .await + .map(|s| s.serial_runtime.snapshot()) + .unwrap_or_default(); + let manager = *state.serial_manager.subscribe().borrow(); + let response = if matches!(board.boot, BootConfig::UefiHttp(_)) { + SerialStatusResponse { + available: true, + connected, + port: runtime.port.clone(), + baud_rate: runtime + .parameters + .and_then(|p| u32::try_from(p.baud_rate).ok()), + ws_url: Some(format!("/api/v1/sessions/{session_id}/serial/ws")), + runtime, + manager, + } + } else if let Some(serial) = board.serial { let port = if serial.key.kind == crate::config::SerialPortKeyKind::Qemu { if state .virtual_boards @@ -1649,6 +1685,8 @@ async fn get_serial_status( .current_device_path }; SerialStatusResponse { + runtime, + manager, available: true, connected, port: Some(port), @@ -1657,6 +1695,8 @@ async fn get_serial_status( } } else { SerialStatusResponse { + runtime, + manager, available: false, connected: false, port: None, @@ -1691,9 +1731,9 @@ async fn serial_ws( .await .ok_or_else(|| ApiError::not_found("session not found"))?; let board = session.board().clone(); - let Some(_serial) = board.serial.clone() else { + if !board.serial_available() { return Err(ApiError::conflict("board has no serial configuration")); - }; + } if session.is_releasing() { return Err(ApiError::conflict("session is releasing")); } @@ -2803,6 +2843,7 @@ pub(crate) async fn admin_topic( "virtual" => serde_json::to_value(list_virtual_devices(extractor).await.0), "dtbs" => serde_json::to_value(list_dtbs(extractor).await?.0), "serial" => serde_json::to_value(list_serial_ports().await?.0), + "serial_manager" => serde_json::to_value(*state.serial_manager.subscribe().borrow()), "network" => serde_json::to_value(list_network_interfaces().await?.0), "server" => serde_json::to_value(get_server_config(extractor).await?.0), "tftp" => serde_json::to_value(get_tftp_config(extractor).await?.0.tftp), @@ -2851,8 +2892,7 @@ mod tests { use super::{ DTB_UPLOAD_MAX_MIB, ResolvedNetwork, boot_profile_with_resolved_network, build_router, - hex_sha256, http_boot_url, mib_to_bytes, resolve_server_network, - virtual_loader_is_bindable, + http_boot_url, mib_to_bytes, resolve_server_network, virtual_loader_is_bindable, }; use crate::{ api::models::{ @@ -4895,30 +4935,10 @@ mod tests { .unwrap(); let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); let response: LoaderPollResponse = serde_json::from_slice(&body).unwrap(); - if version == LEGACY_PROTOCOL_VERSION { - assert!( - matches!(response, LoaderPollResponse::Reject { ref code, .. } - if code == "boot_payload_unsupported") - ); - } else { - let LoaderPollResponse::Boot { - entry_symbol, - initramfs: Some(file), - cmdline, - .. - } = response - else { - panic!("expected boot with verified initramfs"); - }; - assert_eq!( - file.path, - format!("/boot/sessions/{session_id}/initramfs.cpio") - ); - assert_eq!(file.size, archive.len() as u64); - assert_eq!(file.sha256, hex_sha256(archive)); - assert_eq!(cmdline.as_deref(), Some("console=ttyS0 -- test")); - assert_eq!(entry_symbol.as_deref(), Some("httpboot_entry")); - } + assert!( + matches!(response, LoaderPollResponse::Reject { ref code, .. } + if code == "serial_protocol_upgrade_required") + ); } } @@ -4998,10 +5018,9 @@ mod tests { .unwrap(); let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); let first_boot: LoaderPollResponse = serde_json::from_slice(&body).unwrap(); - let LoaderPollResponse::Boot { boot_id, .. } = first_boot else { - panic!("expected boot response"); - }; - assert_eq!(boot_id, published.boot_id); + assert!( + matches!(first_boot, LoaderPollResponse::Reject { ref code, .. } if code == "serial_protocol_upgrade_required") + ); let status = LoaderStatusReport { protocol_version: PREVIOUS_PROTOCOL_VERSION, @@ -5043,10 +5062,9 @@ mod tests { .unwrap(); let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); let second_boot: LoaderPollResponse = serde_json::from_slice(&body).unwrap(); - let LoaderPollResponse::Boot { boot_id, .. } = second_boot else { - panic!("expected boot response after restart"); - }; - assert_eq!(boot_id, published.boot_id); + assert!( + matches!(second_boot, LoaderPollResponse::Reject { ref code, .. } if code == "serial_protocol_upgrade_required") + ); let response = app .oneshot( diff --git a/ostool-server/src/config.rs b/ostool-server/src/config.rs index f59a33a0..db1976a4 100644 --- a/ostool-server/src/config.rs +++ b/ostool-server/src/config.rs @@ -533,6 +533,10 @@ pub struct BoardConfig { } impl BoardConfig { + pub fn serial_available(&self) -> bool { + self.serial.is_some() || matches!(self.boot, BootConfig::UefiHttp(_)) + } + pub fn validate(&self) -> anyhow::Result<()> { if let BootConfig::Uboot(profile) = &self.boot { profile.validate()?; @@ -547,14 +551,6 @@ impl BoardConfig { if virtual_device_id.trim().is_empty() { anyhow::bail!("QEMU virtual_device_id must not be empty"); } - let serial = self - .serial - .as_ref() - .context("QEMU boards must configure a QEMU serial key")?; - if serial.key.kind != SerialPortKeyKind::Qemu || serial.key.value != *virtual_device_id - { - anyhow::bail!("QEMU power and serial must reference the same virtual_device_id"); - } if self.network_identity.is_none() { anyhow::bail!("QEMU boards must configure network_identity.mac_address"); } @@ -1148,7 +1144,7 @@ bootm_addr = "0x82200000" } #[test] - fn qemu_board_requires_httpboot_and_matching_virtual_serial() { + fn qemu_board_requires_httpboot_and_derives_virtual_serial() { let mut board = BoardConfig { id: "qemu-01".into(), board_type: "qemu-x86_64".into(), @@ -1181,14 +1177,9 @@ bootm_addr = "0x82200000" boot_arch: Some(UefiBootArch::X86_64), }); board.validate().unwrap(); - board.serial.as_mut().unwrap().key.value = "virtual-2".into(); - assert!( - board - .validate() - .unwrap_err() - .to_string() - .contains("QEMU power and serial must reference the same virtual_device_id") - ); + board.serial = None; + board.validate().unwrap(); + assert!(board.serial_available()); } #[test] diff --git a/ostool-server/src/device.rs b/ostool-server/src/device.rs index e1eae87b..0323d3bd 100644 --- a/ostool-server/src/device.rs +++ b/ostool-server/src/device.rs @@ -1,4 +1,4 @@ -//! v5 devices own the HTTP endpoint. The server only discovers and calls it. +//! v6 devices own the HTTP endpoint. The server only discovers and calls it. use std::{net::SocketAddr, time::Duration}; @@ -26,7 +26,10 @@ pub async fn reconcile( peer: SocketAddr, ) -> anyhow::Result<()> { ensure!( - announcement.protocol_version == DEVICE_PROTOCOL_VERSION, + matches!( + announcement.protocol_version, + DEVICE_PROTOCOL_VERSION | httpboot_protocol::PREVIOUS_DEVICE_PROTOCOL_VERSION + ), "unsupported device protocol" ); ensure!(announcement.http_port > 0, "missing device HTTP port"); @@ -154,7 +157,7 @@ pub async fn reconcile( } } else { log::warn!( - "v5 loader {} at {} did not report OTA state; skipping upgrades", + "loader {} at {} did not report OTA state; skipping upgrades", announcement.mac_address, peer.ip() ); @@ -174,16 +177,119 @@ pub async fn reconcile( if command.arch != observed.arch { return Ok(()); } - push_boot( - &state, - &client, - &endpoint, - &observed, - &session_id, - &command, - &session, - ) - .await + if observed.protocol_version != DEVICE_PROTOCOL_VERSION { + session.serial_runtime.fail( + "automatic serial binding requires axloader protocol v6; upgrade the loader".into(), + ); + return Ok(()); + } + if !session.is_serial_connected() || session.is_stop_requested() || session.is_releasing() { + return Ok(()); + } + if !session.serial_runtime.accepts_epoch(&observed.boot_epoch) { + return Ok(()); + } + let mut generation = session.subscribe_boot_generation(); + let mut shutdown = session.subscribe_shutdown(); + let operation = async { + let serial = observed + .serial + .as_ref() + .context("missing v6 serial status")?; + let runtime = session.serial_runtime.snapshot(); + if let Some(binding) = &serial.binding + && (runtime.binding_id.as_deref() != Some(binding.binding_id.as_str()) + || runtime.phase == crate::serial::runtime::SerialRuntimePhase::Recovering) + { + let response = client + .delete(format!( + "{endpoint}/api/v1/serial/bindings/{}", + binding.binding_id + )) + .header("X-Boot-Epoch", &observed.boot_epoch) + .send() + .await?; + require_status(response, 200).await?; + } + let binding = session.serial_runtime.bind(observed.clone()).await?; + ensure!( + !session.is_stop_requested() && !session.is_releasing(), + "session stopped before serial continue" + ); + let mut grant = SerialGrant::new( + &client, + &endpoint, + &observed.boot_epoch, + &binding.binding_id, + ); + let response = client + .post(format!("{endpoint}/api/v1/serial/continue")) + .header("X-Boot-Epoch", &observed.boot_epoch) + .json(&binding) + .send() + .await?; + require_status(response, 200).await?; + session.serial_runtime.confirm(&binding.binding_id).await?; + push_boot( + &state, + &client, + &endpoint, + &observed, + &command, + &session, + &binding.binding_id, + ) + .await?; + grant.armed = false; + Ok(()) + }; + tokio::select! { + result = operation => { if let Err(error)=&result {session.serial_runtime.fail(format!("{error:#}"));} result }, + _ = shutdown.wait_for(|s|*s) => Ok(()), + _ = generation.changed() => Ok(()), + } +} + +// Cancellation can race the HTTP reply after the firmware has accepted continue. +// Revoke the exact epoch/token unless handoff was successfully requested. +struct SerialGrant { + client: Client, + endpoint: String, + epoch: String, + binding_id: String, + armed: bool, +} +impl SerialGrant { + fn new(client: &Client, endpoint: &str, epoch: &str, binding_id: &str) -> Self { + Self { + client: client.clone(), + endpoint: endpoint.into(), + epoch: epoch.into(), + binding_id: binding_id.into(), + armed: true, + } + } +} +impl Drop for SerialGrant { + fn drop(&mut self) { + if !self.armed { + return; + } + let client = self.client.clone(); + let url = format!( + "{}/api/v1/serial/bindings/{}", + self.endpoint, self.binding_id + ); + let epoch = self.epoch.clone(); + tokio::spawn(async move { + let _ = client + .delete(url) + .header("X-Boot-Epoch", epoch) + .timeout(std::time::Duration::from_secs(2)) + .send() + .await; + }); + } } async fn push_ota_image( @@ -216,9 +322,9 @@ async fn push_boot( client: &Client, endpoint: &str, observed: &LoaderDeviceStatus, - session_id: &str, command: &SessionBootCommand, session: &crate::session::SessionState, + binding_id: &str, ) -> anyhow::Result<()> { let epoch = observed.boot_epoch.as_str(); let observed_boot_id = observed.boot.as_ref().map(|boot| boot.boot_id.as_str()); @@ -257,13 +363,14 @@ async fn push_boot( .update_loader_status(epoch.into(), &command.boot_id, LoaderStatusPhase::Accepted) .await .map_err(|error| anyhow::anyhow!("stale boot session: {error:?}"))?; + let session_id = session.snapshot().await.id; let prefix = format!("/boot/sessions/{session_id}/"); let push = PushContext { state, client, base: &base, epoch, - session_id, + session_id: &session_id, boot_id: &command.boot_id, }; push_file( @@ -294,6 +401,7 @@ async fn push_boot( let response = client .post(format!("{base}/{}/start", command.boot_id)) .header("X-Boot-Epoch", epoch) + .header("X-Serial-Binding", binding_id) .send() .await?; require_status(response, 202).await?; @@ -738,6 +846,8 @@ mod tests { http_port: u16, ) -> LoaderAnnouncement { LoaderAnnouncement { + serial_id: None, + serial_ready: false, protocol_version: DEVICE_PROTOCOL_VERSION, mac_address: mac, current_mac_address: mac, @@ -766,7 +876,8 @@ mod tests { .unwrap(); let fake = Arc::new(Mutex::new(FakeDevice { status: LoaderDeviceStatus { - protocol_version: DEVICE_PROTOCOL_VERSION, + serial: None, + protocol_version: httpboot_protocol::PREVIOUS_DEVICE_PROTOCOL_VERSION, boot_epoch: "epoch-1".into(), mac_address: mac, current_mac_address: mac, @@ -801,7 +912,12 @@ mod tests { let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); let peer = "127.0.0.1:40000".parse().unwrap(); - let error = reconcile(state.clone(), announcement(mac, "epoch-1", port), peer) + let old_announcement = |epoch| { + let mut a = announcement(mac, epoch, port); + a.protocol_version = httpboot_protocol::PREVIOUS_DEVICE_PROTOCOL_VERSION; + a + }; + let error = reconcile(state.clone(), old_announcement("epoch-1"), peer) .await .unwrap_err(); let error = format!("{error:#}"); @@ -824,7 +940,7 @@ mod tests { let mut fake = fake.lock().await; fake.status.boot_epoch = "epoch-2".into(); } - reconcile(state.clone(), announcement(mac, "epoch-2", port), peer) + reconcile(state.clone(), old_announcement("epoch-2"), peer) .await .unwrap(); assert_eq!(state.ota.job("board-1").await.unwrap().phase, Phase::Staged); @@ -833,6 +949,7 @@ mod tests { Some(job.update_id.as_str()) ); + fake.lock().await.status.protocol_version = DEVICE_PROTOCOL_VERSION; let error = reconcile(state.clone(), announcement(mac, "epoch-2", port), peer) .await .unwrap_err(); @@ -910,6 +1027,7 @@ mod tests { }; let fake = Arc::new(Mutex::new(FakeBootDevice { status: LoaderDeviceStatus { + serial: None, protocol_version: DEVICE_PROTOCOL_VERSION, boot_epoch: "boot-epoch".into(), mac_address: mac, @@ -946,12 +1064,22 @@ mod tests { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let port = listener.local_addr().unwrap().port(); let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); - let peer = "127.0.0.1:40001".parse().unwrap(); - let announcement = announcement(mac, "boot-epoch", port); + let observed = fake.lock().await.status.clone(); + let command = session.boot_command().await.unwrap(); + let client = reqwest::Client::new(); + let endpoint = format!("http://127.0.0.1:{port}"); - let error = reconcile(state.clone(), announcement.clone(), peer) - .await - .unwrap_err(); + let error = super::push_boot( + &state, + &client, + &endpoint, + &observed, + &command, + &session, + "test-binding", + ) + .await + .unwrap_err(); let error = format!("{error:#}"); assert!( error.contains("device HTTP 400 Bad Request: digest mismatch"), @@ -959,9 +1087,17 @@ mod tests { ); fake.lock().await.reject_upload = false; - reconcile(state.clone(), announcement.clone(), peer) - .await - .unwrap(); + super::push_boot( + &state, + &client, + &endpoint, + &observed, + &command, + &session, + "test-binding", + ) + .await + .unwrap(); { let fake = fake.lock().await; assert_eq!(fake.create_attempts, 3); @@ -989,7 +1125,17 @@ mod tests { fake.delete_returns_not_found = true; fake.reject_create_without_manifest = true; } - let error = reconcile(state, announcement, peer).await.unwrap_err(); + let error = super::push_boot( + &state, + &client, + &endpoint, + &observed, + &session.boot_command().await.unwrap(), + &session, + "test-binding", + ) + .await + .unwrap_err(); let error = format!("{error:#}"); assert!(error.contains("device refused boot job: create still conflicts")); let fake = fake.lock().await; diff --git a/ostool-server/src/loader.rs b/ostool-server/src/loader.rs index e2d9b7c6..d0611536 100644 --- a/ostool-server/src/loader.rs +++ b/ostool-server/src/loader.rs @@ -1,7 +1,4 @@ -use std::{ - collections::{BTreeMap, BTreeSet}, - sync::Arc, -}; +use std::{collections::BTreeMap, sync::Arc}; use anyhow::Context; use chrono::{DateTime, Duration, Utc}; @@ -86,8 +83,10 @@ impl LoaderRegistry { device: &LoaderDeviceStatus, ip_address: String, ) -> Result { - if announcement.protocol_version != DEVICE_PROTOCOL_VERSION - || device.protocol_version != DEVICE_PROTOCOL_VERSION + if !matches!( + announcement.protocol_version, + DEVICE_PROTOCOL_VERSION | httpboot_protocol::PREVIOUS_DEVICE_PROTOCOL_VERSION + ) || device.protocol_version != announcement.protocol_version || announcement.mac_address != device.mac_address || announcement.boot_epoch != device.boot_epoch || announcement.arch != device.arch @@ -491,31 +490,51 @@ async fn serve_udp_discovery( socket: UdpSocket, ) -> anyhow::Result<()> { let mut buffer = [0_u8; MAX_DISCOVERY_DATAGRAM_BYTES + 1]; - let in_progress = Arc::new(Mutex::new(BTreeSet::new())); + let in_progress = Arc::new(Mutex::new(BTreeMap::< + httpboot_protocol::MacAddress, + (String, tokio::task::AbortHandle), + >::new())); loop { let (length, peer) = socket.recv_from(&mut buffer).await?; if length > MAX_DISCOVERY_DATAGRAM_BYTES { continue; } if let Ok(announcement) = serde_json::from_slice::(&buffer[..length]) - && announcement.protocol_version == DEVICE_PROTOCOL_VERSION + && matches!( + announcement.protocol_version, + DEVICE_PROTOCOL_VERSION | httpboot_protocol::PREVIOUS_DEVICE_PROTOCOL_VERSION + ) { state .loader_registry .touch_announcement(&announcement, &peer.ip().to_string()) .await; let mac = announcement.mac_address; - if !in_progress.lock().await.insert(mac) { - continue; + let mut running = in_progress.lock().await; + if let Some((epoch, task)) = running.get(&mac) { + if *epoch == announcement.boot_epoch { + continue; + } + task.abort(); } let active = in_progress.clone(); - let state = state.clone(); - tokio::spawn(async move { - if let Err(error) = crate::device::reconcile(state, announcement, peer).await { - log::warn!("v5 device {} at {}: {error:#}", mac, peer); + let device_state = state.clone(); + let epoch = announcement.boot_epoch.clone(); + let active_epoch = epoch.clone(); + let task = tokio::spawn(async move { + if let Err(error) = crate::device::reconcile(device_state, announcement, peer).await + { + log::warn!("device {} at {}: {error:#}", mac, peer); + } + let mut running = active.lock().await; + if running + .get(&mac) + .is_some_and(|(current, _)| *current == active_epoch) + { + running.remove(&mac); } - active.lock().await.remove(&mac); }); + running.insert(mac, (epoch, task.abort_handle())); continue; } let Ok(probe) = serde_json::from_slice::(&buffer[..length]) else { @@ -551,6 +570,8 @@ mod tests { let registry = LoaderRegistry::new(); let mac = "02:00:00:00:00:01".parse().unwrap(); let announcement = LoaderAnnouncement { + serial_id: None, + serial_ready: false, protocol_version: DEVICE_PROTOCOL_VERSION, mac_address: mac, current_mac_address: mac, @@ -560,6 +581,7 @@ mod tests { http_port: 2999, }; let mut observed = LoaderDeviceStatus { + serial: None, protocol_version: DEVICE_PROTOCOL_VERSION, boot_epoch: "generation-2".into(), mac_address: mac, diff --git a/ostool-server/src/serial.rs b/ostool-server/src/serial.rs index 04fb9553..c4b56acb 100644 --- a/ostool-server/src/serial.rs +++ b/ostool-server/src/serial.rs @@ -5,3 +5,6 @@ pub mod ws; #[cfg(unix)] mod physical; + +pub mod backend; +pub mod runtime; diff --git a/ostool-server/src/serial/backend.rs b/ostool-server/src/serial/backend.rs new file mode 100644 index 00000000..f1d92636 --- /dev/null +++ b/ostool-server/src/serial/backend.rs @@ -0,0 +1,131 @@ +use crate::{ + config::{PowerManagementConfig, SerialPortKey, SerialPortKeyKind}, + virtual_qemu::VirtualBoardManager, +}; +use httpboot_protocol::SerialParameters; +use ostool_serial::{BackendFuture, NativeBackend, PortLocator, SerialBackend, SerialIo}; +use std::{ + io, + pin::Pin, + task::{Context, Poll}, +}; +use tokio::io::{AsyncRead, AsyncWrite, DuplexStream, ReadBuf}; + +pub struct ServerSerialBackend { + pub virtual_boards: VirtualBoardManager, +} +impl SerialBackend for ServerSerialBackend { + fn ports(&self) -> BackendFuture<'_, Vec> { + Box::pin(async { + let mut ports = NativeBackend.ports().await?; + let metadata = tokio::task::spawn_blocking(super::discovery::list_serial_ports) + .await + .map_err(io::Error::other)? + .map_err(io::Error::other)?; + for record in metadata { + let canonical = std::fs::canonicalize(&record.current_device_path) + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or(record.current_device_path); + if let Some(port) = ports.iter_mut().find(|p| p.name == canonical) { + if record.serial_number.is_some() { + port.serial_number = record.serial_number; + } + if let Some(alias) = record.usb_path + && !port.aliases.contains(&alias) + { + port.aliases.push(alias); + } + } + } + ports.extend( + self.virtual_boards + .snapshots() + .await + .into_iter() + .map(|d| virtual_locator(&d.id)), + ); + Ok(ports) + }) + } + fn open( + &self, + port: PortLocator, + parameters: SerialParameters, + ) -> BackendFuture<'_, Box> { + Box::pin(async move { + if let Some(id) = port.name.strip_prefix("qemu:") { + parameters.validate().map_err(io::Error::other)?; + let io = self + .virtual_boards + .attach_serial(id) + .await + .map_err(io::Error::other)?; + Ok(Box::new(VirtualSerial(io)) as Box) + } else { + NativeBackend.open(port, parameters).await + } + }) + } +} +pub fn virtual_locator(id: &str) -> PortLocator { + PortLocator { + name: format!("qemu:{id}"), + aliases: Vec::new(), + serial_number: None, + } +} +pub fn locator_for_key(key: &SerialPortKey) -> anyhow::Result { + if key.kind == SerialPortKeyKind::Qemu { + return Ok(virtual_locator(&key.value)); + } + let resolved = super::discovery::resolve_serial_key(key)?; + let canonical = std::fs::canonicalize(&resolved.current_device_path)?; + let mut aliases = vec![key.value.clone(), resolved.current_device_path]; + aliases.extend(resolved.usb_path); + Ok(PortLocator { + name: canonical.to_string_lossy().into_owned(), + aliases, + serial_number: resolved.serial_number, + }) +} +pub fn relay_selector(power: &PowerManagementConfig) -> Option { + let PowerManagementConfig::ZhongshengRelay(relay) = power else { + return None; + }; + Some(match relay.key.kind { + SerialPortKeyKind::SerialNumber => { + ostool_serial::PortSelector::SerialNumber(relay.key.value.clone()) + } + _ => ostool_serial::PortSelector::Path(relay.key.value.clone()), + }) +} +struct VirtualSerial(DuplexStream); +impl SerialIo for VirtualSerial { + fn configure(&mut self, parameters: SerialParameters) -> io::Result<()> { + parameters.validate().map_err(io::Error::other) + } +} +impl AsyncRead for VirtualSerial { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + out: &mut ReadBuf<'_>, + ) -> Poll> { + Pin::new(&mut self.0).poll_read(cx, out) + } +} +impl AsyncWrite for VirtualSerial { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + bytes: &[u8], + ) -> Poll> { + Pin::new(&mut self.0).poll_write(cx, bytes) + } + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.0).poll_flush(cx) + } + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.0).poll_shutdown(cx) + } +} diff --git a/ostool-server/src/serial/discovery.rs b/ostool-server/src/serial/discovery.rs index 9efe6edd..5b317799 100644 --- a/ostool-server/src/serial/discovery.rs +++ b/ostool-server/src/serial/discovery.rs @@ -298,14 +298,17 @@ fn match_usb_device<'a>( return Some(exact); } - if let Some(product) = info.product.as_deref() - && let Some(exact) = usb_devices.iter().find(|device| { + if let Some(product) = info.product.as_deref() { + let mut matches = usb_devices.iter().filter(|device| { device.vendor_id == info.vid && device.product_id == info.pid && device.product.as_deref() == Some(product) - }) - { - return Some(exact); + }); + if let Some(exact) = matches.next() + && matches.next().is_none() + { + return Some(exact); + } } let candidates = usb_devices @@ -369,6 +372,13 @@ mod tests { match_usb_device(&devices, &usb_info(0x1a86, 0x7523, None, None, Some("B"))).unwrap(); assert_eq!(matched.serial_number.as_deref(), Some("B")); + assert!( + match_usb_device( + &devices, + &usb_info(0x1a86, 0x7523, None, Some("USB2.0-Serial"), None) + ) + .is_none() + ); } #[test] diff --git a/ostool-server/src/serial/physical.rs b/ostool-server/src/serial/physical.rs index 5fdab6fa..eee3cdc9 100644 --- a/ostool-server/src/serial/physical.rs +++ b/ostool-server/src/serial/physical.rs @@ -1,172 +1,2 @@ -//! Physical RX keeps draining even when the HTTP/WebSocket executor is busy. - -use std::{ - collections::VecDeque, - io::{self, Read}, - pin::Pin, - sync::{ - Arc, Mutex, - atomic::{AtomicBool, Ordering}, - }, - task::{Context, Poll}, - thread::JoinHandle, - time::Duration, -}; - -use futures_util::task::AtomicWaker; -use serialport::{ClearBuffer, SerialPort, TTYPort}; -use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; - -const CHUNK_SIZE: usize = 4096; -const QUEUE_BYTES: usize = CHUNK_SIZE * 64; -const READ_TIMEOUT: Duration = Duration::from_millis(20); - -#[derive(Default)] -struct ReceiveState { - bytes: VecDeque, - closed: bool, - error: Option, -} - -#[derive(Default)] -struct ReceiveBuffer { - state: Mutex, - waker: AtomicWaker, - stopped: AtomicBool, -} - -pub(super) struct PhysicalSerial { - pub(super) writer: tokio_serial::SerialStream, - receive: Arc, - reader: Option>, -} - -impl PhysicalSerial { - pub(super) fn new(mut port: TTYPort) -> io::Result { - port.set_timeout(READ_TIMEOUT)?; - port.clear(ClearBuffer::Input)?; - let writer = port.try_clone_native()?.try_into()?; - let receive = Arc::new(ReceiveBuffer::default()); - let shared = receive.clone(); - let reader = std::thread::Builder::new() - .name("serial-rx".into()) - .spawn(move || { - let mut buffer = [0; CHUNK_SIZE]; - let error = loop { - if shared.stopped.load(Ordering::Acquire) { - break None; - } - match port.read(&mut buffer) { - Ok(0) => break None, - Ok(size) => { - let mut state = shared.state.lock().expect("serial RX mutex poisoned"); - if size > QUEUE_BYTES - state.bytes.len() { - break Some(io::Error::other("physical serial RX buffer full")); - } - state.bytes.extend(&buffer[..size]); - drop(state); - shared.waker.wake(); - } - Err(error) - if matches!( - error.kind(), - io::ErrorKind::TimedOut - | io::ErrorKind::WouldBlock - | io::ErrorKind::Interrupted - ) => {} - Err(error) => break Some(error), - } - }; - let mut state = shared.state.lock().expect("serial RX mutex poisoned"); - state.error = error; - state.closed = true; - drop(state); - shared.waker.wake(); - })?; - Ok(Self { - writer, - receive, - reader: Some(reader), - }) - } - - pub(super) fn stop_reader(&mut self) { - self.receive.stopped.store(true, Ordering::Release); - if let Some(reader) = self.reader.take() { - // Never wait for queue capacity or network I/O. The worker's only - // blocking operation is the finite serial read timeout. Join before - // releasing the lease, including cancellation, to prevent an old - // reader consuming bytes from the next owner's session. - if reader.join().is_err() { - log::error!("physical serial receive worker panicked"); - } - } - } - - #[cfg(test)] - pub(super) fn test_receive_snapshotter( - &self, - ) -> impl Fn() -> (Vec, bool, bool) + Send + Sync + 'static { - let receive = self.receive.clone(); - move || { - let state = receive.state.lock().expect("serial RX mutex poisoned"); - ( - state.bytes.iter().copied().collect(), - state.closed, - state.error.is_some(), - ) - } - } -} - -impl Drop for PhysicalSerial { - fn drop(&mut self) { - self.stop_reader(); - } -} - -impl AsyncRead for PhysicalSerial { - fn poll_read( - self: Pin<&mut Self>, - cx: &mut Context<'_>, - output: &mut ReadBuf<'_>, - ) -> Poll> { - if output.remaining() == 0 { - return Poll::Ready(Ok(())); - } - // Register before inspecting state; producer publication and wake cannot - // be lost between an empty check and returning Pending. - self.receive.waker.register(cx.waker()); - let mut state = self.receive.state.lock().expect("serial RX mutex poisoned"); - if !state.bytes.is_empty() { - let size = output.remaining().min(state.bytes.len()); - let (first, second) = state.bytes.as_slices(); - let first_size = size.min(first.len()); - output.put_slice(&first[..first_size]); - output.put_slice(&second[..size - first_size]); - state.bytes.drain(..size); - return Poll::Ready(Ok(())); - } - if state.closed { - return Poll::Ready(state.error.take().map_or(Ok(()), Err)); - } - Poll::Pending - } -} - -impl AsyncWrite for PhysicalSerial { - fn poll_write( - mut self: Pin<&mut Self>, - cx: &mut Context<'_>, - bytes: &[u8], - ) -> Poll> { - Pin::new(&mut self.writer).poll_write(cx, bytes) - } - fn poll_flush(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll> { - // Unbuffered writes: never invoke tcdrain on the network executor. - Poll::Ready(Ok(())) - } - fn poll_shutdown(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll> { - Poll::Ready(Ok(())) - } -} +//! The UART reader is shared with automatic discovery and the local CLI. +pub(super) use ostool_serial::PhysicalSerial; diff --git a/ostool-server/src/serial/runtime.rs b/ostool-server/src/serial/runtime.rs new file mode 100644 index 00000000..94bf35cb --- /dev/null +++ b/ostool-server/src/serial/runtime.rs @@ -0,0 +1,479 @@ +//! Session-owned UART runtime. Only this task reads a transferred lease. +use crate::{ + session::{SessionState, SessionStopReason}, + state::AppState, +}; +use anyhow::{Context, ensure}; +use httpboot_protocol::{LoaderDeviceStatus, SerialBinding, SerialBindingMode, SerialParameters}; +use ostool_serial::{BindRequest, SerialLease}; +use serde::{Deserialize, Serialize}; +use std::{ + future::{Future, pending}, + pin::Pin, + sync::Arc, + time::Duration, +}; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt, DuplexStream}, + sync::{Mutex, mpsc, oneshot, watch}, + time::Instant, +}; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct SerialRuntimeStatus { + pub phase: SerialRuntimePhase, + pub port: Option, + pub parameters: Option, + pub boot_epoch: Option, + pub binding_id: Option, + pub error: Option, +} +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum SerialRuntimePhase { + #[default] + WaitingDevice, + Verifying, + Discovering, + Bound, + Recovering, + Failed, + Closed, +} +enum Command { + Bind( + Box, + oneshot::Sender>, + ), + Confirm(String, oneshot::Sender>), + Fail(String), + Restart(bool, oneshot::Sender<()>), +} +#[derive(Debug)] +pub struct SessionSerialRuntime { + commands: mpsc::UnboundedSender, + receiver: Mutex>>, + status: watch::Sender, + retired_epoch: std::sync::Mutex>, +} +impl Default for SessionSerialRuntime { + fn default() -> Self { + let (commands, receiver) = mpsc::unbounded_channel(); + Self { + commands, + receiver: Mutex::new(Some(receiver)), + status: watch::channel(SerialRuntimeStatus::default()).0, + retired_epoch: std::sync::Mutex::new(None), + } + } +} +impl SessionSerialRuntime { + pub fn accepts_epoch(&self, epoch: &str) -> bool { + self.retired_epoch + .lock() + .expect("serial epoch lock") + .as_deref() + != Some(epoch) + } + pub async fn restart(&self, powered: bool) { + if self.receiver.lock().await.is_some() { + return; + } + let epoch = self.snapshot().boot_epoch; + if epoch.is_some() { + *self.retired_epoch.lock().expect("serial epoch lock") = epoch; + } + let (reply, rx) = oneshot::channel(); + if self.commands.send(Command::Restart(powered, reply)).is_ok() { + let _ = rx.await; + } + } + pub fn snapshot(&self) -> SerialRuntimeStatus { + self.status.borrow().clone() + } + pub async fn bind(&self, device: LoaderDeviceStatus) -> anyhow::Result { + let (reply, rx) = oneshot::channel(); + self.commands + .send(Command::Bind(Box::new(device), reply)) + .map_err(|_| anyhow::anyhow!("serial runtime closed"))?; + rx.await.context("serial binding cancelled")? + } + pub async fn confirm(&self, binding_id: &str) -> anyhow::Result<()> { + let (reply, rx) = oneshot::channel(); + self.commands + .send(Command::Confirm(binding_id.into(), reply)) + .map_err(|_| anyhow::anyhow!("serial runtime closed"))?; + rx.await.context("serial confirmation cancelled")? + } + pub fn fail(&self, error: String) { + let _ = self.commands.send(Command::Fail(error)); + } + pub async fn attach( + self: &Arc, + state: AppState, + session: Arc, + ) -> anyhow::Result { + let commands = self + .receiver + .lock() + .await + .take() + .context("serial runtime already attached")?; + let (client, io) = tokio::io::duplex(256 * 1024); + let runtime = self.clone(); + tokio::spawn(async move { + let result = runtime.run(&state, &session, commands, io).await; + let mut status = runtime.snapshot(); + status.phase = if result.is_err() { + SerialRuntimePhase::Failed + } else { + SerialRuntimePhase::Closed + }; + if let Err(error) = &result { + status.error = Some(format!("{error:#}")); + } + runtime.status.send_replace(status); + state.admin_events.invalidate(&["sessions"]); + if result.is_err() { + session.request_stop(SessionStopReason::SerialClosed); + } + }); + Ok(client) + } + async fn run( + &self, + state: &AppState, + session: &Arc, + mut commands: mpsc::UnboundedReceiver, + io: DuplexStream, + ) -> anyhow::Result<()> { + let (mut read_client, mut write_client) = tokio::io::split(io); + let (input_tx, mut input) = mpsc::channel::>(64); + let (output, mut output_rx) = mpsc::channel::>(64); + let input_worker = async { + let mut bytes = [0; 4096]; + loop { + let n = read_client.read(&mut bytes).await?; + if n == 0 { + return Ok::<(), anyhow::Error>(()); + } + input_tx + .send(bytes[..n].to_vec()) + .await + .context("serial input closed")?; + } + }; + let (result_tx, result_rx) = oneshot::channel(); + let owner_worker = async { + let result = self + .run_owner(state, session, &mut commands, &mut input, output) + .await; + let _ = result_tx.send(result); + pending::>().await + }; + let output_worker = async { + while let Some(bytes) = output_rx.recv().await { + write_client.write_all(&bytes).await?; + } + result_rx + .await + .context("serial owner stopped without result")? + }; + tokio::select! { result=owner_worker=>result, result=input_worker=>result, result=output_worker=>result } + } + async fn run_owner( + &self, + state: &AppState, + session: &Arc, + commands: &mut mpsc::UnboundedReceiver, + input: &mut mpsc::Receiver>, + output: mpsc::Sender>, + ) -> anyhow::Result<()> { + type BindingFuture = + Pin> + Send>>; + let owner = session.snapshot().await.id; + let mut shutdown = session.subscribe_shutdown(); + let mut lease: Option = None; + let mut opening: Option = None; + let mut current: Option = None; + let mut generation = 0_u64; + let mut deadline = Instant::now() + Duration::from_secs(60); + let mut power_wait = true; + let mut verifying_until: Option = None; + let mut timer = tokio::time::interval(Duration::from_millis(25)); + let mut bytes = [0; 4096]; + loop { + let event = tokio::select! { + _=async {let _=shutdown.wait_for(|s|*s).await;}=>RuntimeEvent::Shutdown, + command=commands.recv()=>RuntimeEvent::Command(command), + result=async {match opening.as_mut(){Some(f)=>f.await,None=>pending().await}}=>RuntimeEvent::Opened(Box::new(result)), + result=async {match lease.as_mut(){Some(l)=>l.read(&mut bytes).await,None=>pending().await}}=>RuntimeEvent::Read(result), + Some(payload)=input.recv(),if lease.is_some()=>RuntimeEvent::Input(payload), + _=timer.tick()=>RuntimeEvent::Tick, + }; + let event = match event { + RuntimeEvent::Input(payload) => { + match tokio::time::timeout( + Duration::from_secs(1), + lease.as_mut().expect("live lease").write_all(&payload), + ) + .await + { + Ok(Ok(())) => continue, + Ok(Err(error)) => RuntimeEvent::Lost(error.to_string()), + Err(_) => RuntimeEvent::Lost("serial write timed out".into()), + } + } + RuntimeEvent::Read(Ok(0)) => RuntimeEvent::Lost("serial reader closed".into()), + RuntimeEvent::Read(Err(error)) => RuntimeEvent::Lost(error.to_string()), + other => other, + }; + match event { + RuntimeEvent::Shutdown => return Ok(()), + RuntimeEvent::Command(command) => match command { + None => return Ok(()), + Some(Command::Fail(error)) => anyhow::bail!(error), + Some(Command::Restart(powered, reply)) => { + current = None; + opening = None; + verifying_until = None; + power_wait = powered; + deadline = Instant::now() + Duration::from_secs(60); + self.publish( + state, + SerialRuntimeStatus { + port: lease.as_ref().map(|l| l.locator().name.clone()), + ..Default::default() + }, + ); + let _ = reply.send(()); + } + Some(Command::Confirm(id, reply)) => { + let valid = current + .as_ref() + .is_some_and(|c| c.binding.binding_id == id && c.reply.is_none()) + && lease.is_some(); + if valid { + lease.as_ref().expect("checked lease").confirm(); + let mut status = self.snapshot(); + status.phase = SerialRuntimePhase::Bound; + self.publish(state, status); + } + let _ = reply.send(if valid { + Ok(()) + } else { + Err(anyhow::anyhow!("stale serial confirmation")) + }); + } + Some(Command::Bind(device, reply)) => { + let device = *device; + if !self.accepts_epoch(&device.boot_epoch) { + let _ = + reply.send(Err(anyhow::anyhow!("waiting for a new boot epoch"))); + continue; + } + power_wait = true; + let serial = device + .serial + .as_ref() + .context("device has no v6 serial parameters")?; + ensure!( + serial.ready, + "automatic serial unavailable: {:?}", + serial.error + ); + let parameters = serial + .parameters + .context("unknown device UART parameters")?; + parameters.validate().map_err(|e| anyhow::anyhow!("{e}"))?; + if !matches!( + session.board().power_management, + crate::config::PowerManagementConfig::Qemu { .. } + ) { + ostool_serial::validate_host_parameters(parameters)?; + } + if let Some(c) = current.as_ref() + && c.device.boot_epoch == device.boot_epoch + && c.binding.serial_id == serial.serial_id + && c.reply.is_none() + && lease.is_some() + { + let _ = reply.send(Ok(c.binding.clone())); + continue; + } + generation = generation + .checked_add(1) + .context("serial generation exhausted")?; + opening = None; + let binding = SerialBinding { + serial_id: serial.serial_id.clone(), + binding_id: uuid::Uuid::new_v4().simple().to_string(), + mode: SerialBindingMode::Bound, + }; + current = Some(BindingAttempt { + device, + parameters, + binding, + reply: Some(reply), + }); + deadline = Instant::now() + Duration::from_secs(60); + let c = current.as_ref().expect("current attempt"); + if let Some(io) = lease.as_mut() { + if io.configure(parameters).is_err() { + state.serial_manager.invalidate(c.device.mac_address); + lease = None; + } else { + verifying_until = Some(Instant::now() + Duration::from_secs(1)); + } + } + self.publish( + state, + SerialRuntimeStatus { + phase: if lease.is_some() { + SerialRuntimePhase::Verifying + } else { + SerialRuntimePhase::Discovering + }, + parameters: Some(parameters), + boot_epoch: Some(c.device.boot_epoch.clone()), + port: lease.as_ref().map(|l| l.locator().name.clone()), + ..Default::default() + }, + ); + if lease.is_none() { + opening = Some(bind_future(state, &owner, generation, c, deadline)); + } + } + }, + RuntimeEvent::Opened(result) => { + opening = None; + let io = (*result)?; + let c = current.as_mut().context("missing binding attempt")?; + let status = SerialRuntimeStatus { + phase: SerialRuntimePhase::Verifying, + port: Some(io.locator().name.clone()), + parameters: Some(c.parameters), + boot_epoch: Some(c.device.boot_epoch.clone()), + binding_id: Some(c.binding.binding_id.clone()), + error: None, + }; + lease = Some(io); + if let Some(reply) = c.reply.take() { + let _ = reply.send(Ok(c.binding.clone())); + } + self.publish(state, status); + } + RuntimeEvent::Lost(error) => { + if let Some(c) = current.as_ref() { + state.serial_manager.invalidate(c.device.mac_address); + } + lease = None; + verifying_until = None; + let mut status = self.snapshot(); + status.phase = SerialRuntimePhase::Recovering; + status.error = Some(format!( + "serial IO lost: {error}; waiting for axloader identity" + )); + self.publish(state, status); + if let Some(c) = current.as_ref().filter(|c| c.reply.is_some()) { + opening = Some(bind_future(state, &owner, generation, c, deadline)); + } else { + deadline = Instant::now() + Duration::from_secs(5); + } + } + RuntimeEvent::Read(Ok(size)) => { + if let Some(c) = current.as_mut() + && c.reply.is_some() + && lease + .as_mut() + .and_then(|l| l.take_observed_id()) + .is_some_and(|id| id.as_slice() == c.binding.serial_id.as_bytes()) + { + verifying_until = None; + let _ = c + .reply + .take() + .expect("checked reply") + .send(Ok(c.binding.clone())); + let mut status = self.snapshot(); + status.binding_id = Some(c.binding.binding_id.clone()); + self.publish(state, status); + } + output + .try_send(bytes[..size].to_vec()) + .context("serial output buffer full or closed")?; + } + RuntimeEvent::Read(Err(_)) | RuntimeEvent::Input(_) => { + unreachable!("normalized event") + } + RuntimeEvent::Tick => { + if current + .as_ref() + .is_some_and(|c| c.reply.as_ref().is_some_and(|r| r.is_closed())) + { + current = None; + opening = None; + verifying_until = None; + } + if verifying_until.is_some_and(|time| time <= Instant::now()) { + verifying_until = None; + let c = current.as_ref().context("missing verification")?; + state.serial_manager.invalidate(c.device.mac_address); + lease = None; + opening = Some(bind_future(state, &owner, generation, c, deadline)); + let mut status = self.snapshot(); + status.phase = SerialRuntimePhase::Discovering; + self.publish(state, status); + } + if power_wait + && Instant::now() >= deadline + && (lease.is_none() || current.as_ref().is_some_and(|c| c.reply.is_some())) + { + anyhow::bail!( + "axloader serial identity did not become available before deadline" + ); + } + } + } + } + } + fn publish(&self, state: &AppState, status: SerialRuntimeStatus) { + self.status.send_replace(status); + state.admin_events.invalidate(&["sessions"]); + } +} +enum RuntimeEvent { + Shutdown, + Command(Option), + Opened(Box>), + Read(std::io::Result), + Lost(String), + Input(Vec), + Tick, +} + +struct BindingAttempt { + device: LoaderDeviceStatus, + parameters: SerialParameters, + binding: SerialBinding, + reply: Option>>, +} +fn bind_future( + state: &AppState, + owner: &str, + generation: u64, + c: &BindingAttempt, + deadline: Instant, +) -> Pin> + Send>> { + let manager = state.serial_manager.clone(); + let request = BindRequest { + owner: owner.into(), + generation, + mac_address: c.device.mac_address, + boot_epoch: c.device.boot_epoch.clone(), + serial_id: c.binding.serial_id.clone(), + parameters: c.parameters, + deadline, + }; + Box::pin(async move { manager.bind(request).await }) +} diff --git a/ostool-server/src/serial/transport.rs b/ostool-server/src/serial/transport.rs index 7c7f42e1..eb924123 100644 --- a/ostool-server/src/serial/transport.rs +++ b/ostool-server/src/serial/transport.rs @@ -24,7 +24,7 @@ pub(super) async fn run( serial_tx: &mut W, ws_sender: &mut S, ws_receiver: &mut I, - mut ready: watch::Receiver, + ready: watch::Receiver, heartbeat: F, ) -> anyhow::Result<()> where @@ -91,10 +91,6 @@ where } }; let read_websocket = async { - ready - .wait_for(|ready| *ready) - .await - .context("power-on cancelled")?; while let Some(message) = ws_receiver.next().await { let payload = match message? { Message::Binary(bytes) => Some(bytes.to_vec()), @@ -135,6 +131,11 @@ where Ok(()) }; let write_serial = async { + let mut ready = ready; + ready + .wait_for(|ready| *ready) + .await + .context("power-on cancelled")?; while let Some(payload) = input_rx.recv().await { // SerialStream writes directly to the kernel. flush() calls blocking // tcdrain(), holding tokio::io::split's mutex and preventing reads. @@ -157,14 +158,10 @@ where #[cfg(test)] mod tests { - #[cfg(unix)] - use super::super::physical::PhysicalSerial; use super::*; use futures_util::{Sink, stream}; - #[cfg(unix)] - use serialport::TTYPort; use std::{ - io::{self, Write}, + io, pin::Pin, sync::Arc, task::{Context, Poll}, @@ -260,36 +257,40 @@ mod tests { assert_eq!(bytes, payload); } - #[cfg(unix)] + struct ErrorReader { + bytes: std::io::Cursor>, + error_observed: Arc, + } + impl AsyncRead for ErrorReader { + fn poll_read( + mut self: Pin<&mut Self>, + _: &mut Context<'_>, + output: &mut tokio::io::ReadBuf<'_>, + ) -> Poll> { + if output.remaining() == 0 { + return Poll::Ready(Ok(())); + } + let size = std::io::Read::read(&mut self.bytes, output.initialize_unfilled())?; + if size == 0 { + self.error_observed.notify_one(); + Poll::Ready(Err(io::Error::other("receive failed"))) + } else { + output.advance(size); + Poll::Ready(Ok(())) + } + } + } #[tokio::test] - async fn physical_receive_overflow_drains_accepted_output_before_error() { - let (mut board, slave) = TTYPort::pair().expect("create PTY pair"); - let physical = PhysicalSerial::new(slave).expect("start physical serial reader"); - let snapshot = physical.test_receive_snapshotter(); - let payload: Vec = (0..CHUNK_SIZE * QUEUE_CHUNKS + 1) + async fn receive_error_drains_accepted_output_before_failure() { + let expected: Vec = (0..CHUNK_SIZE * QUEUE_CHUNKS / 2) .map(|i| (i % 251) as u8) .collect(); - deadline(tokio::task::spawn_blocking(move || { - board.write_all(&payload) - })) - .await - .expect("PTY writer task panicked") - .expect("write PTY payload"); - - let expected = deadline(async { - loop { - let (bytes, closed, error_pending) = snapshot(); - if closed { - assert!(error_pending, "physical reader closed without overflow"); - break bytes; - } - tokio::task::yield_now().await; - } - }) - .await; - assert!(!expected.is_empty(), "physical reader buffered no input"); - - let (mut serial_rx, mut serial_tx) = tokio::io::split(physical); + let error_observed = Arc::new(Notify::new()); + let mut serial_rx = ErrorReader { + bytes: std::io::Cursor::new(expected.clone()), + error_observed: error_observed.clone(), + }; + let mut serial_tx = tokio::io::sink(); let (output, mut received) = mpsc::unbounded_channel(); let (release, gate) = tokio::sync::oneshot::channel(); let blocked = Arc::new(Notify::new()); @@ -314,16 +315,7 @@ mod tests { }); deadline(blocked.notified()).await; - deadline(async { - loop { - let (_, closed, error_pending) = snapshot(); - if closed && !error_pending { - break; - } - tokio::task::yield_now().await; - } - }) - .await; + deadline(error_observed.notified()).await; assert!( !task.is_finished(), "transport reported serial overflow before draining queued output" @@ -351,6 +343,12 @@ mod tests { #[tokio::test] async fn blocked_serial_write_does_not_stop_output_or_peer_close() { + for initially_ready in [false, true] { + assert_peer_progress_while_serial_write_waits(initially_ready).await; + } + } + + async fn assert_peer_progress_while_serial_write_waits(initially_ready: bool) { let (mut board, server) = tokio::io::duplex(16); let (mut rx, mut tx) = tokio::io::split(server); let (output, mut received) = mpsc::unbounded_channel(); @@ -363,21 +361,35 @@ mod tests { }; let (commands, mut command_rx) = mpsc::unbounded_channel(); let mut input = Box::pin(stream::poll_fn(move |cx| command_rx.poll_recv(cx))); - let (_ready, ready) = watch::channel(true); + let (_ready, ready) = watch::channel(initially_ready); let task = tokio::spawn(async move { run(&mut rx, &mut tx, &mut sink, &mut input, ready, || async {}).await }); commands .send(Ok::<_, io::Error>(Message::Binary(vec![0x41; 128].into()))) .unwrap(); - let mut first = [0; 16]; - deadline(board.read_exact(&mut first)).await.unwrap(); - // Leave the rest unread, so the serial writer cannot finish. + if initially_ready { + let mut first = [0; 16]; + deadline(board.read_exact(&mut first)).await.unwrap(); + // Leave the rest unread, so the serial writer cannot finish. + } + commands + .send(Ok(Message::Ping(b"waiting".to_vec().into()))) + .unwrap(); board.write_all(b"still alive").await.unwrap(); - loop { - if let Message::Binary(bytes) = deadline(received.recv()).await.unwrap() { - assert_eq!(bytes.as_ref(), b"still alive"); - break; + let mut got_output = false; + let mut got_pong = false; + while !got_output || !got_pong { + match deadline(received.recv()).await.unwrap() { + Message::Binary(bytes) => { + assert_eq!(bytes.as_ref(), b"still alive"); + got_output = true; + } + Message::Pong(bytes) => { + assert_eq!(bytes.as_ref(), b"waiting"); + got_pong = true; + } + _ => {} } } commands.send(Ok(Message::Close(None))).unwrap(); diff --git a/ostool-server/src/serial/ws.rs b/ostool-server/src/serial/ws.rs index d466af43..40d493e5 100644 --- a/ostool-server/src/serial/ws.rs +++ b/ostool-server/src/serial/ws.rs @@ -16,7 +16,7 @@ use tokio_serial::SerialPortBuilderExt; use tokio_serial::{ClearBuffer, SerialPort}; use crate::{ - config::{BoardConfig, SerialConfig, SerialPortKeyKind}, + config::{BoardConfig, BootConfig, SerialConfig, SerialPortKeyKind}, power::{PowerAction, PowerActionError}, serial::discovery::resolve_serial_config, session::SessionState, @@ -115,25 +115,49 @@ async fn run_serial_ws_inner( ) -> anyhow::Result<()> { let session_id = session.snapshot().await.id; let board = session.board().clone(); - let serial = board - .serial - .as_ref() - .ok_or_else(|| anyhow::anyhow!("board has no serial configuration"))?; - let mut port = match open_board_serial(state, serial) - .await - .with_context(|| format!("failed to open board serial for `{}`", board.id)) - { - Ok(port) => port, - Err(err) => { - let _ = tokio::time::timeout( - SERIAL_CLOSE_TIMEOUT, - send_serial_failure_and_close(&mut socket, &format!("{err:#}")), + let automatic = matches!(board.boot, BootConfig::UefiHttp(_)); + let opened = async { + let reservation = if automatic { + None + } else { + let serial = board + .serial + .as_ref() + .context("board has no serial configuration")?; + Some( + state + .serial_manager + .reserve(super::backend::locator_for_key(&serial.key)?) + .await?, ) - .await; - return Err(err); - } - }; - clear_serial_input_after_open(&session_id, &mut port); + }; + let port = if automatic { + session + .serial_runtime + .attach(state.clone(), session.clone()) + .await + .map(BoardSerialStream::Qemu)? + } else { + open_board_serial(state, board.serial.as_ref().expect("manual serial")).await? + }; + Ok::<_, anyhow::Error>((reservation, port)) + } + .await; + let (_reservation, mut port) = + match opened.with_context(|| format!("failed to open board serial for `{}`", board.id)) { + Ok(pair) => pair, + Err(err) => { + let _ = tokio::time::timeout( + SERIAL_CLOSE_TIMEOUT, + send_serial_failure_and_close(&mut socket, &format!("{err:#}")), + ) + .await; + return Err(err); + } + }; + if !automatic { + clear_serial_input_after_open(&session_id, &mut port); + } let (mut ws_sender, mut ws_receiver) = socket.split(); let (mut serial_rx, mut serial_tx) = tokio::io::split(port); @@ -224,6 +248,7 @@ async fn open_board_serial( #[cfg(unix)] fn physical_from_port(port: serialport::TTYPort) -> anyhow::Result { + port.clear(ClearBuffer::Input)?; Ok(BoardSerialStream::Physical(PhysicalSerial::new(port)?)) } @@ -369,10 +394,10 @@ impl SerialQueueCleanup for tokio_serial::SerialStream { impl SerialQueueCleanup for PhysicalSerial { async fn flush_output(&mut self) -> std::io::Result<()> { self.stop_reader(); - self.writer.flush_output().await + wait_output_empty(|| self.bytes_to_write(), SERIAL_CLOSE_TIMEOUT).await } fn clear_all_buffers(&mut self) -> std::io::Result<()> { - self.writer.clear_all_buffers() + self.clear(ClearBuffer::All) } } @@ -854,32 +879,6 @@ mod reader_progress_tests { .unwrap(); assert_eq!(received, expected); } - #[tokio::test(flavor = "current_thread")] - async fn physical_receive_overflow_is_reported_after_buffered_bytes() { - let (mut board, mut port) = serialport::TTYPort::pair().unwrap(); - board.set_timeout(Duration::from_millis(200)).unwrap(); - port.set_timeout(super::SERIAL_READ_TIMEOUT).unwrap(); - let mut serial = super::physical_from_port(port).unwrap(); - let writer = std::thread::spawn(move || { - // Deliberately exceed the receive bound without polling AsyncRead. - let _ = (0..4096).try_for_each(|_| board.write_all(&[0x5a; 128])); - board - }); - let _board = writer.join().unwrap(); - let mut bytes = Vec::new(); - let error = tokio::time::timeout(Duration::from_secs(2), serial.read_to_end(&mut bytes)) - .await - .unwrap() - .unwrap_err(); - assert!( - error.to_string().contains("physical serial RX buffer full"), - "{error}" - ); - assert!(!bytes.is_empty()); - assert!(bytes.len() <= 256 * 1024); - assert!(bytes.iter().all(|byte| *byte == 0x5a)); - } - #[tokio::test(flavor = "current_thread")] async fn dropping_physical_receive_joins_reader_before_reusing_tty() { use std::io::Read; diff --git a/ostool-server/src/session.rs b/ostool-server/src/session.rs index cb2d98eb..4d9a185b 100644 --- a/ostool-server/src/session.rs +++ b/ostool-server/src/session.rs @@ -65,6 +65,8 @@ pub struct Session { pub expires_at: DateTime, #[serde(default)] pub serial_connected: bool, + #[serde(default)] + pub serial_runtime: crate::serial::runtime::SerialRuntimeStatus, #[serde(default = "default_session_state")] pub state: SessionLifecycleState, } @@ -84,6 +86,7 @@ impl Session { last_heartbeat_at: now, expires_at: now + SESSION_TTL, serial_connected: false, + serial_runtime: Default::default(), state: SessionLifecycleState::Active, } } @@ -97,6 +100,8 @@ impl Session { #[derive(Debug)] pub struct SessionState { + pub serial_runtime: Arc, + boot_generation: watch::Sender, info: RwLock, board: BoardConfig, shutdown_tx: watch::Sender, @@ -157,6 +162,8 @@ impl SessionState { ) -> Arc { let (shutdown_tx, _shutdown_rx) = watch::channel(false); Arc::new(Self { + serial_runtime: Arc::default(), + boot_generation: watch::channel(0).0, info: RwLock::new(Session::new_with_id( session_id, board.id.clone(), @@ -172,6 +179,14 @@ impl SessionState { }) } + pub fn subscribe_boot_generation(&self) -> watch::Receiver { + self.boot_generation.subscribe() + } + pub async fn change_power(&self, powered: bool) { + self.boot_generation + .send_modify(|g| *g = g.checked_add(1).expect("boot generation exhausted")); + self.serial_runtime.restart(powered).await; + } pub fn board(&self) -> &BoardConfig { &self.board } @@ -184,6 +199,7 @@ impl SessionState { let mut info = self.info.read().await.clone(); info.serial_connected = self.serial_connected.load(Ordering::Acquire); info.state = self.lifecycle_state(); + info.serial_runtime = self.serial_runtime.snapshot(); info } @@ -192,6 +208,7 @@ impl SessionState { info.touch(); info.serial_connected = self.serial_connected.load(Ordering::Acquire); info.state = self.lifecycle_state(); + info.serial_runtime = self.serial_runtime.snapshot(); info.clone() } diff --git a/ostool-server/src/state.rs b/ostool-server/src/state.rs index 1c653990..b28cb4ac 100644 --- a/ostool-server/src/state.rs +++ b/ostool-server/src/state.rs @@ -98,6 +98,7 @@ fn release_settle_delay(board: &BoardConfig) -> Option { #[derive(Clone)] pub struct AppState { + pub serial_manager: ostool_serial::SerialManager, pub admin_events: crate::admin_events::AdminEvents, pub admin_power: crate::admin_power::AdminPower, pub config_path: Arc, @@ -149,7 +150,12 @@ pub async fn build_app_state( } } } + let serial_manager = + ostool_serial::SerialManager::new(Arc::new(crate::serial::backend::ServerSerialBackend { + virtual_boards: virtual_boards.clone(), + })); let state = AppState { + serial_manager, admin_events: admin_events.clone(), admin_power: crate::admin_power::AdminPower::default(), config_path: Arc::new(config_path), @@ -167,6 +173,15 @@ pub async fn build_app_state( release_tx, }; + state.refresh_serial_exclusions().await?; + let mut serial_snapshot = state.serial_manager.subscribe(); + let serial_events = state.admin_events.clone(); + tokio::spawn(async move { + while serial_snapshot.changed().await.is_ok() { + serial_events.invalidate(&["serial_manager"]); + } + }); + tokio::spawn(run_release_coordinator(state.clone(), release_rx)); state.admin_events.start(state.clone()); @@ -389,11 +404,57 @@ impl AppState { }) } + pub async fn refresh_serial_exclusions(&self) -> anyhow::Result<()> { + let selectors = self + .boards + .read() + .await + .values() + .filter_map(|b| crate::serial::backend::relay_selector(&b.power_management)) + .collect(); + self.serial_manager.exclude(selectors).await?; + Ok(()) + } + pub async fn prepare_board_serial_exclusions( + &self, + board: &BoardConfig, + replaced: Option<&str>, + ) -> anyhow::Result<()> { + let boards = self.boards.read().await; + let mut selectors: Vec<_> = boards + .iter() + .filter(|(id, _)| Some(id.as_str()) != replaced) + .filter_map(|(_, b)| crate::serial::backend::relay_selector(&b.power_management)) + .collect(); + selectors.extend(crate::serial::backend::relay_selector( + &board.power_management, + )); + drop(boards); + self.serial_manager.exclude(selectors).await?; + Ok(()) + } + pub async fn reserve_power_serial( + &self, + power: &PowerManagementConfig, + ) -> anyhow::Result> { + if let PowerManagementConfig::ZhongshengRelay(relay) = power { + let locator = crate::serial::backend::locator_for_key(&relay.key)?; + return Ok(Some(self.serial_manager.reserve(locator).await?)); + } + Ok(None) + } pub async fn execute_board_power_action( &self, board: &BoardConfig, action: PowerAction, ) -> Result { + if matches!(board.boot, crate::config::BootConfig::UefiHttp(_)) + && let Some(runtime) = self.board_runtime_status(&board.id).await + && let Some(id) = runtime.active_session_id + && let Some(session) = self.session_state(&id).await + { + session.change_power(action == PowerAction::On).await; + } if let PowerManagementConfig::Qemu { virtual_device_id } = &board.power_management { return match action { PowerAction::On => { @@ -418,6 +479,10 @@ impl AppState { .map_err(PowerActionError::Execution), }; } + let _reservation = self + .reserve_power_serial(&board.power_management) + .await + .map_err(PowerActionError::Execution)?; execute_power_action_for_board(board, action).await } @@ -722,7 +787,14 @@ impl AppState { let deadline = tokio::time::Instant::now() + timeout; loop { if !session.is_serial_connected() { - return Ok(()); + let owner = session.snapshot().await.id; + return tokio::time::timeout_at( + deadline, + self.serial_manager.wait_owner_released(&owner), + ) + .await + .map_err(|_| "timed out waiting for actual serial lease release".to_string())? + .map_err(|e| e.to_string()); } if tokio::time::Instant::now() >= deadline { diff --git a/ostool-server/src/virtual_qemu.rs b/ostool-server/src/virtual_qemu.rs index 0d72f4af..f1ce6b7c 100644 --- a/ostool-server/src/virtual_qemu.rs +++ b/ostool-server/src/virtual_qemu.rs @@ -496,14 +496,12 @@ impl SerialHub { async fn attach(&self) -> anyhow::Result { let (client, hub_side) = tokio::io::duplex(SERIAL_HISTORY_LIMIT); let (mut hub_reader, mut hub_writer) = tokio::io::split(hub_side); - let history = self - .history - .lock() - .await - .iter() - .copied() - .collect::>(); + // Subscribe and snapshot under the producer's lock, so bytes cross the + // history/live boundary exactly once during discovery lease transfer. + let history_guard = self.history.lock().await; let mut output_rx = self.output_tx.subscribe(); + let history = history_guard.iter().copied().collect::>(); + drop(history_guard); let (attachment_closed_tx, mut attachment_closed_rx) = tokio::sync::oneshot::channel(); tokio::spawn(async move { if !history.is_empty() && hub_writer.write_all(&history).await.is_err() { @@ -588,8 +586,8 @@ impl SerialHub { let drain = overflow.min(history.len()); history.drain(..drain); history.extend(bytes.iter().copied()); - drop(history); let _ = self.output_tx.send(bytes.to_vec()); + drop(history); } async fn shutdown(&self) { diff --git a/ostool-server/tests/axloader_serial.rs b/ostool-server/tests/axloader_serial.rs new file mode 100644 index 00000000..af27670d --- /dev/null +++ b/ostool-server/tests/axloader_serial.rs @@ -0,0 +1,379 @@ +//! Real PTY, HTTP and WebSocket coverage of automatic serial ownership. +#![cfg(target_os = "linux")] +use axum::{ + Json, Router, + extract::State, + http::{HeaderMap, StatusCode}, + routing::{get, post, put}, +}; +use futures_util::{SinkExt, StreamExt}; +use httpboot_protocol::*; +use ostool_serial::{PortLocator, SerialManager}; +#[path = "common/serial.rs"] +mod serial_fixture; +use ostool_server::{session::SessionBootCommand, tftp::service::build_tftp_manager, *}; +use serial_fixture::PtyBackend; +use serialport::{SerialPort, TTYPort}; +use sha2::{Digest, Sha256}; +use std::{sync::Arc, time::Duration}; +use tokio::{ + io::AsyncWriteExt, + sync::{Mutex, mpsc}, + time::Instant, +}; +use tokio_tungstenite::tungstenite::Message; + +fn actual_baud(file: &std::fs::File) -> u32 { + use std::os::fd::AsRawFd; + let mut settings = std::mem::MaybeUninit::::uninit(); + // SAFETY: a live PTY descriptor and aligned output; TCGETS2 initializes the + // complete object on success and retains no pointer. + assert_eq!( + unsafe { nix::libc::ioctl(file.as_raw_fd(), nix::libc::TCGETS2, settings.as_mut_ptr()) }, + 0 + ); + // SAFETY: the successful ioctl initialized all fields. + unsafe { settings.assume_init() }.c_ospeed +} + +struct Device { + status: LoaderDeviceStatus, + output: tokio_serial::SerialStream, + starts: usize, +} +type DeviceState = Arc>; +async fn status(State(d): State) -> Json { + Json(d.lock().await.status.clone()) +} +async fn grant( + State(d): State, + h: HeaderMap, + Json(b): Json, +) -> StatusCode { + let mut d = d.lock().await; + if header(&h, "X-Boot-Epoch") != Some(d.status.boot_epoch.as_str()) { + return StatusCode::CONFLICT; + } + if d.status.serial.as_mut().unwrap().grant(b).is_err() { + return StatusCode::CONFLICT; + } + StatusCode::OK +} +async fn start(State(d): State, h: HeaderMap) -> StatusCode { + let mut d = d.lock().await; + if header(&h, "X-Boot-Epoch") != Some(d.status.boot_epoch.as_str()) + || !d + .status + .serial + .as_ref() + .unwrap() + .permits_start(header(&h, "X-Serial-Binding").unwrap_or("")) + { + return StatusCode::CONFLICT; + } + d.starts += 1; + d.output + .write_all(b"KERNEL_OUTPUT_AFTER_CONTINUE\n") + .await + .unwrap(); + StatusCode::ACCEPTED +} +fn header<'a>(h: &'a HeaderMap, name: &str) -> Option<&'a str> { + h.get(name).and_then(|v| v.to_str().ok()) +} +fn parameters(baud: u64) -> SerialParameters { + SerialParameters { + baud_rate: baud, + data_bits: 8, + parity: SerialParity::None, + stop_bits: SerialStopBits::One, + flow_control: SerialFlowControl::None, + } +} +fn serial_status(n: u8, baud: u64) -> LoaderSerialStatus { + LoaderSerialStatus { + serial_id: format!("{n:032x}"), + ready: true, + parameters: Some(parameters(baud)), + binding: None, + error: None, + } +} +fn locator(path: &str) -> PortLocator { + PortLocator { + name: path.into(), + aliases: vec![format!("alias:{path}")], + serial_number: None, + } +} +async fn wait_config( + rx: &mut mpsc::UnboundedReceiver<(String, SerialParameters)>, + path: &str, + baud: u64, +) { + tokio::time::timeout(Duration::from_secs(3), async { + loop { + let (port, p) = rx.recv().await.unwrap(); + if port == path && p.baud_rate == baud { + break; + } + } + }) + .await + .unwrap(); +} +async fn wait_snapshot( + manager: &SerialManager, + check: impl Fn(&ostool_serial::ManagerSnapshot) -> bool, +) { + let mut snapshot = manager.subscribe(); + tokio::time::timeout(Duration::from_secs(3), snapshot.wait_for(check)) + .await + .unwrap() + .unwrap(); +} +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn serial_null_session_binds_reuses_uart_and_releases_real_reader() { + let dir = tempfile::tempdir().unwrap(); + let mac = "02:00:00:00:00:42".parse().unwrap(); + let (master, mut slave) = TTYPort::pair().unwrap(); + slave.set_exclusive(false).unwrap(); + slave.set_baud_rate(38400).unwrap(); + let path = slave.name().unwrap(); + drop(slave); + let inspection = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(&path) + .unwrap(); + let original = nix::sys::termios::tcgetattr(&inspection).unwrap(); + let original_baud = actual_baud(&inspection); + let (unused_master, mut unused_slave) = TTYPort::pair().unwrap(); + unused_slave.set_exclusive(false).unwrap(); + let unused_path = unused_slave.name().unwrap(); + drop(unused_slave); + let (relay_master, mut relay_slave) = TTYPort::pair().unwrap(); + relay_slave.set_exclusive(false).unwrap(); + let relay_path = relay_slave.name().unwrap(); + drop(relay_slave); + let (configured, mut configs) = mpsc::unbounded_channel(); + let backend = Arc::new(PtyBackend { + ports: vec![locator(&path), locator(&unused_path), locator(&relay_path)], + configured, + }); + let config_path = dir.path().join("server.toml"); + let mut config = ServerConfig::default_for_path(&config_path); + config.data_dir = dir.path().join("data"); + config.board_dir = dir.path().join("boards"); + config.dtb_dir = dir.path().join("dtbs"); + config.tftp = TftpConfig::Builtin(BuiltinTftpConfig::default_with_root( + dir.path().join("files"), + )); + let mut state = build_app_state( + config_path, + config.clone(), + build_tftp_manager(&config.tftp), + ) + .await + .unwrap(); + state.serial_manager = SerialManager::new(backend); + let board = BoardConfig { + id: "auto".into(), + board_type: "uefi".into(), + tags: vec![], + serial: None, + power_management: PowerManagementConfig::Custom(CustomPowerManagement { + power_on_cmd: "true".into(), + power_off_cmd: "true".into(), + }), + boot: BootConfig::UefiHttp(UefiHttpProfile { + boot_arch: Some(UefiBootArch::X86_64), + }), + network_identity: Some(BoardNetworkIdentity { mac_address: mac }), + notes: None, + disabled: false, + }; + board.validate().unwrap(); + state.boards.write().await.insert(board.id.clone(), board); + let relay = BoardConfig { + id: "relay".into(), + board_type: "relay".into(), + tags: vec![], + serial: None, + power_management: PowerManagementConfig::ZhongshengRelay(ZhongshengRelayPowerManagement { + key: SerialPortKey { + kind: SerialPortKeyKind::UsbPath, + value: relay_path.clone(), + }, + }), + boot: BootConfig::Pxe(PxeProfile::default()), + network_identity: None, + notes: None, + disabled: false, + }; + state.boards.write().await.insert(relay.id.clone(), relay); + state.sync_board_runtime_states().await; + state.refresh_serial_exclusions().await.unwrap(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let server_addr = listener.local_addr().unwrap(); + let app = build_router(state.clone()); + let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + let device = Arc::new(Mutex::new(Device { + status: LoaderDeviceStatus { + protocol_version: DEVICE_PROTOCOL_VERSION, + boot_epoch: format!("{:032x}", 1), + mac_address: mac, + current_mac_address: mac, + arch: BootArch::X86_64, + loader_version: "test-v6".into(), + hardware: LoaderHardwareInfo::default(), + boot: None, + ota: None, + serial: Some(serial_status(1, 57600)), + }, + output: tokio_serial::SerialStream::try_from(master).unwrap(), + starts: 0, + })); + let fake = Router::new() + .route("/api/v1/status", get(status)) + .route("/api/v1/serial/continue", post(grant)) + .route("/api/v1/boot/jobs", post(|| async { StatusCode::CREATED })) + .route( + "/api/v1/boot/jobs/{id}/kernel", + put(|| async { StatusCode::OK }), + ) + .route("/api/v1/boot/jobs/{id}/start", post(start)) + .with_state(device.clone()); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let device_port = listener.local_addr().unwrap().port(); + let http = tokio::spawn(async move { axum::serve(listener, fake).await.unwrap() }); + let created = state + .create_session_with_board_id("uefi", "auto", &[], None) + .await + .unwrap(); + let session = state.session_state(&created.id).await.unwrap(); + state + .tftp_manager + .read() + .await + .put_session_file(&created.id, "kernel.elf", b"kernel") + .await + .unwrap(); + session + .publish_boot_command(SessionBootCommand { + boot_id: "kernel".into(), + kernel_path: format!("/boot/sessions/{}/kernel.elf", created.id), + kernel_size: 6, + kernel_sha256: format!("{:x}", Sha256::digest(b"kernel")), + arch: BootArch::X86_64, + image_format: ImageFormat::Elf64, + entry_symbol: None, + initramfs: None, + cmdline: None, + }) + .await; + let (mut ws, _) = tokio_tungstenite::connect_async(format!( + "ws://{server_addr}/api/v1/sessions/{}/serial/ws", + created.id + )) + .await + .unwrap(); + assert!(matches!(ws.next().await.unwrap().unwrap(),Message::Text(t) if t.contains("opened"))); + // Wait for the linked power action before advertising the first epoch. + let limit = Instant::now() + Duration::from_secs(3); + while *session.subscribe_boot_generation().borrow() == 0 { + assert!(Instant::now() < limit); + tokio::task::yield_now().await; + } + for (n, baud) in [(1, 57600), (2, 115200)] { + if n == 2 { + state + .execute_board_power_action(session.board(), ostool_server::power::PowerAction::On) + .await + .unwrap(); + } + { + let mut d = device.lock().await; + d.status.boot_epoch = format!("{n:032x}"); + d.status.serial = Some(serial_status(n, baud)); + } + let announcement = LoaderAnnouncement { + protocol_version: DEVICE_PROTOCOL_VERSION, + mac_address: mac, + current_mac_address: mac, + arch: BootArch::X86_64, + loader_version: "test-v6".into(), + boot_epoch: format!("{n:032x}"), + http_port: device_port, + serial_id: Some(format!("{n:032x}")), + serial_ready: true, + }; + let s = state.clone(); + let operation = tokio::spawn(async move { + ostool_server::device::reconcile(s, announcement, "127.0.0.1:12345".parse().unwrap()) + .await + }); + wait_config(&mut configs, &path, baud).await; + device + .lock() + .await + .output + .write_all(format!("\r\nAXLOADER-SERIAL/1 {n:032x}\r\n").as_bytes()) + .await + .unwrap(); + tokio::time::timeout(Duration::from_secs(4), operation) + .await + .unwrap() + .unwrap() + .unwrap(); + let mut output = Vec::new(); + tokio::time::timeout(Duration::from_secs(3), async { + loop { + if let Message::Binary(bytes) = ws.next().await.unwrap().unwrap() { + output.extend(bytes); + if output.ends_with(b"KERNEL_OUTPUT_AFTER_CONTINUE\n") { + break; + } + } + } + }) + .await + .unwrap(); + assert!(String::from_utf8_lossy(&output).contains(&format!("AXLOADER-SERIAL/1 {n:032x}"))); + assert_eq!( + session.serial_runtime.snapshot().phase, + ostool_server::serial::runtime::SerialRuntimePhase::Bound + ); + wait_snapshot(&state.serial_manager, |s| { + s.pending == 0 && s.candidates == 0 && s.leased == 1 + }) + .await; + assert_eq!(u64::from(actual_baud(&inspection)), baud); + } + assert_eq!(device.lock().await.starts, 2); + while let Ok((port, _)) = configs.try_recv() { + assert_ne!(port, relay_path); + assert_ne!(port, path, "bound UART must not reopen on second boot"); + } + ws.send(Message::Close(None)).await.unwrap(); + drop(ws); + tokio::time::timeout( + Duration::from_secs(3), + state.serial_manager.wait_owner_released(&created.id), + ) + .await + .unwrap() + .unwrap(); + wait_snapshot(&state.serial_manager, |s| { + s.leased == 0 && s.candidates == 0 + }) + .await; + let restored = nix::sys::termios::tcgetattr(&inspection).unwrap(); + assert_eq!(restored.control_flags, original.control_flags); + assert_eq!(restored.local_flags, original.local_flags); + assert_eq!(actual_baud(&inspection), original_baud); + server.abort(); + http.abort(); + drop(unused_master); + drop(relay_master); +} diff --git a/ostool-server/tests/common/serial.rs b/ostool-server/tests/common/serial.rs new file mode 100644 index 00000000..c28eefa4 --- /dev/null +++ b/ostool-server/tests/common/serial.rs @@ -0,0 +1,74 @@ +use httpboot_protocol::SerialParameters; +use ostool_serial::{BackendFuture, NativeBackend, PortLocator, SerialBackend, SerialIo}; +use std::{ + io, + pin::Pin, + task::{Context, Poll}, +}; +use tokio::{ + io::{AsyncRead, AsyncWrite, ReadBuf}, + sync::mpsc, +}; + +pub struct PtyBackend { + pub ports: Vec, + pub configured: mpsc::UnboundedSender<(String, SerialParameters)>, +} +impl SerialBackend for PtyBackend { + fn ports(&self) -> BackendFuture<'_, Vec> { + Box::pin(async { Ok(self.ports.clone()) }) + } + fn open( + &self, + port: PortLocator, + parameters: SerialParameters, + ) -> BackendFuture<'_, Box> { + Box::pin(async move { + let io = NativeBackend.open(port.clone(), parameters).await?; + self.configured + .send((port.name.clone(), parameters)) + .unwrap(); + Ok(Box::new(ObservedIo { + io, + port: port.name, + configured: self.configured.clone(), + }) as Box) + }) + } +} +struct ObservedIo { + io: Box, + port: String, + configured: mpsc::UnboundedSender<(String, SerialParameters)>, +} +impl SerialIo for ObservedIo { + fn configure(&mut self, p: SerialParameters) -> io::Result<()> { + self.io.configure(p)?; + let _ = self.configured.send((self.port.clone(), p)); + Ok(()) + } +} +impl AsyncRead for ObservedIo { + fn poll_read( + mut self: Pin<&mut Self>, + c: &mut Context<'_>, + b: &mut ReadBuf<'_>, + ) -> Poll> { + Pin::new(&mut self.io).poll_read(c, b) + } +} +impl AsyncWrite for ObservedIo { + fn poll_write( + mut self: Pin<&mut Self>, + c: &mut Context<'_>, + b: &[u8], + ) -> Poll> { + Pin::new(&mut self.io).poll_write(c, b) + } + fn poll_flush(mut self: Pin<&mut Self>, c: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.io).poll_flush(c) + } + fn poll_shutdown(mut self: Pin<&mut Self>, c: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.io).poll_shutdown(c) + } +} diff --git a/ostool-server/tests/qemu_serial.rs b/ostool-server/tests/qemu_serial.rs new file mode 100644 index 00000000..e17749dd --- /dev/null +++ b/ostool-server/tests/qemu_serial.rs @@ -0,0 +1,289 @@ +//! Actual OVMF UART identity -> managed PTY -> WebSocket -> uploaded kernel. +#![cfg(target_os = "linux")] +#[path = "common/serial.rs"] +mod serial_fixture; +use futures_util::{SinkExt, StreamExt}; +use httpboot_protocol::{BootArch, BootFile, ImageFormat, LoaderAnnouncement, LoaderDeviceStatus}; +use ostool_serial::{PortLocator, SerialManager}; +use ostool_server::{session::SessionBootCommand, tftp::service::build_tftp_manager, *}; +use serial_fixture::PtyBackend; +use serialport::{SerialPort, TTYPort}; +use sha2::{Digest, Sha256}; +use std::{path::PathBuf, sync::Arc, time::Duration}; +use tokio::{ + net::{TcpListener, UnixStream}, + process::Command, + sync::mpsc, + time::Instant, +}; +use tokio_tungstenite::tungstenite::Message; + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +#[ignore = "requires OSTOOL_QEMU_TGOS and built axloader/ArceOS artifacts; run test-axloader-local.py"] +async fn ovmf_identity_binds_serial_null_session_and_boots_kernel() -> anyhow::Result<()> { + let tgos = PathBuf::from(std::env::var("OSTOOL_QEMU_TGOS")?); + let root = tempfile::tempdir()?; + let esp = root.path().join("esp/EFI/BOOT"); + tokio::fs::create_dir_all(&esp).await?; + tokio::fs::copy( + tgos.join("target/x86_64-unknown-uefi/release/axloader.efi"), + esp.join("BOOTX64.EFI"), + ) + .await?; + let vars = root.path().join("vars.fd"); + tokio::fs::copy("/usr/share/OVMF/OVMF_VARS_4M.fd", &vars).await?; + let kernel_path = std::env::var_os("OSTOOL_QEMU_KERNEL") + .map(PathBuf::from) + .unwrap_or_else(|| tgos.join("target/x86_64-unknown-linux-musl/release/arceos-helloworld")); + let kernel = tokio::fs::read(kernel_path).await?; + let forward = TcpListener::bind("127.0.0.1:0").await?; + let port = forward.local_addr()?.port(); + drop(forward); + let serial_socket = root.path().join("serial.sock"); + let mut qemu = Command::new("qemu-system-x86_64") + .args([ + "-m", "512M", "-smp", "1", "-machine", "q35", "-accel", "kvm", "-cpu", "host", + "-display", "none", "-monitor", "none", + ]) + .args([ + "-serial", + &format!("unix:{},server=on,wait=off", serial_socket.display()), + ]) + .args([ + "-netdev", + &format!("user,id=net0,hostfwd=tcp:127.0.0.1:{port}-:2999"), + ]) + .args([ + "-device", + "virtio-net-pci,netdev=net0,mac=02:00:00:00:00:42", + ]) + .args([ + "-drive", + "if=pflash,format=raw,readonly=on,file=/usr/share/OVMF/OVMF_CODE_4M.fd", + ]) + .args([ + "-drive", + &format!("if=pflash,format=raw,file={}", vars.display()), + ]) + .args([ + "-drive", + &format!( + "format=raw,file=fat:rw:{}", + root.path().join("esp").display() + ), + ]) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .kill_on_drop(true) + .spawn()?; + let limit = Instant::now() + Duration::from_secs(10); + let mut wire = loop { + match UnixStream::connect(&serial_socket).await { + Ok(wire) => break wire, + Err(e) => { + anyhow::ensure!(Instant::now() < limit, "QEMU serial socket: {e}"); + tokio::time::sleep(Duration::from_millis(20)).await; + } + } + }; + let (master, mut slave) = TTYPort::pair()?; + slave.set_exclusive(false)?; + let path = slave.name().unwrap(); + // Keep the PTY alive while discovery has not opened it yet. This descriptor + // never reads bytes or configures the UART. + let _keeper = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(&path)?; + drop(slave); + let mut master = tokio_serial::SerialStream::try_from(master)?; + let bridge = + tokio::spawn(async move { tokio::io::copy_bidirectional(&mut wire, &mut master).await }); + let (configured, mut settings) = mpsc::unbounded_channel(); + let config_path = root.path().join("server.toml"); + let mut config = ServerConfig::default_for_path(&config_path); + config.data_dir = root.path().join("data"); + config.board_dir = root.path().join("boards"); + config.dtb_dir = root.path().join("dtbs"); + config.tftp = TftpConfig::Builtin(BuiltinTftpConfig::default_with_root( + root.path().join("files"), + )); + let mut state = build_app_state( + config_path, + config.clone(), + build_tftp_manager(&config.tftp), + ) + .await?; + state.serial_manager = SerialManager::new(Arc::new(PtyBackend { + ports: vec![PortLocator { + name: path.clone(), + aliases: vec![], + serial_number: None, + }], + configured, + })); + let mac = "02:00:00:00:00:42".parse()?; + let board = BoardConfig { + id: "qemu-auto".into(), + board_type: "uefi".into(), + tags: vec![], + serial: None, + power_management: PowerManagementConfig::Custom(CustomPowerManagement { + power_on_cmd: "true".into(), + power_off_cmd: "true".into(), + }), + boot: BootConfig::UefiHttp(UefiHttpProfile { + boot_arch: Some(UefiBootArch::X86_64), + }), + network_identity: Some(BoardNetworkIdentity { mac_address: mac }), + notes: None, + disabled: false, + }; + state.boards.write().await.insert(board.id.clone(), board); + state.sync_board_runtime_states().await; + let listener = TcpListener::bind("127.0.0.1:0").await?; + let address = listener.local_addr()?; + let app = build_router(state.clone()); + let server = tokio::spawn(async move { axum::serve(listener, app).await }); + let created = state + .create_session_with_board_id("uefi", "qemu-auto", &[], None) + .await + .map_err(|error| anyhow::anyhow!("session allocation: {error:?}"))?; + let session = state.session_state(&created.id).await.unwrap(); + state + .tftp_manager + .read() + .await + .put_session_file(&created.id, "kernel.elf", &kernel) + .await?; + let initramfs = if let Some(file) = std::env::var_os("OSTOOL_QEMU_INITRAMFS") { + let bytes = tokio::fs::read(file).await?; + state + .tftp_manager + .read() + .await + .put_session_file(&created.id, "initramfs.cpio", &bytes) + .await?; + Some(BootFile { + path: format!("/boot/sessions/{}/initramfs.cpio", created.id), + size: bytes.len() as u64, + sha256: format!("{:x}", Sha256::digest(&bytes)), + }) + } else { + None + }; + let expect_initramfs = initramfs.is_some(); + session + .publish_boot_command(SessionBootCommand { + boot_id: "qemu-auto".into(), + kernel_path: format!("/boot/sessions/{}/kernel.elf", created.id), + kernel_size: kernel.len() as u64, + kernel_sha256: format!("{:x}", Sha256::digest(&kernel)), + arch: BootArch::X86_64, + image_format: ImageFormat::Elf64, + entry_symbol: None, + initramfs, + cmdline: Some("axloader.cmdline=ostool-local".into()), + }) + .await; + let (mut ws, _) = tokio_tungstenite::connect_async(format!( + "ws://{address}/api/v1/sessions/{}/serial/ws", + created.id + )) + .await?; + assert!(matches!(ws.next().await.unwrap()?,Message::Text(t) if t.contains("opened"))); + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(3)) + .build()?; + let deadline = Instant::now() + Duration::from_secs(60); + let observed: LoaderDeviceStatus = loop { + if let Ok(response) = client + .get(format!("http://127.0.0.1:{port}/api/v1/status")) + .send() + .await + && let Ok(status) = response.json::().await + { + break status; + } + anyhow::ensure!(Instant::now() < deadline, "OVMF device HTTP not ready"); + tokio::time::sleep(Duration::from_millis(100)).await; + }; + let uart = observed.serial.as_ref().unwrap(); + anyhow::ensure!(uart.ready, "OVMF UART unavailable: {:?}", uart.error); + let announcement = LoaderAnnouncement { + protocol_version: observed.protocol_version, + boot_epoch: observed.boot_epoch.clone(), + mac_address: observed.mac_address, + current_mac_address: observed.current_mac_address, + arch: observed.arch, + loader_version: observed.loader_version.clone(), + http_port: port, + serial_id: Some(uart.serial_id.clone()), + serial_ready: uart.ready, + }; + // The test maps guest TCP4 to hostfwd; UART bytes are forwarded unchanged. + let reconcile = + ostool_server::device::reconcile(state.clone(), announcement, "127.0.0.1:12345".parse()?); + let receive = async { + let mut output = Vec::new(); + loop { + match ws.next().await { + Some(Ok(Message::Binary(bytes))) => { + output.extend(bytes); + if String::from_utf8_lossy(&output).contains("Hello, world!") { + return Ok::<_, anyhow::Error>(output); + } + } + Some(Err(error)) => return Err(error.into()), + Some(Ok(_)) => {} + None => anyhow::bail!( + "WebSocket closed before kernel output: {}", + String::from_utf8_lossy(&output) + ), + } + } + }; + let (_, output) = tokio::time::timeout(Duration::from_secs(60), async { + tokio::try_join!(reconcile, receive) + }) + .await? + .map_err(|e| { + anyhow::anyhow!( + "{e:#}; runtime={:?}; manager={:?}; device={:?}", + session.serial_runtime.snapshot(), + *state.serial_manager.subscribe().borrow(), + observed.serial + ) + })?; + assert!( + String::from_utf8_lossy(&output).contains(&format!("AXLOADER-SERIAL/1 {}", uart.serial_id)) + ); + let text = String::from_utf8_lossy(&output); + assert!(text.contains("HOST_CMDLINE: axloader.cmdline=ostool-local")); + if expect_initramfs { + assert!(text.contains("HOST_INITRAMFS_PASSED")); + } + assert_eq!( + settings.recv().await.unwrap(), + (path, uart.parameters.unwrap()) + ); + assert_eq!( + session.serial_runtime.snapshot().phase, + ostool_server::serial::runtime::SerialRuntimePhase::Bound + ); + if let Some(artifacts) = std::env::var_os("OSTOOL_QEMU_ARTIFACTS") { + tokio::fs::write(PathBuf::from(artifacts).join("kernel-serial.log"), &output).await?; + } + ws.send(Message::Close(None)).await?; + drop(ws); + tokio::time::timeout( + Duration::from_secs(3), + state.serial_manager.wait_owner_released(&created.id), + ) + .await??; + qemu.kill().await?; + qemu.wait().await?; + bridge.abort(); + server.abort(); + Ok(()) +} diff --git a/ostool-server/webui/e2e/loader-binding.spec.ts b/ostool-server/webui/e2e/loader-binding.spec.ts index 713bcd17..3ff38107 100644 --- a/ostool-server/webui/e2e/loader-binding.spec.ts +++ b/ostool-server/webui/e2e/loader-binding.spec.ts @@ -69,6 +69,9 @@ test("unbound power -> real discovery -> manual MAC binding; push preserves focu await page.getByLabel("板型", { exact: true }).fill("qemu-x86_64"); await page.getByLabel("ID", { exact: true }).fill("e2e-board"); await page.getByLabel("启动方式").selectOption("httpboot"); + await expect(page.getByLabel("波特率", { exact: true })).toHaveCount(0); + await expect(page.getByLabel("启用串口")).toHaveCount(0); + await expect(page.getByText("自动串口", { exact: true })).toBeVisible(); const macInput = page.getByLabel("MAC 地址", { exact: true }); await macInput.fill("02:00:00:00:00:"); await macInput.focus(); @@ -109,6 +112,10 @@ test("unbound power -> real discovery -> manual MAC binding; push preserves focu await macInput.fill(mac); await page.getByRole("button", { name: "保存开发板" }).click(); await expect(page).toHaveURL(/boards\/e2e-board$/); + const saved = await ( + await request.get("http://127.0.0.1:4175/api/v1/admin/boards/e2e-board") + ).json(); + expect(saved.serial).toBeNull(); const peer = await context.newPage(); await peer.goto("/admin/boards"); await expect(peer.getByText(/1 份不兼容配置已备份隔离/)).toBeVisible(); diff --git a/ostool-server/webui/src/api/events.ts b/ostool-server/webui/src/api/events.ts index 453e9ed9..77f10444 100644 --- a/ostool-server/webui/src/api/events.ts +++ b/ostool-server/webui/src/api/events.ts @@ -39,6 +39,7 @@ export interface Resources { virtual: VirtualDevicesResponse; dtbs: DtbFileResponse[]; serial: SerialPortSummary[]; + serial_manager: { pending: number; candidates: number; leased: number }; network: NetworkInterfaceSummary[]; server: AdminServerConfigResponse; tftp: TftpConfig; diff --git a/ostool-server/webui/src/board-form.test.ts b/ostool-server/webui/src/board-form.test.ts index 1ea4df5b..4a75b7ac 100644 --- a/ostool-server/webui/src/board-form.test.ts +++ b/ostool-server/webui/src/board-form.test.ts @@ -8,6 +8,19 @@ import { } from "./board-form"; import type { BoardConfig } from "./types/api"; describe("board configuration contracts", () => { + it("hides manual serial in axloader payload and restores the U-Boot draft", () => { + const f = defaultFormState(); + f.serial_enabled = true; + f.serial_key_value = "manual"; + f.serial_baud_rate = 921600; + f.boot_kind = "httpboot"; + expect(buildRequestPayload(f).serial).toBeNull(); + f.boot_kind = "uboot"; + expect(buildRequestPayload(f).serial).toEqual({ + key: { kind: "serial_number", value: "manual" }, + baud_rate: 921600, + }); + }); it("allows power configuration before board identity and HTTPboot MAC exist", () => { const f = defaultFormState(); f.power_on_cmd = "on"; @@ -68,7 +81,7 @@ describe("board configuration contracts", () => { f.network_mac, ); }); - it("rejects QEMU mismatched serial ownership and missing HTTPboot MAC", () => { + it("derives QEMU serial from power configuration and still requires MAC", () => { const f = defaultFormState(); f.power_management_kind = "qemu"; f.virtual_device_id = "one"; @@ -76,7 +89,8 @@ describe("board configuration contracts", () => { f.serial_key_kind = "qemu"; f.serial_key_value = "other"; f.boot_kind = "httpboot"; - expect(validateForm(f)).toContain("同一个虚拟设备"); + expect(buildRequestPayload(f).serial).toBeNull(); + expect(validateForm(f)).not.toContain("串口"); expect(validateForm(f)).toContain("MAC"); }); }); diff --git a/ostool-server/webui/src/board-form.ts b/ostool-server/webui/src/board-form.ts index 0dda4270..2c34204c 100644 --- a/ostool-server/webui/src/board-form.ts +++ b/ostool-server/webui/src/board-form.ts @@ -208,15 +208,16 @@ export function buildRequestPayload( tags: splitTags(form.tags_text), notes: trimToNull(form.notes), disabled: form.disabled, - serial: form.serial_enabled - ? { - key: { - kind: form.serial_key_kind, - value: form.serial_key_value.trim(), - }, - baud_rate: form.serial_baud_rate, - } - : null, + serial: + form.boot_kind !== "httpboot" && form.serial_enabled + ? { + key: { + kind: form.serial_key_kind, + value: form.serial_key_value.trim(), + }, + baud_rate: form.serial_baud_rate, + } + : null, power_management: buildPowerManagementConfig(form), boot: buildBootConfig(form), network_identity: @@ -235,10 +236,15 @@ export function validateForm(form: BoardEditorFormState): string { if (form.id.includes("/") || form.id.includes("\\")) { errors.push("板子 ID 不能包含路径分隔符"); } - if (form.serial_enabled && !form.serial_key_value.trim()) { + if ( + form.boot_kind !== "httpboot" && + form.serial_enabled && + !form.serial_key_value.trim() + ) { errors.push("启用串口时必须选择串口设备"); } if ( + form.boot_kind !== "httpboot" && form.serial_enabled && (!Number.isFinite(form.serial_baud_rate) || form.serial_baud_rate <= 0) ) { @@ -262,11 +268,6 @@ export function validateForm(form: BoardEditorFormState): string { if (!form.virtual_device_id.trim()) { errors.push("QEMU 电源管理必须选择虚拟设备"); } - if (!form.serial_enabled || form.serial_key_kind !== "qemu") { - errors.push("QEMU 电源管理必须启用 QEMU 虚拟串口"); - } else if (form.serial_key_value.trim() !== form.virtual_device_id.trim()) { - errors.push("QEMU 电源和虚拟串口必须引用同一个虚拟设备"); - } if (form.boot_kind !== "httpboot") { errors.push("QEMU 虚拟设备必须使用 HTTPboot"); } diff --git a/ostool-server/webui/src/components/serial-runtime.tsx b/ostool-server/webui/src/components/serial-runtime.tsx new file mode 100644 index 00000000..89d8aca8 --- /dev/null +++ b/ostool-server/webui/src/components/serial-runtime.tsx @@ -0,0 +1,30 @@ +import type { SerialRuntimeStatus } from "@/types/api"; +const phases: Record = { + waiting_device: "等待设备", + verifying: "验证串口身份", + discovering: "发现串口", + bound: "串口已绑定", + recovering: "恢复串口绑定", + failed: "绑定失败", + closed: "已释放", +}; +export function SerialRuntimeDetails({ + status, +}: { + status?: SerialRuntimeStatus; +}) { + const p = status?.parameters; + return ( +
+

{status ? phases[status.phase] : "等待会话自动绑定"}

+ {status?.port &&

{status.port}

} + {p && ( +

+ {p.baud_rate} baud · {p.data_bits} 位 · 校验 {p.parity} · 停止位{" "} + {p.stop_bits} · 流控 {p.flow_control} +

+ )} + {status?.error &&

{status.error}

} +
+ ); +} diff --git a/ostool-server/webui/src/pages/BoardEditor.test.tsx b/ostool-server/webui/src/pages/BoardEditor.test.tsx index 126efb56..50bfafa8 100644 --- a/ostool-server/webui/src/pages/BoardEditor.test.tsx +++ b/ostool-server/webui/src/pages/BoardEditor.test.tsx @@ -75,7 +75,9 @@ it("only prefills the MAC and does not require board identity for a power comman const user = userEvent.setup(); expect(screen.getByLabelText("MAC 地址")).toHaveValue("02:00:00:00:00:21"); expect(screen.getByLabelText("板型")).toHaveValue(""); - expect(screen.getByRole("checkbox", { name: "启用串口" })).not.toBeChecked(); + expect( + screen.queryByRole("checkbox", { name: "启用串口" }), + ).not.toBeInTheDocument(); await user.type(screen.getByLabelText("上电命令"), "true"); await user.click(screen.getByRole("button", { name: "上电" })); expect(power).toHaveBeenCalledOnce(); @@ -152,7 +154,15 @@ it("does not overwrite a local draft when another client edits the board", async it("switches to U-Boot without requiring or submitting a stale MAC", async () => { const create = vi .spyOn(api, "createBoard") - .mockResolvedValue({} as BoardConfig); + .mockImplementation( + async (request) => + ({ + ...request, + id: "created", + tags: request.tags ?? [], + boot: request.boot, + }) as BoardConfig, + ); mount(); const user = userEvent.setup(); await user.clear(screen.getByLabelText("MAC 地址")); @@ -182,3 +192,47 @@ it("shows loader OTA when the HTTP Boot architecture is omitted", () => { expect(screen.getByText("axloader OTA")).toBeInTheDocument(); }); + +it("switches manual UART draft to axloader and preserves focus on serial SSE updates", async () => { + const board = httpBootBoard(); + board.boot = { + kind: "uboot", + use_tftp: false, + dtb_name: null, + kernel_load_addr: null, + fit_load_addr: null, + bootm_addr: null, + network_mode: "dhcp", + board_ip: null, + server_ip: null, + netmask: null, + gatewayip: null, + }; + board.serial = { + key: { kind: "usb_path", value: "/dev/serial/by-path/demo" }, + baud_rate: 57600, + }; + mount(board); + const user = userEvent.setup(); + expect(screen.getByLabelText("波特率")).toHaveValue(57600); + await user.selectOptions(screen.getByLabelText("启动方式"), "httpboot"); + expect(screen.queryByLabelText("波特率")).not.toBeInTheDocument(); + expect( + screen.queryByRole("checkbox", { name: "启用串口" }), + ).not.toBeInTheDocument(); + const input = screen.getByLabelText("备注"); + await user.type(input, "local draft"); + act(() => + admin.apply({ + epoch: "serial-state", + revision: 1, + kind: "snapshot", + data: { serial_manager: { pending: 2, candidates: 4, leased: 1 } }, + }), + ); + expect(screen.getByText("发现请求 2 · 候选监听 4")).toBeVisible(); + expect(input).toHaveFocus(); + expect(input).toHaveValue("local draft"); + await user.selectOptions(screen.getByLabelText("启动方式"), "uboot"); + expect(screen.getByLabelText("波特率")).toHaveValue(57600); +}); diff --git a/ostool-server/webui/src/pages/BoardEditor.tsx b/ostool-server/webui/src/pages/BoardEditor.tsx index f2febcf9..08cc1822 100644 --- a/ostool-server/webui/src/pages/BoardEditor.tsx +++ b/ostool-server/webui/src/pages/BoardEditor.tsx @@ -35,6 +35,7 @@ import { } from "@/components/forms"; import { MacPicker } from "@/components/mac-picker"; import { DtbUpload } from "./Dtbs"; +import { SerialRuntimeDetails } from "@/components/serial-runtime"; import { BoardOta } from "./BoardOta"; export default function BoardEditor() { @@ -73,7 +74,9 @@ function Editor({ board }: { board?: BoardConfig }) { loaders = useList("loaders"), virtual = useResource("virtual"), tftp = useResource("tftp_status"), - actions = useList("power_actions"); + actions = useList("power_actions"), + sessions = useList("sessions"), + serialManager = useResource("serial_manager"); const storageKey = `ostool-power:${board?.id ?? "new"}`; const [actionId, setActionId] = useState( () => sessionStorage.getItem(storageKey) ?? "", @@ -164,7 +167,17 @@ function Editor({ board }: { board?: BoardConfig }) { ? await api.updateBoard(board.id, payload) : await api.createBoard(payload); setPreviousSource(live); - setForm(boardToFormState(saved)); + setForm((draft) => ({ + ...boardToFormState(saved), + ...(saved.boot.kind === "httpboot" + ? { + serial_enabled: draft.serial_enabled, + serial_key_kind: draft.serial_key_kind, + serial_key_value: draft.serial_key_value, + serial_baud_rate: draft.serial_baud_rate, + } + : {}), + })); setBaseline(JSON.stringify(boardToFormState(saved))); navigate(`/boards/${encodeURIComponent(saved.id)}`); }, "已保存开发板"); @@ -247,35 +260,52 @@ function Editor({ board }: { board?: BoardConfig }) { onChange={(v) => set("disabled", v)} /> -
- set("serial_enabled", v)} - /> - {form.serial_enabled && ( - <> - chooseSerial(v)} - options={serialOptions()} - /> - set("serial_baud_rate", Number(v))} - /> - - - )} -
+ {form.boot_kind === "httpboot" ? ( +
+

+ axloader 上报实际参数,每次上电自动确认串口身份。 +

+ s.board_id === board?.id)?.serial_runtime + } + /> +

+ 发现请求 {serialManager?.pending ?? 0} · 候选监听{" "} + {serialManager?.candidates ?? 0} +

+
+ ) : ( +
+ set("serial_enabled", v)} + /> + {form.serial_enabled && ( + <> + chooseSerial(v)} + options={serialOptions()} + /> + set("serial_baud_rate", Number(v))} + /> + + + )} +
+ )}
{form.boot_kind !== "uboot" && ( diff --git a/ostool-server/webui/src/pages/Boards.tsx b/ostool-server/webui/src/pages/Boards.tsx index 9491f694..cceb3ce3 100644 --- a/ostool-server/webui/src/pages/Boards.tsx +++ b/ostool-server/webui/src/pages/Boards.tsx @@ -200,9 +200,11 @@ export default function Boards() { )} - {b.serial?.resolved_device_path ?? - b.serial?.key.value ?? - "无串口"} + {b.boot.kind === "httpboot" + ? "自动绑定" + : (b.serial?.resolved_device_path ?? + b.serial?.key.value ?? + "无串口")} {b.network_identity?.mac_address ?? "未绑定 MAC"} diff --git a/ostool-server/webui/src/pages/Sessions.tsx b/ostool-server/webui/src/pages/Sessions.tsx index f1f5e31c..7d0e0a3b 100644 --- a/ostool-server/webui/src/pages/Sessions.tsx +++ b/ostool-server/webui/src/pages/Sessions.tsx @@ -3,6 +3,7 @@ import { formatLeaseRemaining } from "@/utils/time"; import { useList, useResource } from "@/api/events"; import { api } from "@/api/client"; import { DataTable, Row, Cell, ConfirmAction } from "@/components/forms"; +import { SerialRuntimeDetails } from "@/components/serial-runtime"; import { Badge } from "@/components/ui/badge"; import { Link } from "react-router-dom"; export default function Sessions() { @@ -53,6 +54,10 @@ export default function Sessions() { ? "释放中" : "占用中"} + + WebSocket {s.serial_connected ? "已连接" : "未连接"} + + {runtimes[s.board_id]?.last_release_error} diff --git a/ostool-server/webui/src/types/api.ts b/ostool-server/webui/src/types/api.ts index 91446e71..dc9c469f 100644 --- a/ostool-server/webui/src/types/api.ts +++ b/ostool-server/webui/src/types/api.ts @@ -191,7 +191,15 @@ export interface LoaderUpdateJob { mac_address: string; update_id: string; image: LoaderImage; - phase: "queued" | "downloading" | "staged" | "confirming" | "succeeded" | "rolled_back" | "failed" | "cancelled"; + phase: + | "queued" + | "downloading" + | "staged" + | "confirming" + | "succeeded" + | "rolled_back" + | "failed" + | "cancelled"; error: string | null; delivery_attempts: number; } @@ -230,6 +238,8 @@ export interface DtbFileResponse { } export interface Session { + serial_connected?: boolean; + serial_runtime?: SerialRuntimeStatus; id: string; board_id: string; client_name: string | null; @@ -307,3 +317,25 @@ export interface TftpSessionResponse { writable: boolean; files: FileResponse[]; } + +export interface SerialRuntimeStatus { + phase: + | "waiting_device" + | "verifying" + | "discovering" + | "bound" + | "recovering" + | "failed" + | "closed"; + port: string | null; + parameters: { + baud_rate: number; + data_bits: number; + parity: string; + stop_bits: string; + flow_control: string; + } | null; + error: string | null; + binding_id: string | null; + boot_epoch: string | null; +} diff --git a/ostool/Cargo.toml b/ostool/Cargo.toml index 78ed66f6..9e0bca88 100644 --- a/ostool/Cargo.toml +++ b/ostool/Cargo.toml @@ -36,6 +36,7 @@ fitimage = { version = "0.1", path = "../fitimage" } fdt-edit = "0.2.3" futures = "0.3" httpboot-protocol = { workspace = true } +ostool-serial = { workspace = true } indicatif = "0.18" jkconfig = { version = "0.2", path = "../jkconfig", default-features = false } log = { workspace = true } diff --git a/ostool/src/axloader.rs b/ostool/src/axloader.rs new file mode 100644 index 00000000..81dd7623 --- /dev/null +++ b/ostool/src/axloader.rs @@ -0,0 +1,267 @@ +//! Direct axloader v6 control with the same serial discovery owner as the server. +use anyhow::{Context, ensure}; +use clap::Subcommand; +use httpboot_protocol::{ + DEVICE_PROTOCOL_VERSION, DeviceBootImage, DeviceBootJob, LoaderDeviceStatus, SerialBinding, + SerialBindingMode, +}; +use ostool_serial::{BindRequest, NativeBackend, SerialManager}; +use reqwest::Client; +use sha2::{Digest, Sha256}; +use std::{path::PathBuf, sync::Arc, time::Duration}; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + time::Instant, +}; + +#[derive(Debug, Subcommand)] +pub enum AxloaderCommand { + /// Automatically discover/configure the UART and boot a kernel through axloader. + Run { + #[arg(long)] + device: String, + #[arg(long)] + kernel: PathBuf, + #[arg(long)] + initramfs: Option, + #[arg(long)] + cmdline: Option, + }, + /// Explicitly allow direct network boot without a serial tunnel. + Continue { + #[arg(long)] + device: String, + }, +} +pub async fn execute(command: AxloaderCommand) -> anyhow::Result<()> { + let device = match &command { + AxloaderCommand::Run { device, .. } | AxloaderCommand::Continue { device } => { + device.trim_end_matches('/').to_string() + } + }; + let client = Client::builder() + .timeout(Duration::from_secs(300)) + .build()?; + let observed: LoaderDeviceStatus = client + .get(format!("{device}/api/v1/status")) + .timeout(Duration::from_secs(5)) + .send() + .await? + .error_for_status()? + .json() + .await?; + ensure!( + observed.protocol_version == DEVICE_PROTOCOL_VERSION, + "automatic serial requires axloader v6; upgrade this device" + ); + let serial = observed + .serial + .as_ref() + .context("device did not report serial status")?; + let mut binding = SerialBinding { + serial_id: serial.serial_id.clone(), + binding_id: format!("{:032x}", binding_nonce()), + mode: SerialBindingMode::Direct, + }; + let AxloaderCommand::Run { + kernel, + initramfs, + cmdline, + .. + } = command + else { + if let Some(current) = &serial.binding + && current.mode == SerialBindingMode::Direct + && current.serial_id == serial.serial_id + { + binding = current.clone(); + } + grant(&client, &device, &observed.boot_epoch, &binding).await?; + println!("Direct network continue accepted (no serial tunnel)"); + println!("X-Boot-Epoch: {}", observed.boot_epoch); + println!("X-Serial-Binding: {}", binding.binding_id); + return Ok(()); + }; + ensure!( + serial.ready, + "automatic serial unavailable: {:?}", + serial.error + ); + let parameters = serial + .parameters + .context("device UART parameters are unknown")?; + ostool_serial::validate_host_parameters(parameters)?; + let kernel = tokio::fs::read(kernel).await?; + let initramfs = match initramfs { + Some(path) => Some(tokio::fs::read(path).await?), + None => None, + }; + let manager = SerialManager::new(Arc::new(NativeBackend)); + let owner = binding.binding_id.clone(); + let mut lease = manager + .bind(BindRequest { + owner: owner.clone(), + generation: 1, + mac_address: observed.mac_address, + boot_epoch: observed.boot_epoch.clone(), + serial_id: binding.serial_id.clone(), + parameters, + deadline: Instant::now() + Duration::from_secs(60), + }) + .await?; + println!( + "Serial identity verified on {} with {:?}", + lease.locator().name, + parameters + ); + binding.mode = SerialBindingMode::Bound; + if let Err(error) = grant(&client, &device, &observed.boot_epoch, &binding).await { + drop(lease); + manager.wait_owner_released(&owner).await?; + return Err(error); + } + lease.confirm(); + let job = DeviceBootJob { + boot_id: format!("cli-{}", binding.binding_id), + arch: observed.arch, + image_format: httpboot_protocol::ImageFormat::Elf64, + kernel: image(&kernel), + initramfs: initramfs.as_deref().map(image), + cmdline, + entry_symbol: Some("__x86_64_efi_pe_entry".into()), + }; + let base = format!("{device}/api/v1/boot/jobs"); + let boot = async { + client + .post(&base) + .header("X-Boot-Epoch", &observed.boot_epoch) + .json(&job) + .send() + .await? + .error_for_status()?; + let mut files = vec![("kernel", kernel.as_slice())]; + if let Some(bytes) = initramfs.as_deref() { + files.push(("initramfs", bytes)); + } + for (kind, bytes) in files { + client + .put(format!("{base}/{}/{kind}", job.boot_id)) + .header("X-Boot-Epoch", &observed.boot_epoch) + .header("X-Image-Sha256", image(bytes).sha256) + .body(bytes.to_vec()) + .send() + .await? + .error_for_status()?; + } + client + .post(format!("{base}/{}/start", job.boot_id)) + .header("X-Boot-Epoch", &observed.boot_epoch) + .header("X-Serial-Binding", &binding.binding_id) + .send() + .await? + .error_for_status()?; + std::future::pending::>().await + }; + let console = async { + let mut bytes = [0; 4096]; + let mut stdout = tokio::io::stdout(); + // Reuse the terminal's nonblocking owner: a blocking stdin worker would + // keep the Tokio runtime alive after Ctrl-C while waiting for a newline. + #[cfg(unix)] + let mut stdin = { + use std::io::IsTerminal; + std::io::stdin() + .is_terminal() + .then(crate::sterm::Input::new) + .transpose()? + }; + #[cfg(not(unix))] + let mut stdin = tokio::io::stdin(); + #[cfg(not(unix))] + let mut input = [0; 4096]; + #[cfg(unix)] + let mut input_open = stdin.is_some(); + #[cfg(not(unix))] + let mut input_open = true; + loop { + let keyboard = async { + #[cfg(unix)] + return stdin + .as_mut() + .expect("input is enabled") + .next() + .await + .transpose(); + #[cfg(not(unix))] + { + let n = stdin.read(&mut input).await?; + Ok::<_, std::io::Error>((n > 0).then(|| input[..n].to_vec())) + } + }; + let n = tokio::select! { + result = lease.read(&mut bytes) => result?, + result = keyboard, if input_open => { + if let Some(input) = result? { + tokio::time::timeout(Duration::from_secs(1), lease.write_all(&input)).await.context("serial write timed out")??; + } else { + input_open = false; + } + continue; + } + }; + if n == 0 { + anyhow::bail!("serial closed"); + } + stdout.write_all(&bytes[..n]).await?; + stdout.flush().await?; + } + }; + let result = tokio::select! {result=boot=>result,result=console=>result,_=tokio::signal::ctrl_c()=>Ok(())}; + drop(lease); + manager.wait_owner_released(&owner).await?; + // Revocation is best effort: after handoff this endpoint no longer exists. + let _ = client + .delete(format!( + "{device}/api/v1/serial/bindings/{}", + binding.binding_id + )) + .header("X-Boot-Epoch", &observed.boot_epoch) + .timeout(Duration::from_secs(2)) + .send() + .await; + result +} +fn image(bytes: &[u8]) -> DeviceBootImage { + DeviceBootImage { + size: bytes.len() as u64, + sha256: Sha256::digest(bytes) + .iter() + .map(|b| format!("{b:02x}")) + .collect(), + } +} +fn binding_nonce() -> u128 { + // A process-local token is a correlation identifier, not an authentication secret. + use std::sync::atomic::{AtomicU64, Ordering}; + static SEQUENCE: AtomicU64 = AtomicU64::new(1); + let time = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock before epoch") + .as_nanos(); + time ^ ((std::process::id() as u128) << 64) ^ SEQUENCE.fetch_add(1, Ordering::Relaxed) as u128 +} +async fn grant( + client: &Client, + device: &str, + epoch: &str, + binding: &SerialBinding, +) -> anyhow::Result<()> { + client + .post(format!("{device}/api/v1/serial/continue")) + .header("X-Boot-Epoch", epoch) + .json(binding) + .send() + .await? + .error_for_status()?; + Ok(()) +} diff --git a/ostool/src/board/client.rs b/ostool/src/board/client.rs index fc238577..e765113a 100644 --- a/ostool/src/board/client.rs +++ b/ostool/src/board/client.rs @@ -166,6 +166,19 @@ pub struct SerialStatusResponse { pub port: Option, pub baud_rate: Option, pub ws_url: Option, + #[serde(default)] + pub runtime: Option, + #[serde(default)] + pub manager: ostool_serial::ManagerSnapshot, +} +#[derive(Debug, Clone, Deserialize)] +pub struct SerialRuntimeStatus { + pub phase: String, + pub port: Option, + pub parameters: Option, + pub boot_epoch: Option, + pub binding_id: Option, + pub error: Option, } #[derive(Debug, Clone, Deserialize)] diff --git a/ostool/src/lib.rs b/ostool/src/lib.rs index d0a5dee3..c795e895 100644 --- a/ostool/src/lib.rs +++ b/ostool/src/lib.rs @@ -94,3 +94,5 @@ pub mod variables; extern crate log; #[macro_use] extern crate anyhow; + +pub mod axloader; diff --git a/ostool/src/main.rs b/ostool/src/main.rs index d6dc2c60..f40fc706 100644 --- a/ostool/src/main.rs +++ b/ostool/src/main.rs @@ -32,6 +32,10 @@ struct Cli { #[derive(Subcommand, Debug)] enum SubCommands { + Axloader { + #[command(subcommand)] + command: ostool::axloader::AxloaderCommand, + }, Build { /// Path to the build configuration file #[arg(short, long)] @@ -341,6 +345,7 @@ async fn try_main() -> Result<()> { ) .await?; } + SubCommands::Axloader { command } => ostool::axloader::execute(command).await?, SubCommands::Run { command } => match command { RunSubCommands::Qemu(args) => { let RunQemuCommand { diff --git a/ostool/src/sterm/input.rs b/ostool/src/sterm/input.rs index 87e77478..9a56abe0 100644 --- a/ostool/src/sterm/input.rs +++ b/ostool/src/sterm/input.rs @@ -5,14 +5,14 @@ use std::{io, os::fd::OwnedFd}; use rustix::fs::{OFlags, fcntl_getfl, fcntl_setfl}; use tokio::io::unix::AsyncFd; -pub(super) struct Input { +pub(crate) struct Input { fd: AsyncFd, original_flags: OFlags, } impl Input { /// The terminal loop must be the sole stdin reader while this owner lives. - pub(super) fn new() -> io::Result { + pub(crate) fn new() -> io::Result { Self::from_fd(rustix::io::dup(io::stdin())?) } @@ -23,7 +23,7 @@ impl Input { Ok(Self { fd, original_flags }) } - pub(super) async fn next(&mut self) -> Option>> { + pub(crate) async fn next(&mut self) -> Option>> { loop { let mut ready = match self.fd.readable().await { Ok(ready) => ready, diff --git a/ostool/src/sterm/mod.rs b/ostool/src/sterm/mod.rs index 46c93bc8..36d77d06 100644 --- a/ostool/src/sterm/mod.rs +++ b/ostool/src/sterm/mod.rs @@ -27,7 +27,7 @@ use futures::StreamExt; #[cfg(unix)] mod input; #[cfg(unix)] -use input::Input; +pub(crate) use input::Input; #[cfg(not(unix))] type Input = EventStream; use tokio::{ From 4a3a66bf4ce77cedd1b0a658fa286b661ca3a501 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=91=A8=E7=9D=BF?= Date: Thu, 8 Oct 2026 14:04:25 +0800 Subject: [PATCH 02/14] chore(deps): update chacha20 --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5e465bfd..01b5c32a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -616,9 +616,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chacha20" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", "cpufeatures 0.3.0", From f70b5a1cc8fa6bf31328aefb684d606ef393eecb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=91=A8=E7=9D=BF?= Date: Thu, 8 Oct 2026 15:15:00 +0800 Subject: [PATCH 03/14] fix(ostool-server): keep serial handoff retryable --- ostool-server/src/device.rs | 11 ++- ostool-server/src/serial/runtime.rs | 67 +++++++++++---- ostool-server/tests/axloader_serial.rs | 108 ++++++++++++++++++++++++- 3 files changed, 168 insertions(+), 18 deletions(-) diff --git a/ostool-server/src/device.rs b/ostool-server/src/device.rs index 0323d3bd..46691212 100644 --- a/ostool-server/src/device.rs +++ b/ostool-server/src/device.rs @@ -189,6 +189,7 @@ pub async fn reconcile( if !session.serial_runtime.accepts_epoch(&observed.boot_epoch) { return Ok(()); } + let session_id = session.snapshot().await.id; let mut generation = session.subscribe_boot_generation(); let mut shutdown = session.subscribe_shutdown(); let operation = async { @@ -244,7 +245,15 @@ pub async fn reconcile( Ok(()) }; tokio::select! { - result = operation => { if let Err(error)=&result {session.serial_runtime.fail(format!("{error:#}"));} result }, + result = operation => { + if let Err(error) = &result { + log::warn!( + "axloader serial handoff for session `{}` did not complete: {error:#}; waiting for the next device broadcast", + session_id, + ); + } + result + }, _ = shutdown.wait_for(|s|*s) => Ok(()), _ = generation.changed() => Ok(()), } diff --git a/ostool-server/src/serial/runtime.rs b/ostool-server/src/serial/runtime.rs index 94bf35cb..55c72f08 100644 --- a/ostool-server/src/serial/runtime.rs +++ b/ostool-server/src/serial/runtime.rs @@ -3,7 +3,7 @@ use crate::{ session::{SessionState, SessionStopReason}, state::AppState, }; -use anyhow::{Context, ensure}; +use anyhow::Context; use httpboot_protocol::{LoaderDeviceStatus, SerialBinding, SerialBindingMode, SerialParameters}; use ostool_serial::{BindRequest, SerialLease}; use serde::{Deserialize, Serialize}; @@ -273,24 +273,44 @@ impl SessionSerialRuntime { continue; } power_wait = true; - let serial = device - .serial - .as_ref() - .context("device has no v6 serial parameters")?; - ensure!( - serial.ready, - "automatic serial unavailable: {:?}", - serial.error - ); - let parameters = serial - .parameters - .context("unknown device UART parameters")?; - parameters.validate().map_err(|e| anyhow::anyhow!("{e}"))?; + let Some(serial) = device.serial.as_ref() else { + self.reject_bind( + state, + &device, + reply, + anyhow::anyhow!("device has no v6 serial parameters"), + ); + continue; + }; + if !serial.ready { + self.reject_bind( + state, + &device, + reply, + anyhow::anyhow!("automatic serial unavailable: {:?}", serial.error), + ); + continue; + } + let Some(parameters) = serial.parameters else { + self.reject_bind( + state, + &device, + reply, + anyhow::anyhow!("unknown device UART parameters"), + ); + continue; + }; + if let Err(error) = parameters.validate() { + self.reject_bind(state, &device, reply, anyhow::anyhow!("{error}")); + continue; + } if !matches!( session.board().power_management, crate::config::PowerManagementConfig::Qemu { .. } - ) { - ostool_serial::validate_host_parameters(parameters)?; + ) && let Err(error) = ostool_serial::validate_host_parameters(parameters) + { + self.reject_bind(state, &device, reply, error.into()); + continue; } if let Some(c) = current.as_ref() && c.device.boot_epoch == device.boot_epoch @@ -437,6 +457,21 @@ impl SessionSerialRuntime { } } } + fn reject_bind( + &self, + state: &AppState, + device: &LoaderDeviceStatus, + reply: oneshot::Sender>, + error: anyhow::Error, + ) { + let mut status = self.snapshot(); + status.phase = SerialRuntimePhase::Recovering; + status.boot_epoch = Some(device.boot_epoch.clone()); + status.parameters = device.serial.as_ref().and_then(|serial| serial.parameters); + status.error = Some(format!("{error:#}")); + self.publish(state, status); + let _ = reply.send(Err(error)); + } fn publish(&self, state: &AppState, status: SerialRuntimeStatus) { self.status.send_replace(status); state.admin_events.invalidate(&["sessions"]); diff --git a/ostool-server/tests/axloader_serial.rs b/ostool-server/tests/axloader_serial.rs index af27670d..d1409436 100644 --- a/ostool-server/tests/axloader_serial.rs +++ b/ostool-server/tests/axloader_serial.rs @@ -40,6 +40,7 @@ struct Device { status: LoaderDeviceStatus, output: tokio_serial::SerialStream, starts: usize, + reject_start: bool, } type DeviceState = Arc>; async fn status(State(d): State) -> Json { @@ -71,6 +72,9 @@ async fn start(State(d): State, h: HeaderMap) -> StatusCode { { return StatusCode::CONFLICT; } + if d.reject_start { + return StatusCode::CONFLICT; + } d.starts += 1; d.output .write_all(b"KERNEL_OUTPUT_AFTER_CONTINUE\n") @@ -233,6 +237,7 @@ async fn serial_null_session_binds_reuses_uart_and_releases_real_reader() { }, output: tokio_serial::SerialStream::try_from(master).unwrap(), starts: 0, + reject_start: false, })); let fake = Router::new() .route("/api/v1/status", get(status)) @@ -285,6 +290,33 @@ async fn serial_null_session_binds_reuses_uart_and_releases_real_reader() { assert!(Instant::now() < limit); tokio::task::yield_now().await; } + { + let mut d = device.lock().await; + d.status.boot_epoch = format!("{:032x}", 0); + let mut serial = serial_status(0, 57600); + serial.ready = false; + serial.error = Some("UART is not ready".into()); + d.status.serial = Some(serial); + } + let not_ready = LoaderAnnouncement { + protocol_version: DEVICE_PROTOCOL_VERSION, + mac_address: mac, + current_mac_address: mac, + arch: BootArch::X86_64, + loader_version: "test-v6".into(), + boot_epoch: format!("{:032x}", 0), + http_port: device_port, + serial_id: Some(format!("{:032x}", 0)), + serial_ready: false, + }; + let error = ostool_server::device::reconcile( + state.clone(), + not_ready, + "127.0.0.1:12345".parse().unwrap(), + ) + .await + .unwrap_err(); + assert!(format!("{error:#}").contains("automatic serial unavailable")); for (n, baud) in [(1, 57600), (2, 115200)] { if n == 2 { state @@ -350,7 +382,81 @@ async fn serial_null_session_binds_reuses_uart_and_releases_real_reader() { .await; assert_eq!(u64::from(actual_baud(&inspection)), baud); } - assert_eq!(device.lock().await.starts, 2); + { + let mut d = device.lock().await; + d.status.boot_epoch = format!("{:032x}", 3); + d.status.serial = Some(serial_status(3, 230400)); + d.reject_start = true; + } + let failed_announcement = LoaderAnnouncement { + protocol_version: DEVICE_PROTOCOL_VERSION, + mac_address: mac, + current_mac_address: mac, + arch: BootArch::X86_64, + loader_version: "test-v6".into(), + boot_epoch: format!("{:032x}", 3), + http_port: device_port, + serial_id: Some(format!("{:032x}", 3)), + serial_ready: true, + }; + let failed_reconcile = tokio::spawn(ostool_server::device::reconcile( + state.clone(), + failed_announcement, + "127.0.0.1:12345".parse().unwrap(), + )); + wait_config(&mut configs, &path, 230400).await; + device + .lock() + .await + .output + .write_all(format!("\r\nAXLOADER-SERIAL/1 {:032x}\r\n", 3).as_bytes()) + .await + .unwrap(); + let error = tokio::time::timeout(Duration::from_secs(4), failed_reconcile) + .await + .unwrap() + .unwrap() + .unwrap_err(); + assert!(format!("{error:#}").contains("device HTTP 409 Conflict")); + + // A device-side handoff failure must leave the session runtime available + // for the next broadcast instead of closing the session. + { + let mut d = device.lock().await; + d.status.boot_epoch = format!("{:032x}", 4); + d.status.serial = Some(serial_status(4, 230400)); + d.reject_start = false; + } + let retry_announcement = LoaderAnnouncement { + protocol_version: DEVICE_PROTOCOL_VERSION, + mac_address: mac, + current_mac_address: mac, + arch: BootArch::X86_64, + loader_version: "test-v6".into(), + boot_epoch: format!("{:032x}", 4), + http_port: device_port, + serial_id: Some(format!("{:032x}", 4)), + serial_ready: true, + }; + let retry = tokio::spawn(ostool_server::device::reconcile( + state.clone(), + retry_announcement, + "127.0.0.1:12345".parse().unwrap(), + )); + wait_config(&mut configs, &path, 230400).await; + device + .lock() + .await + .output + .write_all(format!("\r\nAXLOADER-SERIAL/1 {:032x}\r\n", 4).as_bytes()) + .await + .unwrap(); + tokio::time::timeout(Duration::from_secs(4), retry) + .await + .unwrap() + .unwrap() + .unwrap(); + assert_eq!(device.lock().await.starts, 3); while let Ok((port, _)) = configs.try_recv() { assert_ne!(port, relay_path); assert_ne!(port, path, "bound UART must not reopen on second boot"); From 50b51bfe70695079188217c64fb5aaf8cf22c0c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=91=A8=E7=9D=BF?= Date: Thu, 8 Oct 2026 16:06:30 +0800 Subject: [PATCH 04/14] fix(ostool-server): remove deprecated atomic update calls --- ostool-server/src/network_test.rs | 2 +- ostool-server/src/state.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ostool-server/src/network_test.rs b/ostool-server/src/network_test.rs index e5677c16..acaeedb6 100644 --- a/ostool-server/src/network_test.rs +++ b/ostool-server/src/network_test.rs @@ -292,7 +292,7 @@ impl TransferGuard { } let counter = self.record.bytes(self.direction); counter - .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| { + .try_update(Ordering::Relaxed, Ordering::Relaxed, |current| { current.checked_add(count as u64) }) .map(|_| ()) diff --git a/ostool-server/src/state.rs b/ostool-server/src/state.rs index b28cb4ac..8b61f1c4 100644 --- a/ostool-server/src/state.rs +++ b/ostool-server/src/state.rs @@ -1040,7 +1040,7 @@ mod tests { async fn remove_session_dir(&self, _session_id: &str) -> anyhow::Result<()> { if let Some(failures_remaining) = &self.failures_remaining { if failures_remaining - .fetch_update(Ordering::AcqRel, Ordering::Acquire, |remaining| { + .try_update(Ordering::AcqRel, Ordering::Acquire, |remaining| { remaining.checked_sub(1) }) .is_err() From 84e7848a0ddee073e3209a77db5cfe1c8db0363a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=91=A8=E7=9D=BF?= Date: Thu, 8 Oct 2026 18:26:59 +0800 Subject: [PATCH 05/14] feat(ostool-server): persist axloader serial overrides --- docs/admin-ui.md | 4 +- docs/api.md | 4 +- docs/axloader-serial-ownership.md | 4 +- httpboot-protocol/src/serial.rs | 28 ++++ ostool-server/src/api/router.rs | 15 +- ostool-server/src/board_store/fs.rs | 5 +- ostool-server/src/config.rs | 156 +++++++++++++++++- ostool-server/src/device.rs | 1 + ostool-server/src/lib.rs | 11 +- ostool-server/src/serial/runtime.rs | 95 ++++++++++- ostool-server/tests/axloader_serial.rs | 15 +- ostool-server/tests/qemu_serial.rs | 1 + ostool-server/webui/src/board-form.test.ts | 35 ++++ ostool-server/webui/src/board-form.ts | 41 +++++ .../webui/src/components/serial-runtime.tsx | 1 + .../webui/src/pages/BoardEditor.test.tsx | 43 +++-- ostool-server/webui/src/pages/BoardEditor.tsx | 79 +++++++++ ostool-server/webui/src/types/api.ts | 14 ++ 18 files changed, 511 insertions(+), 41 deletions(-) diff --git a/docs/admin-ui.md b/docs/admin-ui.md index 26b96315..29840641 100644 --- a/docs/admin-ui.md +++ b/docs/admin-ui.md @@ -11,8 +11,8 @@ 选择发现设备不会自动填写板型、电源或串口,保存前不会生成半成品板卡。 QEMU 使用已有虚拟设备,保留原来的真实网络发现与匹配校验。 -选择 axloader 后,`BoardEditor` 隐藏手动串口开关、SN/path 和线参数,保存 `serial: null`。 -切回 U-Boot 保留编辑中的手动串口草稿。实际参数、端口、错误和 `SerialRuntimeStatus` +选择 axloader 后,`BoardEditor` 隐藏手动串口开关、SN/path;可选启用“指定串口参数”并持久化保存,未启用时不配置覆盖,保存 `serial: null`。 +指定参数优先于 axloader 上报,未指定时使用上报值或固件默认 115200/8N1。切回 U-Boot 保留编辑中的手动串口草稿。实际参数、端口、错误和 `SerialRuntimeStatus` 由会话的 SSE 展示;`serial_manager` 展示发现请求数与候选监听数。没有黑名单界面。 继电器配置继续保留,server 在发现前排除其所有路径别名和 SN。 diff --git a/docs/api.md b/docs/api.md index a1d4e5ff..92da9086 100644 --- a/docs/api.md +++ b/docs/api.md @@ -376,7 +376,7 @@ Content-Type: application/json 启用内建虚拟板时也可使用 `{"kind":"qemu","virtual_device_id":"..."}`。此时 axloader 配置使用 `serial: null`,串口 provider 从电源取得,并配置相同虚拟设备的 MAC。 -- `boot.kind` 可为上例的 `uboot`、`{"kind":"pxe","notes":null}`,或 `{"kind":"httpboot","boot_arch":"aarch64"}`。`boot_arch` 可为 `x86_64`、`aarch64`、`loongarch64`、`riscv64` 或 `other`。 +- `boot.kind` 可为上例的 `uboot`、`{"kind":"pxe","notes":null}`,或 `{"kind":"httpboot","boot_arch":"aarch64","serial_parameters":null}`。`boot_arch` 可为 `x86_64`、`aarch64`、`loongarch64`、`riscv64` 或 `other`。`httpboot.serial_parameters` 是可选的宿主串口覆盖,包含 `baud_rate`、`data_bits`(7/8)、`parity`(`none`/`odd`/`even`/`mark`/`space`)、`stop_bits`(`one`/`one_point_five`/`two`)和 `flow_control`(`none`/`rts_cts`);配置后优先于 axloader 本次上报,省略或为 `null` 时采用上报值,固件无法提供时采用 115200/8N1/无流控。覆盖必须与实际串口输出一致,否则身份帧无法绑定。 - `httpboot` 板卡必须提供 `network_identity: {"mac_address":"02:00:00:00:00:01"}`。MAC 会规范化为小写六字节冒号格式并在全部板卡配置中保持唯一;重复绑定返回 `409` 和错误码 `mac_already_bound`。`board_type` 始终由管理员填写,不根据 SMBIOS 或架构推断。 - U-Boot `network_mode` 可为 `dhcp` 或 `static_ip`。未启用 TFTP 或使用 DHCP 时服务端清除静态网络字段;使用 `static_ip` 时 `board_ip` 必填,所有已提供的网络字段必须是 IPv4 地址。`dtb_name` 必须符合单层 DTB 文件名格式,但创建或更新开发板时不会检查对应文件是否已经上传。 @@ -897,7 +897,7 @@ GET /api/v1/sessions/{session_id}/serial } ``` -非 axloader 模式没有串口时,`available` 和 `connected` 为 `false`,`port`、`baud_rate`、`ws_url` 均为 `null`。axloader 连接后 `connected` 只表示 WebSocket;新增 `runtime` 包含 phase、port、parameters、boot_epoch、binding_id、error,`manager` 包含 pending、candidates、leased。实际绑定前 port/baud_rate 为空。配置了串口但服务端无法把稳定标识解析为当前设备路径时返回 `503 Service Unavailable`。 +非 axloader 模式没有串口时,`available` 和 `connected` 为 `false`,`port`、`baud_rate`、`ws_url` 均为 `null`。axloader 连接后 `connected` 只表示 WebSocket;新增 `runtime` 包含 phase、port、parameters、boot_epoch、binding_id、warning、error,`manager` 包含 pending、candidates、leased。`warning` 用于固件参数回退等非致命诊断,`error` 表示当前失败。实际绑定前 port/baud_rate 为空。配置了串口但服务端无法把稳定标识解析为当前设备路径时返回 `503 Service Unavailable`。 ### 获取 TFTP 状态 diff --git a/docs/axloader-serial-ownership.md b/docs/axloader-serial-ownership.md index 658e6166..fad13d6a 100644 --- a/docs/axloader-serial-ownership.md +++ b/docs/axloader-serial-ownership.md @@ -6,7 +6,7 @@ ### 1.1 设备参数 -`SerialBeacon` 从 `ConOut` 对应的唯一 `SerialIo` 读取生效参数。未知参数、多个匹配 UART、没有协议和宿主后端不能表示的参数均产生明确错误。服务器不猜测 115200,也不恢复手工串口字段。 +`SerialBeacon` 从 `ConOut` 对应的唯一 `SerialIo` 读取生效参数。固件无法读取某个参数时使用 UEFI 常见默认值 115200/8N1、无硬件流控,并在状态中保留诊断;无法唯一选择 UART、没有协议或宿主后端不能表示的参数仍明确失败。Web UI 可选保存一组宿主侧参数覆盖,覆盖优先级高于本次 axloader 上报;默认不配置。 | 字段 | 含义与约束 | | --- | --- | @@ -80,7 +80,7 @@ sequenceDiagram ### 3.2 配置和命令 -axloader 表单隐藏串口开关、SN/path 和线参数,保存 `serial: null`;编辑时切回 U-Boot 保留手动字段草稿。QEMU 的 provider 来自电源配置的虚拟设备 ID;仍必须解析实际身份帧。U-Boot 保留原手动串口和启动步骤。 +axloader 表单隐藏串口开关和 SN/path,保存 `serial: null`;“指定串口参数”是可选的持久化覆盖,未配置时使用本次上报或固件默认值。编辑时切回 U-Boot 保留手动字段草稿。QEMU 的 provider 来自电源配置的虚拟设备 ID;仍必须解析实际身份帧。U-Boot 保留原手动串口和启动步骤。 ```bash ostool axloader run --device http://DEVICE:2999 --kernel kernel.elf \ diff --git a/httpboot-protocol/src/serial.rs b/httpboot-protocol/src/serial.rs index 0fa55f67..a8cae526 100644 --- a/httpboot-protocol/src/serial.rs +++ b/httpboot-protocol/src/serial.rs @@ -16,6 +16,19 @@ pub struct SerialParameters { pub flow_control: SerialFlowControl, } +impl Default for SerialParameters { + /// The conventional UART profile used when firmware cannot report one. + fn default() -> Self { + Self { + baud_rate: 115_200, + data_bits: 8, + parity: SerialParity::None, + stop_bits: SerialStopBits::One, + flow_control: SerialFlowControl::None, + } + } +} + impl SerialParameters { /// Reject unknown parameters and formats that cannot carry the ASCII beacon. pub fn validate(self) -> Result<(), SerialProtocolError> { @@ -198,6 +211,21 @@ impl SerialFrameDecoder { #[cfg(all(test, feature = "alloc"))] mod tests { use super::*; + + #[test] + fn serial_defaults_are_conventional_uart_settings() { + assert_eq!( + SerialParameters::default(), + SerialParameters { + baud_rate: 115_200, + data_bits: 8, + parity: SerialParity::None, + stop_bits: SerialStopBits::One, + flow_control: SerialFlowControl::None, + } + ); + } + #[test] fn identification_and_grants_reject_stale_owners() { let id = "0123456789abcdef0123456789abcdef"; diff --git a/ostool-server/src/api/router.rs b/ostool-server/src/api/router.rs index 42e436bc..4e8443e2 100644 --- a/ostool-server/src/api/router.rs +++ b/ostool-server/src/api/router.rs @@ -1119,7 +1119,13 @@ fn normalize_boot_config(boot: &mut BootConfig) -> Result<(), ApiError> { BootConfig::Pxe(profile) => { normalize_optional_string(&mut profile.notes); } - BootConfig::UefiHttp(_) => {} + BootConfig::UefiHttp(profile) => { + if let Some(parameters) = profile.serial_parameters { + parameters + .validate() + .map_err(|err| ApiError::bad_request(format!("{err:#}")))?; + } + } } Ok(()) } @@ -3246,6 +3252,7 @@ mod tests { board.tags = vec!["uefi-http".into()]; board.boot = BootConfig::UefiHttp(UefiHttpProfile { boot_arch: Some(UefiBootArch::X86_64), + serial_parameters: None, }); board.network_identity = Some(crate::config::BoardNetworkIdentity { mac_address: "02:00:00:00:00:01".parse().unwrap(), @@ -5955,7 +5962,10 @@ mod tests { async fn v4_upgrade_routes_require_a_board_assignment_and_matching_trial() { let (app, state) = test_router_and_state_with_config(|_| {}).await; let mut board = sample_httpboot_board("ota-board"); - board.boot = BootConfig::UefiHttp(UefiHttpProfile { boot_arch: None }); + board.boot = BootConfig::UefiHttp(UefiHttpProfile { + boot_arch: None, + serial_parameters: None, + }); let mac = board.network_identity.as_ref().unwrap().mac_address; assert_eq!( create_board(&app, serde_json::to_value(&board).unwrap()).await, @@ -5983,6 +5993,7 @@ mod tests { arm_board.serial = None; arm_board.boot = BootConfig::UefiHttp(UefiHttpProfile { boot_arch: Some(UefiBootArch::Aarch64), + serial_parameters: None, }); arm_board.network_identity = Some(crate::config::BoardNetworkIdentity { mac_address: "02:00:00:00:00:02".parse().unwrap(), diff --git a/ostool-server/src/board_store/fs.rs b/ostool-server/src/board_store/fs.rs index aa4341e4..c13a786c 100644 --- a/ostool-server/src/board_store/fs.rs +++ b/ostool-server/src/board_store/fs.rs @@ -246,7 +246,10 @@ mod tests { let store = FileBoardStore::new(dir.path().to_path_buf()); store.write_board(&valid_board("valid")).await.unwrap(); let mut missing_mac = valid_board("old-http"); - missing_mac.boot = BootConfig::UefiHttp(crate::config::UefiHttpProfile { boot_arch: None }); + missing_mac.boot = BootConfig::UefiHttp(crate::config::UefiHttpProfile { + boot_arch: None, + serial_parameters: None, + }); store.write_board(&missing_mac).await.unwrap(); let mut first = valid_board("a"); first.network_identity = Some(crate::config::BoardNetworkIdentity { diff --git a/ostool-server/src/config.rs b/ostool-server/src/config.rs index db1976a4..3986b54c 100644 --- a/ostool-server/src/config.rs +++ b/ostool-server/src/config.rs @@ -544,6 +544,11 @@ impl BoardConfig { if matches!(self.boot, BootConfig::UefiHttp(_)) && self.network_identity.is_none() { anyhow::bail!("network_identity.mac_address is required for httpboot boards"); } + if let BootConfig::UefiHttp(profile) = &self.boot + && let Some(parameters) = profile.serial_parameters + { + parameters.validate()?; + } if let PowerManagementConfig::Qemu { virtual_device_id } = &self.power_management { if !matches!(self.boot, BootConfig::UefiHttp(_)) { anyhow::bail!("QEMU boards must use httpboot"); @@ -713,6 +718,106 @@ pub enum UefiBootArch { pub struct UefiHttpProfile { #[serde(default)] pub boot_arch: Option, + /// Optional host-side UART settings for axloader serial discovery. + /// + /// When omitted, the server uses the parameters reported by axloader (or + /// its protocol fallback). This setting is intentionally separate from + /// [`BoardConfig::serial`], which is retained for U-Boot/PXE consoles. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub serial_parameters: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +pub struct AxloaderSerialParameters { + pub baud_rate: u64, + pub data_bits: u8, + pub parity: AxloaderSerialParity, + pub stop_bits: AxloaderSerialStopBits, + pub flow_control: AxloaderSerialFlowControl, +} + +impl AxloaderSerialParameters { + pub fn validate(self) -> anyhow::Result<()> { + if self.baud_rate == 0 { + anyhow::bail!("boot.serial_parameters.baud_rate must be greater than 0"); + } + if !matches!(self.data_bits, 7 | 8) { + anyhow::bail!("boot.serial_parameters.data_bits must be 7 or 8"); + } + Ok(()) + } + + pub fn into_protocol(self) -> httpboot_protocol::SerialParameters { + httpboot_protocol::SerialParameters { + baud_rate: self.baud_rate, + data_bits: self.data_bits, + parity: self.parity.into(), + stop_bits: self.stop_bits.into(), + flow_control: self.flow_control.into(), + } + } +} + +impl From for httpboot_protocol::SerialParameters { + fn from(value: AxloaderSerialParameters) -> Self { + value.into_protocol() + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum AxloaderSerialParity { + None, + Odd, + Even, + Mark, + Space, +} + +impl From for httpboot_protocol::SerialParity { + fn from(value: AxloaderSerialParity) -> Self { + match value { + AxloaderSerialParity::None => Self::None, + AxloaderSerialParity::Odd => Self::Odd, + AxloaderSerialParity::Even => Self::Even, + AxloaderSerialParity::Mark => Self::Mark, + AxloaderSerialParity::Space => Self::Space, + } + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum AxloaderSerialStopBits { + One, + OnePointFive, + Two, +} + +impl From for httpboot_protocol::SerialStopBits { + fn from(value: AxloaderSerialStopBits) -> Self { + match value { + AxloaderSerialStopBits::One => Self::One, + AxloaderSerialStopBits::OnePointFive => Self::OnePointFive, + AxloaderSerialStopBits::Two => Self::Two, + } + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum AxloaderSerialFlowControl { + None, + RtsCts, +} + +impl From for httpboot_protocol::SerialFlowControl { + fn from(value: AxloaderSerialFlowControl) -> Self { + match value { + AxloaderSerialFlowControl::None => Self::None, + AxloaderSerialFlowControl::RtsCts => Self::RtsCts, + } + } } #[cfg(test)] @@ -726,9 +831,11 @@ mod tests { use tempfile::tempdir; use super::{ - BoardConfig, BoardNetworkIdentity, BootConfig, CustomPowerManagement, - PowerManagementConfig, SerialPortKey, SerialPortKeyKind, ServerConfig, UbootNetworkMode, - UbootProfile, UefiBootArch, UefiHttpProfile, ZhongshengRelayPowerManagement, + AxloaderSerialFlowControl, AxloaderSerialParameters, AxloaderSerialParity, + AxloaderSerialStopBits, BoardConfig, BoardNetworkIdentity, BootConfig, + CustomPowerManagement, PowerManagementConfig, SerialPortKey, SerialPortKeyKind, + ServerConfig, UbootNetworkMode, UbootProfile, UefiBootArch, UefiHttpProfile, + ZhongshengRelayPowerManagement, }; #[test] @@ -1088,6 +1195,7 @@ bootm_addr = "0x82200000" }), boot: BootConfig::UefiHttp(UefiHttpProfile { boot_arch: Some(UefiBootArch::X86_64), + serial_parameters: None, }), network_identity: Some(BoardNetworkIdentity { mac_address: "02:00:00:00:00:01".parse().unwrap(), @@ -1105,12 +1213,52 @@ bootm_addr = "0x82200000" panic!("expected httpboot"); }; assert_eq!(profile.boot_arch, Some(UefiBootArch::X86_64)); + assert_eq!(profile.serial_parameters, None); assert_eq!( decoded.network_identity.unwrap().mac_address.to_string(), "02:00:00:00:00:01" ); } + #[test] + fn httpboot_serial_parameters_are_optional_and_round_trip() { + let parameters = AxloaderSerialParameters { + baud_rate: 921_600, + data_bits: 8, + parity: AxloaderSerialParity::Even, + stop_bits: AxloaderSerialStopBits::Two, + flow_control: AxloaderSerialFlowControl::RtsCts, + }; + let board = BoardConfig { + id: "uefi-http-serial".into(), + board_type: "x86_64-uefi-http".into(), + tags: vec![], + serial: None, + power_management: PowerManagementConfig::Custom(CustomPowerManagement { + power_on_cmd: "true".into(), + power_off_cmd: "true".into(), + }), + boot: BootConfig::UefiHttp(UefiHttpProfile { + boot_arch: Some(UefiBootArch::X86_64), + serial_parameters: Some(parameters), + }), + network_identity: Some(BoardNetworkIdentity { + mac_address: "02:00:00:00:00:01".parse().unwrap(), + }), + notes: None, + disabled: false, + }; + + let encoded = toml::to_string(&board).unwrap(); + assert!(encoded.contains("serial_parameters")); + let decoded: BoardConfig = toml::from_str(&encoded).unwrap(); + let BootConfig::UefiHttp(profile) = decoded.boot else { + panic!("expected httpboot"); + }; + assert_eq!(profile.serial_parameters, Some(parameters)); + assert_eq!(parameters.into_protocol().baud_rate, 921_600); + } + #[test] fn httpboot_board_requires_persistent_network_identity() { let mut board = BoardConfig { @@ -1124,6 +1272,7 @@ bootm_addr = "0x82200000" }), boot: BootConfig::UefiHttp(UefiHttpProfile { boot_arch: Some(UefiBootArch::X86_64), + serial_parameters: None, }), network_identity: None, notes: None, @@ -1175,6 +1324,7 @@ bootm_addr = "0x82200000" ); board.boot = BootConfig::UefiHttp(UefiHttpProfile { boot_arch: Some(UefiBootArch::X86_64), + serial_parameters: None, }); board.validate().unwrap(); board.serial = None; diff --git a/ostool-server/src/device.rs b/ostool-server/src/device.rs index 46691212..7f1ced73 100644 --- a/ostool-server/src/device.rs +++ b/ostool-server/src/device.rs @@ -835,6 +835,7 @@ mod tests { }), boot: BootConfig::UefiHttp(UefiHttpProfile { boot_arch: Some(UefiBootArch::X86_64), + serial_parameters: None, }), network_identity: Some(BoardNetworkIdentity { mac_address: mac }), notes: None, diff --git a/ostool-server/src/lib.rs b/ostool-server/src/lib.rs index fd24be43..3b40fa05 100644 --- a/ostool-server/src/lib.rs +++ b/ostool-server/src/lib.rs @@ -25,11 +25,12 @@ pub mod web; pub use api::router::build_router; pub use config::{ - BoardConfig, BoardNetworkIdentity, BootConfig, BuiltinTftpConfig, CustomPowerManagement, - LoaderNetworkConfig, PowerManagementConfig, PxeProfile, SerialConfig, SerialPortKey, - SerialPortKeyKind, ServerConfig, SystemTftpdHpaConfig, TftpConfig, TftpNetworkConfig, - UbootNetworkMode, UbootProfile, UefiBootArch, UefiHttpProfile, UploadLimitsConfig, - VirtualQemuConfig, ZhongshengRelayPowerManagement, + AxloaderSerialFlowControl, AxloaderSerialParameters, AxloaderSerialParity, + AxloaderSerialStopBits, BoardConfig, BoardNetworkIdentity, BootConfig, BuiltinTftpConfig, + CustomPowerManagement, LoaderNetworkConfig, PowerManagementConfig, PxeProfile, SerialConfig, + SerialPortKey, SerialPortKeyKind, ServerConfig, SystemTftpdHpaConfig, TftpConfig, + TftpNetworkConfig, UbootNetworkMode, UbootProfile, UefiBootArch, UefiHttpProfile, + UploadLimitsConfig, VirtualQemuConfig, ZhongshengRelayPowerManagement, }; pub use dtb_store::{DtbFile, DtbStore}; pub use state::{AppState, BoardLeaseState, build_app_state}; diff --git a/ostool-server/src/serial/runtime.rs b/ostool-server/src/serial/runtime.rs index 55c72f08..73430f1e 100644 --- a/ostool-server/src/serial/runtime.rs +++ b/ostool-server/src/serial/runtime.rs @@ -1,5 +1,6 @@ //! Session-owned UART runtime. Only this task reads a transferred lease. use crate::{ + config::{AxloaderSerialParameters, BootConfig}, session::{SessionState, SessionStopReason}, state::AppState, }; @@ -27,6 +28,7 @@ pub struct SerialRuntimeStatus { pub boot_epoch: Option, pub binding_id: Option, pub error: Option, + pub warning: Option, } #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] @@ -291,15 +293,18 @@ impl SessionSerialRuntime { ); continue; } - let Some(parameters) = serial.parameters else { - self.reject_bind( - state, - &device, - reply, - anyhow::anyhow!("unknown device UART parameters"), - ); - continue; - }; + // A saved Web UI profile is an explicit host-side override. Otherwise + // use the current boot's report, falling back to the conventional UART + // profile when firmware has a usable port but cannot report its mode. + let parameters = effective_parameters( + session + .board() + .boot + .as_uefi_http() + .and_then(|profile| profile.serial_parameters), + serial.parameters, + ); + let warning = serial.error.clone(); if let Err(error) = parameters.validate() { self.reject_bind(state, &device, reply, anyhow::anyhow!("{error}")); continue; @@ -334,6 +339,7 @@ impl SessionSerialRuntime { device, parameters, binding, + warning, reply: Some(reply), }); deadline = Instant::now() + Duration::from_secs(60); @@ -357,6 +363,7 @@ impl SessionSerialRuntime { parameters: Some(parameters), boot_epoch: Some(c.device.boot_epoch.clone()), port: lease.as_ref().map(|l| l.locator().name.clone()), + warning: c.warning.clone(), ..Default::default() }, ); @@ -376,6 +383,7 @@ impl SessionSerialRuntime { boot_epoch: Some(c.device.boot_epoch.clone()), binding_id: Some(c.binding.binding_id.clone()), error: None, + warning: c.warning.clone(), }; lease = Some(io); if let Some(reply) = c.reply.take() { @@ -477,6 +485,20 @@ impl SessionSerialRuntime { state.admin_events.invalidate(&["sessions"]); } } + +trait BootConfigExt { + fn as_uefi_http(&self) -> Option<&crate::config::UefiHttpProfile>; +} + +impl BootConfigExt for BootConfig { + fn as_uefi_http(&self) -> Option<&crate::config::UefiHttpProfile> { + match self { + Self::UefiHttp(profile) => Some(profile), + _ => None, + } + } +} + enum RuntimeEvent { Shutdown, Command(Option), @@ -491,8 +513,20 @@ struct BindingAttempt { device: LoaderDeviceStatus, parameters: SerialParameters, binding: SerialBinding, + warning: Option, reply: Option>>, } + +fn effective_parameters( + override_parameters: Option, + reported: Option, +) -> SerialParameters { + override_parameters + .map(Into::into) + .or(reported) + .unwrap_or_default() +} + fn bind_future( state: &AppState, owner: &str, @@ -512,3 +546,46 @@ fn bind_future( }; Box::pin(async move { manager.bind(request).await }) } + +#[cfg(test)] +mod tests { + use super::effective_parameters; + use crate::config::{ + AxloaderSerialFlowControl, AxloaderSerialParameters, AxloaderSerialParity, + AxloaderSerialStopBits, + }; + use httpboot_protocol::{SerialFlowControl, SerialParameters, SerialParity, SerialStopBits}; + + fn reported(baud_rate: u64) -> SerialParameters { + SerialParameters { + baud_rate, + data_bits: 8, + parity: SerialParity::None, + stop_bits: SerialStopBits::One, + flow_control: SerialFlowControl::None, + } + } + + #[test] + fn missing_firmware_parameters_use_common_uart_defaults() { + assert_eq!( + effective_parameters(None, None), + SerialParameters::default() + ); + } + + #[test] + fn configured_parameters_override_firmware_report() { + let configured = AxloaderSerialParameters { + baud_rate: 921_600, + data_bits: 7, + parity: AxloaderSerialParity::Even, + stop_bits: AxloaderSerialStopBits::Two, + flow_control: AxloaderSerialFlowControl::RtsCts, + }; + assert_eq!( + effective_parameters(Some(configured), Some(reported(115_200))), + configured.into_protocol() + ); + } +} diff --git a/ostool-server/tests/axloader_serial.rs b/ostool-server/tests/axloader_serial.rs index d1409436..220b0918 100644 --- a/ostool-server/tests/axloader_serial.rs +++ b/ostool-server/tests/axloader_serial.rs @@ -192,6 +192,13 @@ async fn serial_null_session_binds_reuses_uart_and_releases_real_reader() { }), boot: BootConfig::UefiHttp(UefiHttpProfile { boot_arch: Some(UefiBootArch::X86_64), + serial_parameters: Some(AxloaderSerialParameters { + baud_rate: 38_400, + data_bits: 8, + parity: AxloaderSerialParity::None, + stop_bits: AxloaderSerialStopBits::One, + flow_control: AxloaderSerialFlowControl::None, + }), }), network_identity: Some(BoardNetworkIdentity { mac_address: mac }), notes: None, @@ -345,7 +352,7 @@ async fn serial_null_session_binds_reuses_uart_and_releases_real_reader() { ostool_server::device::reconcile(s, announcement, "127.0.0.1:12345".parse().unwrap()) .await }); - wait_config(&mut configs, &path, baud).await; + wait_config(&mut configs, &path, 38_400).await; device .lock() .await @@ -380,7 +387,7 @@ async fn serial_null_session_binds_reuses_uart_and_releases_real_reader() { s.pending == 0 && s.candidates == 0 && s.leased == 1 }) .await; - assert_eq!(u64::from(actual_baud(&inspection)), baud); + assert_eq!(u64::from(actual_baud(&inspection)), 38_400); } { let mut d = device.lock().await; @@ -404,7 +411,7 @@ async fn serial_null_session_binds_reuses_uart_and_releases_real_reader() { failed_announcement, "127.0.0.1:12345".parse().unwrap(), )); - wait_config(&mut configs, &path, 230400).await; + wait_config(&mut configs, &path, 38_400).await; device .lock() .await @@ -443,7 +450,7 @@ async fn serial_null_session_binds_reuses_uart_and_releases_real_reader() { retry_announcement, "127.0.0.1:12345".parse().unwrap(), )); - wait_config(&mut configs, &path, 230400).await; + wait_config(&mut configs, &path, 38_400).await; device .lock() .await diff --git a/ostool-server/tests/qemu_serial.rs b/ostool-server/tests/qemu_serial.rs index e17749dd..82a6b425 100644 --- a/ostool-server/tests/qemu_serial.rs +++ b/ostool-server/tests/qemu_serial.rs @@ -134,6 +134,7 @@ async fn ovmf_identity_binds_serial_null_session_and_boots_kernel() -> anyhow::R }), boot: BootConfig::UefiHttp(UefiHttpProfile { boot_arch: Some(UefiBootArch::X86_64), + serial_parameters: None, }), network_identity: Some(BoardNetworkIdentity { mac_address: mac }), notes: None, diff --git a/ostool-server/webui/src/board-form.test.ts b/ostool-server/webui/src/board-form.test.ts index 4a75b7ac..013183c1 100644 --- a/ostool-server/webui/src/board-form.test.ts +++ b/ostool-server/webui/src/board-form.test.ts @@ -93,4 +93,39 @@ describe("board configuration contracts", () => { expect(validateForm(f)).not.toContain("串口"); expect(validateForm(f)).toContain("MAC"); }); + + it("persists optional axloader serial overrides without touching the U-Boot serial draft", () => { + const f = defaultFormState(); + f.board_type = "uefi-http"; + f.boot_kind = "httpboot"; + f.network_mac = "02:00:00:00:00:31"; + f.axloader_serial_parameters_enabled = true; + f.axloader_baud_rate = 921600; + f.axloader_data_bits = 7; + f.axloader_parity = "even"; + f.axloader_stop_bits = "two"; + f.axloader_flow_control = "rts_cts"; + + expect(buildRequestPayload(f).boot).toEqual({ + kind: "httpboot", + boot_arch: null, + serial_parameters: { + baud_rate: 921600, + data_bits: 7, + parity: "even", + stop_bits: "two", + flow_control: "rts_cts", + }, + }); + f.axloader_serial_parameters_enabled = false; + expect(buildRequestPayload(f).boot).toEqual({ + kind: "httpboot", + boot_arch: null, + serial_parameters: null, + }); + f.boot_kind = "uboot"; + f.serial_enabled = true; + f.serial_key_value = "manual"; + expect(buildRequestPayload(f).serial?.key.value).toBe("manual"); + }); }); diff --git a/ostool-server/webui/src/board-form.ts b/ostool-server/webui/src/board-form.ts index 2c34204c..d3d3e84b 100644 --- a/ostool-server/webui/src/board-form.ts +++ b/ostool-server/webui/src/board-form.ts @@ -19,6 +19,12 @@ export interface BoardEditorFormState { serial_key_kind: SerialPortKeyKind; serial_key_value: string; serial_baud_rate: number; + axloader_serial_parameters_enabled: boolean; + axloader_baud_rate: number; + axloader_data_bits: number; + axloader_parity: "none" | "odd" | "even" | "mark" | "space"; + axloader_stop_bits: "one" | "one_point_five" | "two"; + axloader_flow_control: "none" | "rts_cts"; power_management_kind: PowerManagementKind; power_on_cmd: string; power_off_cmd: string; @@ -54,6 +60,12 @@ export function defaultFormState(): BoardEditorFormState { serial_key_kind: "serial_number", serial_key_value: "", serial_baud_rate: DEFAULT_SERIAL_BAUD_RATE, + axloader_serial_parameters_enabled: false, + axloader_baud_rate: DEFAULT_SERIAL_BAUD_RATE, + axloader_data_bits: 8, + axloader_parity: "none", + axloader_stop_bits: "one", + axloader_flow_control: "none", power_management_kind: "custom", power_on_cmd: "", power_off_cmd: "", @@ -93,6 +105,15 @@ export function boardToFormState(board: BoardConfig): BoardEditorFormState { next.serial_baud_rate = board.serial.baud_rate; } + if (board.boot.kind === "httpboot" && board.boot.serial_parameters) { + next.axloader_serial_parameters_enabled = true; + next.axloader_baud_rate = board.boot.serial_parameters.baud_rate; + next.axloader_data_bits = board.boot.serial_parameters.data_bits; + next.axloader_parity = board.boot.serial_parameters.parity; + next.axloader_stop_bits = board.boot.serial_parameters.stop_bits; + next.axloader_flow_control = board.boot.serial_parameters.flow_control; + } + if (board.power_management.kind === "custom") { next.power_management_kind = "custom"; next.power_on_cmd = board.power_management.power_on_cmd; @@ -163,6 +184,15 @@ function buildBootConfig(form: BoardEditorFormState): BootConfig { return { kind: "httpboot", boot_arch: trimToNull(form.boot_arch), + serial_parameters: form.axloader_serial_parameters_enabled + ? { + baud_rate: form.axloader_baud_rate, + data_bits: form.axloader_data_bits, + parity: form.axloader_parity, + stop_bits: form.axloader_stop_bits, + flow_control: form.axloader_flow_control, + } + : null, }; } @@ -288,6 +318,17 @@ export function validateForm(form: BoardEditorFormState): string { } else if (!/^[0-9a-f]{2}(?::[0-9a-f]{2}){5}$/i.test(mac)) { errors.push("MAC 地址必须是六字节冒号格式,例如 02:00:00:00:00:01"); } + if (form.axloader_serial_parameters_enabled) { + if ( + !Number.isFinite(form.axloader_baud_rate) || + form.axloader_baud_rate <= 0 + ) { + errors.push("axloader 串口波特率必须大于 0"); + } + if (![7, 8].includes(form.axloader_data_bits)) { + errors.push("axloader 串口数据位必须是 7 或 8"); + } + } } return errors.join("\n"); } diff --git a/ostool-server/webui/src/components/serial-runtime.tsx b/ostool-server/webui/src/components/serial-runtime.tsx index 89d8aca8..5e2dd446 100644 --- a/ostool-server/webui/src/components/serial-runtime.tsx +++ b/ostool-server/webui/src/components/serial-runtime.tsx @@ -24,6 +24,7 @@ export function SerialRuntimeDetails({ {p.stop_bits} · 流控 {p.flow_control}

)} + {status?.warning &&

{status.warning}

} {status?.error &&

{status.error}

} ); diff --git a/ostool-server/webui/src/pages/BoardEditor.test.tsx b/ostool-server/webui/src/pages/BoardEditor.test.tsx index 50bfafa8..a53d2ebd 100644 --- a/ostool-server/webui/src/pages/BoardEditor.test.tsx +++ b/ostool-server/webui/src/pages/BoardEditor.test.tsx @@ -152,17 +152,15 @@ it("does not overwrite a local draft when another client edits the board", async }); it("switches to U-Boot without requiring or submitting a stale MAC", async () => { - const create = vi - .spyOn(api, "createBoard") - .mockImplementation( - async (request) => - ({ - ...request, - id: "created", - tags: request.tags ?? [], - boot: request.boot, - }) as BoardConfig, - ); + const create = vi.spyOn(api, "createBoard").mockImplementation( + async (request) => + ({ + ...request, + id: "created", + tags: request.tags ?? [], + boot: request.boot, + }) as BoardConfig, + ); mount(); const user = userEvent.setup(); await user.clear(screen.getByLabelText("MAC 地址")); @@ -193,6 +191,29 @@ it("shows loader OTA when the HTTP Boot architecture is omitted", () => { expect(screen.getByText("axloader OTA")).toBeInTheDocument(); }); +it("loads and edits the optional axloader serial override", async () => { + const board = httpBootBoard(); + board.boot = { + kind: "httpboot", + boot_arch: "x86_64", + serial_parameters: { + baud_rate: 921600, + data_bits: 7, + parity: "even", + stop_bits: "two", + flow_control: "rts_cts", + }, + }; + mount(board); + const user = userEvent.setup(); + + expect(screen.getByRole("checkbox", { name: "指定串口参数" })).toBeChecked(); + expect(screen.getByLabelText("波特率")).toHaveValue(921600); + expect(screen.getByLabelText("数据位")).toHaveValue("7"); + await user.selectOptions(screen.getByLabelText("数据位"), "8"); + expect(screen.getByLabelText("数据位")).toHaveValue("8"); +}); + it("switches manual UART draft to axloader and preserves focus on serial SSE updates", async () => { const board = httpBootBoard(); board.boot = { diff --git a/ostool-server/webui/src/pages/BoardEditor.tsx b/ostool-server/webui/src/pages/BoardEditor.tsx index 08cc1822..c826caef 100644 --- a/ostool-server/webui/src/pages/BoardEditor.tsx +++ b/ostool-server/webui/src/pages/BoardEditor.tsx @@ -175,6 +175,13 @@ function Editor({ board }: { board?: BoardConfig }) { serial_key_kind: draft.serial_key_kind, serial_key_value: draft.serial_key_value, serial_baud_rate: draft.serial_baud_rate, + axloader_serial_parameters_enabled: + draft.axloader_serial_parameters_enabled, + axloader_baud_rate: draft.axloader_baud_rate, + axloader_data_bits: draft.axloader_data_bits, + axloader_parity: draft.axloader_parity, + axloader_stop_bits: draft.axloader_stop_bits, + axloader_flow_control: draft.axloader_flow_control, } : {}), })); @@ -274,6 +281,78 @@ function Editor({ board }: { board?: BoardConfig }) { 发现请求 {serialManager?.pending ?? 0} · 候选监听{" "} {serialManager?.candidates ?? 0}

+ set("axloader_serial_parameters_enabled", v)} + /> + {form.axloader_serial_parameters_enabled && ( + <> + set("axloader_baud_rate", Number(v))} + hint="整组参数优先于 axloader 上报;未配置时采用上报值或 115200/8N1" + /> + set("axloader_data_bits", Number(v))} + options={[ + { value: "7", label: "7" }, + { value: "8", label: "8" }, + ]} + /> + + set( + "axloader_parity", + v as BoardEditorFormState["axloader_parity"], + ) + } + options={[ + { value: "none", label: "无" }, + { value: "odd", label: "奇校验" }, + { value: "even", label: "偶校验" }, + { value: "mark", label: "Mark" }, + { value: "space", label: "Space" }, + ]} + /> + + set( + "axloader_stop_bits", + v as BoardEditorFormState["axloader_stop_bits"], + ) + } + options={[ + { value: "one", label: "1" }, + { value: "one_point_five", label: "1.5" }, + { value: "two", label: "2" }, + ]} + /> + + set( + "axloader_flow_control", + v as BoardEditorFormState["axloader_flow_control"], + ) + } + options={[ + { value: "none", label: "无" }, + { value: "rts_cts", label: "RTS/CTS" }, + ]} + /> + + )}
) : (
diff --git a/ostool-server/webui/src/types/api.ts b/ostool-server/webui/src/types/api.ts index dc9c469f..a3be9961 100644 --- a/ostool-server/webui/src/types/api.ts +++ b/ostool-server/webui/src/types/api.ts @@ -127,6 +127,19 @@ export interface PxeProfile { export interface UefiHttpProfile { kind: "httpboot"; boot_arch?: string | null; + serial_parameters?: AxloaderSerialParameters | null; +} + +export type AxloaderSerialParity = "none" | "odd" | "even" | "mark" | "space"; +export type AxloaderSerialStopBits = "one" | "one_point_five" | "two"; +export type AxloaderSerialFlowControl = "none" | "rts_cts"; + +export interface AxloaderSerialParameters { + baud_rate: number; + data_bits: number; + parity: AxloaderSerialParity; + stop_bits: AxloaderSerialStopBits; + flow_control: AxloaderSerialFlowControl; } export type BootConfig = UbootProfile | PxeProfile | UefiHttpProfile; @@ -336,6 +349,7 @@ export interface SerialRuntimeStatus { flow_control: string; } | null; error: string | null; + warning: string | null; binding_id: string | null; boot_epoch: string | null; } From e1d6862207a2417c17fcaf2614fa975c1efff946 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=91=A8=E7=9D=BF?= Date: Fri, 9 Oct 2026 09:08:46 +0800 Subject: [PATCH 06/14] fix(ostool-server): close axloader serial review gaps --- README.en.md | 4 +- README.md | 4 +- docs/api.md | 2 +- ostool-server/src/config.rs | 48 +++++++++++++++++++ ostool-server/src/serial/runtime.rs | 11 ++++- ostool-server/webui/src/pages/BoardEditor.tsx | 3 -- 6 files changed, 62 insertions(+), 10 deletions(-) diff --git a/README.en.md b/README.en.md index 1e8e5a4d..e4aecee5 100644 --- a/README.en.md +++ b/README.en.md @@ -15,7 +15,7 @@ ## 📖 Project Overview -See [docs/axloader-network-control.md](docs/axloader-network-control.md) for axloader network control (legacy v2/v3/v4 compatibility and the v5 device HTTP API), persistent MAC binding, web administration, built-in QEMU virtual boards, and local validation with a real FAT disk image. The complete API contract is documented in [docs/api.md](docs/api.md). +See [docs/axloader-network-control.md](docs/axloader-network-control.md) for axloader network control (v6 serial auto-binding, v5 device identification and OTA, and legacy v2/v3/v4 identification and upgrade endpoints), persistent MAC binding, web administration, built-in QEMU virtual boards, and local validation with a real FAT disk image. The complete API contract is documented in [docs/api.md](docs/api.md). The management console at `/admin/` uses React + shadcn/ui. In the new-board form, choose a power module and power it on before selecting a discovered MAC or entering one manually; saving completes the binding. All management pages receive SSE updates and preserve displayed data and drafts across reconnects. See [Admin UI and event protocol](docs/admin-ui.md). At startup, incompatible board TOML files move into `quarantine/` under the board directory with their original contents and diagnostic metadata; valid boards continue to load. @@ -357,7 +357,7 @@ interface = "eth0" board_ip = "192.168.1.100" ``` -`BootPayloadConfig.cmdline` and `initramfs` are independent optional fields. They are top-level runtime settings for QEMU, board, and U-Boot configurations rather than build settings. `initramfs` is the **host** archive, separate from a Linux guest initrd. Direct AArch64/RISC-V QEMU boot passes it with `-initrd` and passes `cmdline` with `-append`; x86 uses `EFI/BOOT/initramfs.cpio` and `cmdline.txt` on the UEFI ESP, not the Linux x86 boot protocol for a bare ELF. U-Boot includes the archive as a FIT ramdisk and sets `bootargs` before booting. FIT generation removes inherited `linux,initrd-start/end` properties and any exactly matching memory reservation from the input DTB. If the FIT contains a ramdisk, U-Boot writes the range for this boot. The serial command path rejects a `cmdline` containing a single quote. For board HTTP Boot, the archive and kernel belong to the same session and the server records the archive size and SHA-256. For v5 devices the server converts the compatibility entry to `__x86_64_efi_pe_entry` before pushing the job; cmdline uses EFI LoadOptions and initramfs uses the TGOS `BootPayload` table. The v2/v3/v4 poll path retains `httpboot_entry`. The archive limit is 256 MiB and the command-line limit is 4095 printable ASCII bytes. A v2 loader can still boot a session without either field; the server rejects a v2 poll for a boot requiring either field. Firmware and kernels on physical boards must implement the corresponding handoff. +`BootPayloadConfig.cmdline` and `initramfs` are independent optional fields. They are top-level runtime settings for QEMU, board, and U-Boot configurations rather than build settings. `initramfs` is the **host** archive, separate from a Linux guest initrd. Direct AArch64/RISC-V QEMU boot passes it with `-initrd` and passes `cmdline` with `-append`; x86 uses `EFI/BOOT/initramfs.cpio` and `cmdline.txt` on the UEFI ESP, not the Linux x86 boot protocol for a bare ELF. U-Boot includes the archive as a FIT ramdisk and sets `bootargs` before booting. FIT generation removes inherited `linux,initrd-start/end` properties and any exactly matching memory reservation from the input DTB. If the FIT contains a ramdisk, U-Boot writes the range for this boot. The serial command path rejects a `cmdline` containing a single quote. For board HTTP Boot, the archive and kernel belong to the same session and the server records the archive size and SHA-256. v5 devices retain identification and OTA; v6 devices first let the server verify the UART identity with the parameters reported for the current boot, then release the start request with a binding token, convert the compatibility entry to `__x86_64_efi_pe_entry`, and pass cmdline through EFI LoadOptions and initramfs through the TGOS `BootPayload` table. The v2/v3/v4 poll path retains identification and upgrade purposes, while ordinary boot returns `serial_protocol_upgrade_required`. The archive limit is 256 MiB and the command-line limit is 4095 printable ASCII bytes. Firmware and kernels on physical boards must implement the corresponding handoff. ### Ordered shell initialization steps diff --git a/README.md b/README.md index c494ffc3..9170aab6 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ ## 📖 项目简介 -axloader 网络控制(v2/v3/v4 旧协议兼容及 v5 设备 HTTP 接口)、持久 MAC 绑定、Web 管理、内建 QEMU 虚拟板和本地真实 FAT 磁盘验证设计见 [docs/axloader-network-control.md](docs/axloader-network-control.md),完整接口契约见 [docs/api.md](docs/api.md)。 +axloader 网络控制(v6 串口自动绑定、v5 设备识别与 OTA、v2/v3/v4 旧识别与升级入口)、持久 MAC 绑定、Web 管理、内建 QEMU 虚拟板和本地真实 FAT 磁盘验证设计见 [docs/axloader-network-control.md](docs/axloader-network-control.md),完整接口契约见 [docs/api.md](docs/api.md)。 管理后台位于 `/admin/`,使用 React + shadcn/ui。新建开发板时可先选择电源模块并手动上电,再从实时发现列表选择或手工填写 MAC;保存后完成绑定。全部管理页面采用 SSE 推送更新,断线重连保留页面与编辑草稿。详见 [管理界面与事件协议](docs/admin-ui.md)。 不兼容的板卡 TOML 会在启动时移入板卡目录下的 `quarantine/`,保留原文件和原因,其余有效板卡继续加载。 @@ -333,7 +333,7 @@ interface = "eth0" board_ip = "192.168.1.100" ``` -`BootPayloadConfig` 的 `cmdline` 与 `initramfs` 相互独立且都可省略;它们是 QEMU、board 与 U-Boot 的顶层运行配置,不属于构建配置。`initramfs` 是宿主归档,与 Linux guest 的 initrd 分开配置。QEMU 直启仅在 AArch64/RISC-V 路径使用 `-initrd` 和 `-append`;x86 使用 UEFI ESP 中的 `EFI/BOOT/initramfs.cpio`、`cmdline.txt`,不向裸 ELF 传 Linux x86 启动协议参数。U-Boot 的 `generate_fit_image()` 将归档作为 FIT ramdisk,并在启动前设置 `bootargs`;生成 FIT 前会清除输入 DTB 中遗留的 `linux,initrd-start/end` 及与其完全对应的内存预留;若 FIT 包含 ramdisk,U-Boot 会写入本次启动的实际范围。串口命令路径暂不接受含单引号的 `cmdline`。board HTTP Boot 将归档和内核上传到同一 session,服务端记录归档大小与 SHA-256。v5 设备由服务端把兼容入口转换为 `__x86_64_efi_pe_entry` 后反向推送,cmdline 通过 EFI LoadOptions、initramfs 通过 TGOS `BootPayload` 交接;v2/v3/v4 poll 仍收到原 `httpboot_entry`。归档上限为 256 MiB,cmdline 上限为 4095 字节且仅允许可打印 ASCII 和空格。服务端仍允许 v2 loader 启动两个新字段均未配置的旧会话,遇到任一新字段则在 poll 阶段明确拒绝。实体板卡的固件和内核须实现对应交接协议。 +`BootPayloadConfig` 的 `cmdline` 与 `initramfs` 相互独立且都可省略;它们是 QEMU、board 与 U-Boot 的顶层运行配置,不属于构建配置。`initramfs` 是宿主归档,与 Linux guest 的 initrd 分开配置。QEMU 直启仅在 AArch64/RISC-V 路径使用 `-initrd` 和 `-append`;x86 使用 UEFI ESP 中的 `EFI/BOOT/initramfs.cpio`、`cmdline.txt`,不向裸 ELF 传 Linux x86 启动协议参数。U-Boot 的 `generate_fit_image()` 将归档作为 FIT ramdisk,并在启动前设置 `bootargs`;生成 FIT 前会清除输入 DTB 中遗留的 `linux,initrd-start/end` 及与其完全对应的内存预留;若 FIT 包含 ramdisk,U-Boot 会写入本次启动的实际范围。串口命令路径暂不接受含单引号的 `cmdline`。board HTTP Boot 将归档和内核上传到同一 session,服务端记录归档大小与 SHA-256。v5 设备保留识别与 OTA;v6 设备由服务端先按本次上报的 UART 参数确认串口身份,再以绑定令牌放行启动,兼容入口转换为 `__x86_64_efi_pe_entry`,cmdline 通过 EFI LoadOptions、initramfs 通过 TGOS `BootPayload` 交接。v2/v3/v4 poll 保留识别与升级用途,普通启动统一返回 `serial_protocol_upgrade_required`。归档上限为 256 MiB,cmdline 上限为 4095 字节且仅允许可打印 ASCII 和空格。实体板卡的固件和内核须实现对应交接协议。 ### 有序 Shell 初始化步骤 diff --git a/docs/api.md b/docs/api.md index 92da9086..0f2ddb63 100644 --- a/docs/api.md +++ b/docs/api.md @@ -376,7 +376,7 @@ Content-Type: application/json 启用内建虚拟板时也可使用 `{"kind":"qemu","virtual_device_id":"..."}`。此时 axloader 配置使用 `serial: null`,串口 provider 从电源取得,并配置相同虚拟设备的 MAC。 -- `boot.kind` 可为上例的 `uboot`、`{"kind":"pxe","notes":null}`,或 `{"kind":"httpboot","boot_arch":"aarch64","serial_parameters":null}`。`boot_arch` 可为 `x86_64`、`aarch64`、`loongarch64`、`riscv64` 或 `other`。`httpboot.serial_parameters` 是可选的宿主串口覆盖,包含 `baud_rate`、`data_bits`(7/8)、`parity`(`none`/`odd`/`even`/`mark`/`space`)、`stop_bits`(`one`/`one_point_five`/`two`)和 `flow_control`(`none`/`rts_cts`);配置后优先于 axloader 本次上报,省略或为 `null` 时采用上报值,固件无法提供时采用 115200/8N1/无流控。覆盖必须与实际串口输出一致,否则身份帧无法绑定。 +- `boot.kind` 可为上例的 `uboot`、`{"kind":"pxe","notes":null}`,或 `{"kind":"httpboot","boot_arch":"aarch64","serial_parameters":null}`。`boot_arch` 可为 `x86_64`、`aarch64`、`loongarch64`、`riscv64` 或 `other`。`httpboot.serial_parameters` 是可选的宿主串口覆盖,包含 `baud_rate`、`data_bits`(7/8)、`parity`(`none`/`odd`/`even`/`mark`/`space`)、`stop_bits`(`one`/`one_point_five`/`two`)和 `flow_control`(`none`/`rts_cts`);配置后优先于 axloader 本次上报,省略或为 `null` 时采用上报值,固件无法提供时采用 115200/8N1/无流控。物理宿主后端保存配置时只接受 `none`/`odd`/`even` 和 1/2 stop bits,`mark`/`space` 或 1.5 stop bits 会明确拒绝;覆盖必须与实际串口输出一致,否则身份帧无法绑定。 - `httpboot` 板卡必须提供 `network_identity: {"mac_address":"02:00:00:00:00:01"}`。MAC 会规范化为小写六字节冒号格式并在全部板卡配置中保持唯一;重复绑定返回 `409` 和错误码 `mac_already_bound`。`board_type` 始终由管理员填写,不根据 SMBIOS 或架构推断。 - U-Boot `network_mode` 可为 `dhcp` 或 `static_ip`。未启用 TFTP 或使用 DHCP 时服务端清除静态网络字段;使用 `static_ip` 时 `board_ip` 必填,所有已提供的网络字段必须是 IPv4 地址。`dtb_name` 必须符合单层 DTB 文件名格式,但创建或更新开发板时不会检查对应文件是否已经上传。 diff --git a/ostool-server/src/config.rs b/ostool-server/src/config.rs index 3986b54c..934f1a6c 100644 --- a/ostool-server/src/config.rs +++ b/ostool-server/src/config.rs @@ -548,6 +548,15 @@ impl BoardConfig { && let Some(parameters) = profile.serial_parameters { parameters.validate()?; + if !matches!(self.power_management, PowerManagementConfig::Qemu { .. }) { + ostool_serial::validate_host_parameters(parameters.into_protocol()).map_err( + |error| { + anyhow::anyhow!( + "boot.serial_parameters is unsupported by the host serial backend: {error}" + ) + }, + )?; + } } if let PowerManagementConfig::Qemu { virtual_device_id } = &self.power_management { if !matches!(self.boot, BootConfig::UefiHttp(_)) { @@ -1259,6 +1268,45 @@ bootm_addr = "0x82200000" assert_eq!(parameters.into_protocol().baud_rate, 921_600); } + #[test] + fn physical_httpboot_rejects_host_unsupported_serial_modes() { + let mut board = BoardConfig { + id: "uefi-http-serial-invalid".into(), + board_type: "x86_64-uefi-http".into(), + tags: vec![], + serial: None, + power_management: PowerManagementConfig::Custom(CustomPowerManagement { + power_on_cmd: "true".into(), + power_off_cmd: "true".into(), + }), + boot: BootConfig::UefiHttp(UefiHttpProfile { + boot_arch: Some(UefiBootArch::X86_64), + serial_parameters: Some(AxloaderSerialParameters { + baud_rate: 115_200, + data_bits: 8, + parity: AxloaderSerialParity::Mark, + stop_bits: AxloaderSerialStopBits::One, + flow_control: AxloaderSerialFlowControl::None, + }), + }), + network_identity: Some(BoardNetworkIdentity { + mac_address: "02:00:00:00:00:01".parse().unwrap(), + }), + notes: None, + disabled: false, + }; + let error = board.validate().unwrap_err().to_string(); + assert!(error.contains("unsupported by the host serial backend")); + + if let BootConfig::UefiHttp(profile) = &mut board.boot { + profile.serial_parameters.as_mut().unwrap().parity = AxloaderSerialParity::None; + profile.serial_parameters.as_mut().unwrap().stop_bits = + AxloaderSerialStopBits::OnePointFive; + } + let error = board.validate().unwrap_err().to_string(); + assert!(error.contains("unsupported by the host serial backend")); + } + #[test] fn httpboot_board_requires_persistent_network_identity() { let mut board = BoardConfig { diff --git a/ostool-server/src/serial/runtime.rs b/ostool-server/src/serial/runtime.rs index 73430f1e..60e6ed35 100644 --- a/ostool-server/src/serial/runtime.rs +++ b/ostool-server/src/serial/runtime.rs @@ -20,6 +20,8 @@ use tokio::{ time::Instant, }; +const SERIAL_BIND_TIMEOUT: Duration = Duration::from_secs(60); + #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct SerialRuntimeStatus { pub phase: SerialRuntimePhase, @@ -240,7 +242,7 @@ impl SessionSerialRuntime { opening = None; verifying_until = None; power_wait = powered; - deadline = Instant::now() + Duration::from_secs(60); + deadline = Instant::now() + SERIAL_BIND_TIMEOUT; self.publish( state, SerialRuntimeStatus { @@ -275,6 +277,11 @@ impl SessionSerialRuntime { continue; } power_wait = true; + // Each valid device report is progress. Refresh the wait + // window before handling recoverable reports such as + // `ready = false`, so repeated diagnostics do not expire + // the session while axloader is still booting. + deadline = Instant::now() + SERIAL_BIND_TIMEOUT; let Some(serial) = device.serial.as_ref() else { self.reject_bind( state, @@ -342,7 +349,7 @@ impl SessionSerialRuntime { warning, reply: Some(reply), }); - deadline = Instant::now() + Duration::from_secs(60); + deadline = Instant::now() + SERIAL_BIND_TIMEOUT; let c = current.as_ref().expect("current attempt"); if let Some(io) = lease.as_mut() { if io.configure(parameters).is_err() { diff --git a/ostool-server/webui/src/pages/BoardEditor.tsx b/ostool-server/webui/src/pages/BoardEditor.tsx index c826caef..b7d4e15d 100644 --- a/ostool-server/webui/src/pages/BoardEditor.tsx +++ b/ostool-server/webui/src/pages/BoardEditor.tsx @@ -318,8 +318,6 @@ function Editor({ board }: { board?: BoardConfig }) { { value: "none", label: "无" }, { value: "odd", label: "奇校验" }, { value: "even", label: "偶校验" }, - { value: "mark", label: "Mark" }, - { value: "space", label: "Space" }, ]} /> From 97af14f97fd47e8ebd28f6ca4752108dd7acfd65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=91=A8=E7=9D=BF?= Date: Fri, 9 Oct 2026 09:17:14 +0800 Subject: [PATCH 07/14] fix(ostool-server): refresh serial retry deadline on rejection --- ostool-server/src/serial/runtime.rs | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/ostool-server/src/serial/runtime.rs b/ostool-server/src/serial/runtime.rs index 60e6ed35..72b4a078 100644 --- a/ostool-server/src/serial/runtime.rs +++ b/ostool-server/src/serial/runtime.rs @@ -277,16 +277,12 @@ impl SessionSerialRuntime { continue; } power_wait = true; - // Each valid device report is progress. Refresh the wait - // window before handling recoverable reports such as - // `ready = false`, so repeated diagnostics do not expire - // the session while axloader is still booting. - deadline = Instant::now() + SERIAL_BIND_TIMEOUT; let Some(serial) = device.serial.as_ref() else { self.reject_bind( state, &device, reply, + &mut deadline, anyhow::anyhow!("device has no v6 serial parameters"), ); continue; @@ -296,6 +292,7 @@ impl SessionSerialRuntime { state, &device, reply, + &mut deadline, anyhow::anyhow!("automatic serial unavailable: {:?}", serial.error), ); continue; @@ -313,7 +310,13 @@ impl SessionSerialRuntime { ); let warning = serial.error.clone(); if let Err(error) = parameters.validate() { - self.reject_bind(state, &device, reply, anyhow::anyhow!("{error}")); + self.reject_bind( + state, + &device, + reply, + &mut deadline, + anyhow::anyhow!("{error}"), + ); continue; } if !matches!( @@ -321,7 +324,7 @@ impl SessionSerialRuntime { crate::config::PowerManagementConfig::Qemu { .. } ) && let Err(error) = ostool_serial::validate_host_parameters(parameters) { - self.reject_bind(state, &device, reply, error.into()); + self.reject_bind(state, &device, reply, &mut deadline, error.into()); continue; } if let Some(c) = current.as_ref() @@ -477,8 +480,13 @@ impl SessionSerialRuntime { state: &AppState, device: &LoaderDeviceStatus, reply: oneshot::Sender>, + deadline: &mut Instant, error: anyhow::Error, ) { + // A device report, including a recoverable `ready = false` report, is + // progress. Keep the session alive for another complete discovery + // window while the device repairs or exposes its UART. + *deadline = Instant::now() + SERIAL_BIND_TIMEOUT; let mut status = self.snapshot(); status.phase = SerialRuntimePhase::Recovering; status.boot_epoch = Some(device.boot_epoch.clone()); From 532c030566faf7cd7b777b359cac87e3d0d74ec3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=91=A8=E7=9D=BF?= Date: Fri, 9 Oct 2026 09:50:23 +0800 Subject: [PATCH 08/14] fix(ostool): retry recoverable axloader binding failures --- docs/axloader-serial-ownership.md | 4 +- ostool-server/src/serial/runtime.rs | 56 +++++++++++-- ostool/src/axloader.rs | 124 +++++++++++++++++++++------- 3 files changed, 143 insertions(+), 41 deletions(-) diff --git a/docs/axloader-serial-ownership.md b/docs/axloader-serial-ownership.md index fad13d6a..e440142f 100644 --- a/docs/axloader-serial-ownership.md +++ b/docs/axloader-serial-ownership.md @@ -32,7 +32,7 @@ HTTP 和 UART 就绪后,固件每 250 ms 尝试发送 `\r\nAXLOADER-SERIAL/1 < `BindRequest` 携带所有者、请求代次、MAC、启动代次、身份、参数和截止时间。`Discovery` actor 串行处理 channel 消息,端口枚举与打开在独立任务执行,避免阻塞取消和继电器保留请求。 -有 RAM 定位时先按本次参数验证对应口 1 秒;失败清除定位后进入共享发现。多个请求共享候选 IO 和读取者;并行打开的端口按规范化路径去重。参数组合去重后每 1 秒轮换。扫描期限是 `max(5 秒, 组合数 × 2 秒)`,不超过请求剩余期限。设备等待上限 60 秒;无身份帧时返回错误,不无限占用串口。 +有 RAM 定位时先按本次参数验证对应口 1 秒;失败清除定位后进入共享发现。多个请求共享候选 IO 和读取者;并行打开的端口按规范化路径去重。参数组合去重后每 1 秒轮换。每次管理器绑定请求的扫描期限是 `max(5 秒, 组合数 × 2 秒)`,不超过该请求剩余期限。服务端设备等待上限为 60 秒;每次收到同一启动代次的有效设备报告(包括可恢复的 `ready=false` 或参数错误)都会刷新这个窗口。单次扫描没有身份帧时释放候选并返回可恢复错误,不无限占用串口。 `SerialLease::confirm()` 只在网络确认成功后提交 `MAC → PortLocator`。缓存不包含启动 ID、绑定令牌或句柄;session 结束保留定位,读写/验证出错清除定位,进程重启全部丢失。缓存每次仍须匹配新启动身份。 @@ -88,7 +88,7 @@ ostool axloader run --device http://DEVICE:2999 --kernel kernel.elf \ ostool axloader continue --device http://DEVICE:2999 ``` -`run` 自动配置本地口并输出串口,在交互终端转发键盘输入;Unix 非交互模式只输出,沿用现有终端行为。交互输入复用 `sterm::Input` 的可取消非阻塞读取,不留下等待换行的工作线程。Ctrl-C 归还租约,尚在装载器时尽力撤销绑定。`continue` 只执行 direct 放行,调用方随后上传和启动时仍须带设备状态中的匹配令牌。 +`run` 自动配置本地口并输出串口,在交互终端转发键盘输入;Unix 非交互模式只输出,沿用现有终端行为。CLI 对设备状态和管理器绑定请求使用 60 秒总等待期限;每次请求的串口扫描仍按 `max(5 秒, 组合数 × 2 秒)` 结束,遇到 `ready=false`、扫描超时或发现失败会重新读取状态并发起下一次请求。交互输入复用 `sterm::Input` 的可取消非阻塞读取,不留下等待换行的工作线程。Ctrl-C 归还租约,尚在装载器时尽力撤销绑定。`continue` 只执行 direct 放行,调用方随后上传和启动时仍须带设备状态中的匹配令牌。 ## 4. 验证与迁移 diff --git a/ostool-server/src/serial/runtime.rs b/ostool-server/src/serial/runtime.rs index 72b4a078..ac028fa2 100644 --- a/ostool-server/src/serial/runtime.rs +++ b/ostool-server/src/serial/runtime.rs @@ -281,7 +281,7 @@ impl SessionSerialRuntime { self.reject_bind( state, &device, - reply, + Some(reply), &mut deadline, anyhow::anyhow!("device has no v6 serial parameters"), ); @@ -291,7 +291,7 @@ impl SessionSerialRuntime { self.reject_bind( state, &device, - reply, + Some(reply), &mut deadline, anyhow::anyhow!("automatic serial unavailable: {:?}", serial.error), ); @@ -313,7 +313,7 @@ impl SessionSerialRuntime { self.reject_bind( state, &device, - reply, + Some(reply), &mut deadline, anyhow::anyhow!("{error}"), ); @@ -324,7 +324,13 @@ impl SessionSerialRuntime { crate::config::PowerManagementConfig::Qemu { .. } ) && let Err(error) = ostool_serial::validate_host_parameters(parameters) { - self.reject_bind(state, &device, reply, &mut deadline, error.into()); + self.reject_bind( + state, + &device, + Some(reply), + &mut deadline, + error.into(), + ); continue; } if let Some(c) = current.as_ref() @@ -384,7 +390,25 @@ impl SessionSerialRuntime { }, RuntimeEvent::Opened(result) => { opening = None; - let io = (*result)?; + let io = match *result { + Ok(io) => io, + Err(error) if is_recoverable_bind_error(&error) => { + let Some(c) = current.take() else { + continue; + }; + state.serial_manager.invalidate(c.device.mac_address); + verifying_until = None; + self.reject_bind( + state, + &c.device, + c.reply, + &mut deadline, + anyhow::anyhow!("{error}"), + ); + continue; + } + Err(error) => return Err(error.into()), + }; let c = current.as_mut().context("missing binding attempt")?; let status = SerialRuntimeStatus { phase: SerialRuntimePhase::Verifying, @@ -479,7 +503,7 @@ impl SessionSerialRuntime { &self, state: &AppState, device: &LoaderDeviceStatus, - reply: oneshot::Sender>, + reply: Option>>, deadline: &mut Instant, error: anyhow::Error, ) { @@ -489,11 +513,19 @@ impl SessionSerialRuntime { *deadline = Instant::now() + SERIAL_BIND_TIMEOUT; let mut status = self.snapshot(); status.phase = SerialRuntimePhase::Recovering; + status.port = None; + status.binding_id = None; status.boot_epoch = Some(device.boot_epoch.clone()); status.parameters = device.serial.as_ref().and_then(|serial| serial.parameters); + status.warning = device + .serial + .as_ref() + .and_then(|serial| serial.error.clone()); status.error = Some(format!("{error:#}")); self.publish(state, status); - let _ = reply.send(Err(error)); + if let Some(reply) = reply { + let _ = reply.send(Err(error)); + } } fn publish(&self, state: &AppState, status: SerialRuntimeStatus) { self.status.send_replace(status); @@ -501,6 +533,16 @@ impl SessionSerialRuntime { } } +fn is_recoverable_bind_error(error: &ostool_serial::BindError) -> bool { + matches!( + error, + ostool_serial::BindError::Timeout + | ostool_serial::BindError::DiscoveryFailed(_) + | ostool_serial::BindError::Busy + | ostool_serial::BindError::Io(_) + ) +} + trait BootConfigExt { fn as_uefi_http(&self) -> Option<&crate::config::UefiHttpProfile>; } diff --git a/ostool/src/axloader.rs b/ostool/src/axloader.rs index 81dd7623..8cdf04d0 100644 --- a/ostool/src/axloader.rs +++ b/ostool/src/axloader.rs @@ -5,15 +5,17 @@ use httpboot_protocol::{ DEVICE_PROTOCOL_VERSION, DeviceBootImage, DeviceBootJob, LoaderDeviceStatus, SerialBinding, SerialBindingMode, }; -use ostool_serial::{BindRequest, NativeBackend, SerialManager}; +use ostool_serial::{BindError, BindRequest, NativeBackend, SerialManager}; use reqwest::Client; use sha2::{Digest, Sha256}; use std::{path::PathBuf, sync::Arc, time::Duration}; use tokio::{ io::{AsyncReadExt, AsyncWriteExt}, - time::Instant, + time::{Instant, sleep}, }; +const CLI_BIND_TIMEOUT: Duration = Duration::from_secs(60); + #[derive(Debug, Subcommand)] pub enum AxloaderCommand { /// Automatically discover/configure the UART and boot a kernel through axloader. @@ -42,14 +44,7 @@ pub async fn execute(command: AxloaderCommand) -> anyhow::Result<()> { let client = Client::builder() .timeout(Duration::from_secs(300)) .build()?; - let observed: LoaderDeviceStatus = client - .get(format!("{device}/api/v1/status")) - .timeout(Duration::from_secs(5)) - .send() - .await? - .error_for_status()? - .json() - .await?; + let mut observed = fetch_status(&client, &device).await?; ensure!( observed.protocol_version == DEVICE_PROTOCOL_VERSION, "automatic serial requires axloader v6; upgrade this device" @@ -82,39 +77,77 @@ pub async fn execute(command: AxloaderCommand) -> anyhow::Result<()> { println!("X-Serial-Binding: {}", binding.binding_id); return Ok(()); }; - ensure!( - serial.ready, - "automatic serial unavailable: {:?}", - serial.error - ); - let parameters = serial - .parameters - .context("device UART parameters are unknown")?; - ostool_serial::validate_host_parameters(parameters)?; let kernel = tokio::fs::read(kernel).await?; let initramfs = match initramfs { Some(path) => Some(tokio::fs::read(path).await?), None => None, }; let manager = SerialManager::new(Arc::new(NativeBackend)); - let owner = binding.binding_id.clone(); - let mut lease = manager - .bind(BindRequest { - owner: owner.clone(), - generation: 1, - mac_address: observed.mac_address, - boot_epoch: observed.boot_epoch.clone(), - serial_id: binding.serial_id.clone(), - parameters, - deadline: Instant::now() + Duration::from_secs(60), - }) - .await?; + let cli_deadline = Instant::now() + CLI_BIND_TIMEOUT; + let mut generation = 0_u64; + let (binding, parameters, mut lease, owner) = loop { + let serial = observed + .serial + .as_ref() + .context("device did not report serial status")?; + if !serial.ready { + if Instant::now() >= cli_deadline { + anyhow::bail!( + "automatic serial unavailable before deadline: {:?}", + serial.error + ); + } + sleep(Duration::from_millis(250)).await; + observed = fetch_status(&client, &device).await?; + ensure_v6(&observed)?; + continue; + } + let parameters = serial + .parameters + .context("device UART parameters are unknown")?; + ostool_serial::validate_host_parameters(parameters)?; + binding = SerialBinding { + serial_id: serial.serial_id.clone(), + binding_id: format!("{:032x}", binding_nonce()), + mode: SerialBindingMode::Bound, + }; + let owner = binding.binding_id.clone(); + generation = generation + .checked_add(1) + .context("serial binding generation exhausted")?; + let remaining = cli_deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + anyhow::bail!("serial identity did not become available before deadline"); + } + match manager + .bind(BindRequest { + owner: owner.clone(), + generation, + mac_address: observed.mac_address, + boot_epoch: observed.boot_epoch.clone(), + serial_id: binding.serial_id.clone(), + parameters, + deadline: Instant::now() + remaining.min(Duration::from_secs(5)), + }) + .await + { + Ok(lease) => break (binding, parameters, lease, owner), + Err(error) if retryable_bind_error(&error) => { + if Instant::now() >= cli_deadline { + anyhow::bail!("serial identity discovery failed before deadline: {error}"); + } + sleep(Duration::from_millis(250)).await; + observed = fetch_status(&client, &device).await?; + ensure_v6(&observed)?; + } + Err(error) => return Err(error.into()), + } + }; println!( "Serial identity verified on {} with {:?}", lease.locator().name, parameters ); - binding.mode = SerialBindingMode::Bound; if let Err(error) = grant(&client, &device, &observed.boot_epoch, &binding).await { drop(lease); manager.wait_owner_released(&owner).await?; @@ -231,6 +264,33 @@ pub async fn execute(command: AxloaderCommand) -> anyhow::Result<()> { .await; result } + +async fn fetch_status(client: &Client, device: &str) -> anyhow::Result { + Ok(client + .get(format!("{device}/api/v1/status")) + .timeout(Duration::from_secs(5)) + .send() + .await? + .error_for_status()? + .json() + .await?) +} + +fn ensure_v6(status: &LoaderDeviceStatus) -> anyhow::Result<()> { + ensure!( + status.protocol_version == DEVICE_PROTOCOL_VERSION, + "automatic serial requires axloader v6; upgrade this device" + ); + Ok(()) +} + +fn retryable_bind_error(error: &BindError) -> bool { + matches!( + error, + BindError::Timeout | BindError::DiscoveryFailed(_) | BindError::Busy | BindError::Io(_) + ) +} + fn image(bytes: &[u8]) -> DeviceBootImage { DeviceBootImage { size: bytes.len() as u64, From 654026b63f62f47728b014dbb4f98d9130279e99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=91=A8=E7=9D=BF?= Date: Fri, 9 Oct 2026 10:49:39 +0800 Subject: [PATCH 09/14] test(ostool-server): cover serial bind recovery state --- ostool-server/src/serial/runtime.rs | 31 +++++++- ostool-server/tests/axloader_serial.rs | 104 ++++++++++++++++++++++++- 2 files changed, 132 insertions(+), 3 deletions(-) diff --git a/ostool-server/src/serial/runtime.rs b/ostool-server/src/serial/runtime.rs index ac028fa2..65e1865a 100644 --- a/ostool-server/src/serial/runtime.rs +++ b/ostool-server/src/serial/runtime.rs @@ -278,20 +278,24 @@ impl SessionSerialRuntime { } power_wait = true; let Some(serial) = device.serial.as_ref() else { + let active = active_binding_status(lease.as_ref(), current.as_ref()); self.reject_bind( state, &device, Some(reply), + active, &mut deadline, anyhow::anyhow!("device has no v6 serial parameters"), ); continue; }; if !serial.ready { + let active = active_binding_status(lease.as_ref(), current.as_ref()); self.reject_bind( state, &device, Some(reply), + active, &mut deadline, anyhow::anyhow!("automatic serial unavailable: {:?}", serial.error), ); @@ -310,10 +314,12 @@ impl SessionSerialRuntime { ); let warning = serial.error.clone(); if let Err(error) = parameters.validate() { + let active = active_binding_status(lease.as_ref(), current.as_ref()); self.reject_bind( state, &device, Some(reply), + active, &mut deadline, anyhow::anyhow!("{error}"), ); @@ -324,10 +330,12 @@ impl SessionSerialRuntime { crate::config::PowerManagementConfig::Qemu { .. } ) && let Err(error) = ostool_serial::validate_host_parameters(parameters) { + let active = active_binding_status(lease.as_ref(), current.as_ref()); self.reject_bind( state, &device, Some(reply), + active, &mut deadline, error.into(), ); @@ -393,6 +401,7 @@ impl SessionSerialRuntime { let io = match *result { Ok(io) => io, Err(error) if is_recoverable_bind_error(&error) => { + let active = active_binding_status(lease.as_ref(), current.as_ref()); let Some(c) = current.take() else { continue; }; @@ -402,6 +411,7 @@ impl SessionSerialRuntime { state, &c.device, c.reply, + active, &mut deadline, anyhow::anyhow!("{error}"), ); @@ -504,6 +514,7 @@ impl SessionSerialRuntime { state: &AppState, device: &LoaderDeviceStatus, reply: Option>>, + active: ActiveBindingStatus, deadline: &mut Instant, error: anyhow::Error, ) { @@ -513,8 +524,8 @@ impl SessionSerialRuntime { *deadline = Instant::now() + SERIAL_BIND_TIMEOUT; let mut status = self.snapshot(); status.phase = SerialRuntimePhase::Recovering; - status.port = None; - status.binding_id = None; + status.port = active.port; + status.binding_id = active.binding_id; status.boot_epoch = Some(device.boot_epoch.clone()); status.parameters = device.serial.as_ref().and_then(|serial| serial.parameters); status.warning = device @@ -533,6 +544,22 @@ impl SessionSerialRuntime { } } +#[derive(Default)] +struct ActiveBindingStatus { + port: Option, + binding_id: Option, +} + +fn active_binding_status( + lease: Option<&SerialLease>, + current: Option<&BindingAttempt>, +) -> ActiveBindingStatus { + ActiveBindingStatus { + port: lease.map(|lease| lease.locator().name.clone()), + binding_id: lease.and_then(|_| current.map(|attempt| attempt.binding.binding_id.clone())), + } +} + fn is_recoverable_bind_error(error: &ostool_serial::BindError) -> bool { matches!( error, diff --git a/ostool-server/tests/axloader_serial.rs b/ostool-server/tests/axloader_serial.rs index 220b0918..5caf41c3 100644 --- a/ostool-server/tests/axloader_serial.rs +++ b/ostool-server/tests/axloader_serial.rs @@ -348,11 +348,54 @@ async fn serial_null_session_binds_reuses_uart_and_releases_real_reader() { serial_ready: true, }; let s = state.clone(); - let operation = tokio::spawn(async move { + let mut operation = tokio::spawn(async move { ostool_server::device::reconcile(s, announcement, "127.0.0.1:12345".parse().unwrap()) .await }); wait_config(&mut configs, &path, 38_400).await; + if n == 1 { + // The manager's discovery window is deliberately shorter than the + // session wait deadline. A missing identity frame must recover the + // runtime, rather than request session shutdown, so a later + // broadcast can retry the same boot epoch. + let error = tokio::time::timeout(Duration::from_secs(10), &mut operation) + .await + .unwrap() + .unwrap() + .unwrap_err(); + assert!(format!("{error:#}").contains("serial binding")); + assert!(!session.is_stop_requested()); + assert_eq!( + session.serial_runtime.snapshot().phase, + ostool_server::serial::runtime::SerialRuntimePhase::Recovering + ); + wait_snapshot(&state.serial_manager, |s| { + s.pending == 0 && s.candidates == 0 && s.leased == 0 + }) + .await; + + let retry_state = state.clone(); + let retry_announcement = LoaderAnnouncement { + protocol_version: DEVICE_PROTOCOL_VERSION, + mac_address: mac, + current_mac_address: mac, + arch: BootArch::X86_64, + loader_version: "test-v6".into(), + boot_epoch: format!("{n:032x}"), + http_port: device_port, + serial_id: Some(format!("{n:032x}")), + serial_ready: true, + }; + operation = tokio::spawn(async move { + ostool_server::device::reconcile( + retry_state, + retry_announcement, + "127.0.0.1:12345".parse().unwrap(), + ) + .await + }); + wait_config(&mut configs, &path, 38_400).await; + } device .lock() .await @@ -388,6 +431,65 @@ async fn serial_null_session_binds_reuses_uart_and_releases_real_reader() { }) .await; assert_eq!(u64::from(actual_baud(&inspection)), 38_400); + if n == 1 { + let bound = session.serial_runtime.snapshot(); + let bound_binding_id = bound.binding_id.clone().unwrap(); + assert_eq!(bound.port.as_deref(), Some(path.as_str())); + + // A transient device-side not-ready report must preserve the + // still-live lease identity in the public runtime status. + { + let mut d = device.lock().await; + let mut serial = serial_status(n, 230400); + serial.ready = false; + serial.error = Some("UART is temporarily unavailable".into()); + d.status.serial = Some(serial); + } + let unavailable = LoaderAnnouncement { + protocol_version: DEVICE_PROTOCOL_VERSION, + mac_address: mac, + current_mac_address: mac, + arch: BootArch::X86_64, + loader_version: "test-v6".into(), + boot_epoch: format!("{n:032x}"), + http_port: device_port, + serial_id: Some(format!("{n:032x}")), + serial_ready: false, + }; + let unavailable_error = ostool_server::device::reconcile( + state.clone(), + unavailable, + "127.0.0.1:12345".parse().unwrap(), + ) + .await + .unwrap_err(); + assert!(format!("{unavailable_error:#}").contains("automatic serial unavailable")); + let recovering = session.serial_runtime.snapshot(); + assert_eq!( + recovering.phase, + ostool_server::serial::runtime::SerialRuntimePhase::Recovering + ); + assert_eq!(recovering.port.as_deref(), Some(path.as_str())); + assert_eq!( + recovering.binding_id.as_deref(), + Some(bound_binding_id.as_str()) + ); + assert!(!session.is_stop_requested()); + assert!( + state + .tftp_manager + .read() + .await + .get_session_file(&created.id, "kernel.elf") + .await + .unwrap() + .is_some() + ); + { + let mut d = device.lock().await; + d.status.serial = Some(serial_status(n, baud)); + } + } } { let mut d = device.lock().await; From 700ab632964528f2bec1c29815c88131b7a6d267 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=91=A8=E7=9D=BF?= Date: Fri, 9 Oct 2026 10:54:28 +0800 Subject: [PATCH 10/14] fix(ostool-server): keep active binding state by epoch --- ostool-server/src/serial/runtime.rs | 39 ++++++++++++++++++++++++----- 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/ostool-server/src/serial/runtime.rs b/ostool-server/src/serial/runtime.rs index 65e1865a..b514db57 100644 --- a/ostool-server/src/serial/runtime.rs +++ b/ostool-server/src/serial/runtime.rs @@ -278,7 +278,11 @@ impl SessionSerialRuntime { } power_wait = true; let Some(serial) = device.serial.as_ref() else { - let active = active_binding_status(lease.as_ref(), current.as_ref()); + let active = active_binding_status( + lease.as_ref(), + current.as_ref(), + Some(device.boot_epoch.as_str()), + ); self.reject_bind( state, &device, @@ -290,7 +294,11 @@ impl SessionSerialRuntime { continue; }; if !serial.ready { - let active = active_binding_status(lease.as_ref(), current.as_ref()); + let active = active_binding_status( + lease.as_ref(), + current.as_ref(), + Some(device.boot_epoch.as_str()), + ); self.reject_bind( state, &device, @@ -314,7 +322,11 @@ impl SessionSerialRuntime { ); let warning = serial.error.clone(); if let Err(error) = parameters.validate() { - let active = active_binding_status(lease.as_ref(), current.as_ref()); + let active = active_binding_status( + lease.as_ref(), + current.as_ref(), + Some(device.boot_epoch.as_str()), + ); self.reject_bind( state, &device, @@ -330,7 +342,11 @@ impl SessionSerialRuntime { crate::config::PowerManagementConfig::Qemu { .. } ) && let Err(error) = ostool_serial::validate_host_parameters(parameters) { - let active = active_binding_status(lease.as_ref(), current.as_ref()); + let active = active_binding_status( + lease.as_ref(), + current.as_ref(), + Some(device.boot_epoch.as_str()), + ); self.reject_bind( state, &device, @@ -401,7 +417,13 @@ impl SessionSerialRuntime { let io = match *result { Ok(io) => io, Err(error) if is_recoverable_bind_error(&error) => { - let active = active_binding_status(lease.as_ref(), current.as_ref()); + let active = active_binding_status( + lease.as_ref(), + current.as_ref(), + current + .as_ref() + .map(|attempt| attempt.device.boot_epoch.as_str()), + ); let Some(c) = current.take() else { continue; }; @@ -553,10 +575,15 @@ struct ActiveBindingStatus { fn active_binding_status( lease: Option<&SerialLease>, current: Option<&BindingAttempt>, + boot_epoch: Option<&str>, ) -> ActiveBindingStatus { ActiveBindingStatus { port: lease.map(|lease| lease.locator().name.clone()), - binding_id: lease.and_then(|_| current.map(|attempt| attempt.binding.binding_id.clone())), + binding_id: lease.and_then(|_| { + current + .filter(|attempt| Some(attempt.device.boot_epoch.as_str()) == boot_epoch) + .map(|attempt| attempt.binding.binding_id.clone()) + }), } } From a854be4c71701d594e5674b8529e75fbd6ca2471 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=91=A8=E7=9D=BF?= Date: Fri, 9 Oct 2026 10:58:26 +0800 Subject: [PATCH 11/14] test(ostool-server): assert recovery keeps session files --- ostool-server/tests/axloader_serial.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/ostool-server/tests/axloader_serial.rs b/ostool-server/tests/axloader_serial.rs index 5caf41c3..83f6be3c 100644 --- a/ostool-server/tests/axloader_serial.rs +++ b/ostool-server/tests/axloader_serial.rs @@ -365,10 +365,21 @@ async fn serial_null_session_binds_reuses_uart_and_releases_real_reader() { .unwrap_err(); assert!(format!("{error:#}").contains("serial binding")); assert!(!session.is_stop_requested()); + assert!(!session.is_releasing()); assert_eq!( session.serial_runtime.snapshot().phase, ostool_server::serial::runtime::SerialRuntimePhase::Recovering ); + assert!( + state + .tftp_manager + .read() + .await + .get_session_file(&created.id, "kernel.elf") + .await + .unwrap() + .is_some() + ); wait_snapshot(&state.serial_manager, |s| { s.pending == 0 && s.candidates == 0 && s.leased == 0 }) From 77061dd0338896301749eafd569a617aa856d41b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=91=A8=E7=9D=BF?= Date: Fri, 9 Oct 2026 11:01:21 +0800 Subject: [PATCH 12/14] docs(ostool): describe recovering serial lease state --- docs/api.md | 2 +- docs/axloader-serial-ownership.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/api.md b/docs/api.md index 0f2ddb63..05db10cd 100644 --- a/docs/api.md +++ b/docs/api.md @@ -897,7 +897,7 @@ GET /api/v1/sessions/{session_id}/serial } ``` -非 axloader 模式没有串口时,`available` 和 `connected` 为 `false`,`port`、`baud_rate`、`ws_url` 均为 `null`。axloader 连接后 `connected` 只表示 WebSocket;新增 `runtime` 包含 phase、port、parameters、boot_epoch、binding_id、warning、error,`manager` 包含 pending、candidates、leased。`warning` 用于固件参数回退等非致命诊断,`error` 表示当前失败。实际绑定前 port/baud_rate 为空。配置了串口但服务端无法把稳定标识解析为当前设备路径时返回 `503 Service Unavailable`。 +非 axloader 模式没有串口时,`available` 和 `connected` 为 `false`,`port`、`baud_rate`、`ws_url` 均为 `null`。axloader 连接后 `connected` 只表示 WebSocket;新增 `runtime` 包含 phase、port、parameters、boot_epoch、binding_id、warning、error,`manager` 包含 pending、candidates、leased。`warning` 用于固件参数回退等非致命诊断,`error` 表示当前失败。首次绑定且尚未持有租约时,实际绑定前 `port`/`baud_rate` 为空;若 `Recovering` 仅表示仍在验证一个保留的实时租约,`runtime.port` 和同一启动代次的 `binding_id` 会继续反映该租约。配置了串口但服务端无法把稳定标识解析为当前设备路径时返回 `503 Service Unavailable`。 ### 获取 TFTP 状态 diff --git a/docs/axloader-serial-ownership.md b/docs/axloader-serial-ownership.md index e440142f..69cdc984 100644 --- a/docs/axloader-serial-ownership.md +++ b/docs/axloader-serial-ownership.md @@ -70,7 +70,7 @@ sequenceDiagram ## 3. 会话与界面 -`serial_connected` 只表示 WebSocket 已建立。实际 UART 状态由 `SerialRuntimeStatus` 表示;UI 经现有 SSE 订阅 `sessions` 和 `serial_manager`,展示参数、端口、状态、错误、发现请求数和候选数。 +`serial_connected` 只表示 WebSocket 已建立。实际 UART 状态由 `SerialRuntimeStatus` 表示;UI 经现有 SSE 订阅 `sessions` 和 `serial_manager`,展示参数、端口、状态、错误、发现请求数和候选数。`Recovering` 可能仍保留待重新验证的实时租约,此时端口和同一启动代次的绑定令牌继续展示;没有实时租约时才清空端口和令牌。 ### 3.1 上电与错误恢复 From c66f8a760458a9cee11ec7033449eaf065fb3e7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=91=A8=E7=9D=BF?= Date: Fri, 9 Oct 2026 11:34:27 +0800 Subject: [PATCH 13/14] fix(ostool-server): recover from invalid loader identity --- ostool-server/src/serial/runtime.rs | 32 ++++++++++++++ ostool-server/tests/axloader_serial.rs | 60 ++++++++++++++++++++++++++ 2 files changed, 92 insertions(+) diff --git a/ostool-server/src/serial/runtime.rs b/ostool-server/src/serial/runtime.rs index b514db57..668c80ab 100644 --- a/ostool-server/src/serial/runtime.rs +++ b/ostool-server/src/serial/runtime.rs @@ -277,6 +277,22 @@ impl SessionSerialRuntime { continue; } power_wait = true; + if device.boot_epoch.is_empty() { + let active = active_binding_status( + lease.as_ref(), + current.as_ref(), + Some(device.boot_epoch.as_str()), + ); + self.reject_bind( + state, + &device, + Some(reply), + active, + &mut deadline, + anyhow::anyhow!("invalid device boot epoch"), + ); + continue; + } let Some(serial) = device.serial.as_ref() else { let active = active_binding_status( lease.as_ref(), @@ -293,6 +309,22 @@ impl SessionSerialRuntime { ); continue; }; + if !httpboot_protocol::valid_serial_id(&serial.serial_id) { + let active = active_binding_status( + lease.as_ref(), + current.as_ref(), + Some(device.boot_epoch.as_str()), + ); + self.reject_bind( + state, + &device, + Some(reply), + active, + &mut deadline, + anyhow::anyhow!("invalid device serial identity"), + ); + continue; + } if !serial.ready { let active = active_binding_status( lease.as_ref(), diff --git a/ostool-server/tests/axloader_serial.rs b/ostool-server/tests/axloader_serial.rs index 83f6be3c..932fdb6b 100644 --- a/ostool-server/tests/axloader_serial.rs +++ b/ostool-server/tests/axloader_serial.rs @@ -324,6 +324,66 @@ async fn serial_null_session_binds_reuses_uart_and_releases_real_reader() { .await .unwrap_err(); assert!(format!("{error:#}").contains("automatic serial unavailable")); + { + let mut d = device.lock().await; + let mut serial = serial_status(0, 57600); + serial.serial_id = "not-a-serial-id".into(); + d.status.serial = Some(serial); + } + let malformed_identity = LoaderAnnouncement { + protocol_version: DEVICE_PROTOCOL_VERSION, + mac_address: mac, + current_mac_address: mac, + arch: BootArch::X86_64, + loader_version: "test-v6".into(), + boot_epoch: format!("{:032x}", 0), + http_port: device_port, + serial_id: Some("not-a-serial-id".into()), + serial_ready: true, + }; + let malformed_error = ostool_server::device::reconcile( + state.clone(), + malformed_identity, + "127.0.0.1:12345".parse().unwrap(), + ) + .await + .unwrap_err(); + assert!(format!("{malformed_error:#}").contains("invalid device serial identity")); + assert_eq!( + session.serial_runtime.snapshot().phase, + ostool_server::serial::runtime::SerialRuntimePhase::Recovering + ); + assert!(!session.is_stop_requested()); + { + let mut d = device.lock().await; + d.status.boot_epoch.clear(); + d.status.serial = Some(serial_status(0, 57600)); + } + let missing_epoch = LoaderAnnouncement { + protocol_version: DEVICE_PROTOCOL_VERSION, + mac_address: mac, + current_mac_address: mac, + arch: BootArch::X86_64, + loader_version: "test-v6".into(), + boot_epoch: String::new(), + http_port: device_port, + serial_id: Some(format!("{:032x}", 0)), + serial_ready: true, + }; + let missing_epoch_error = ostool_server::device::reconcile( + state.clone(), + missing_epoch, + "127.0.0.1:12345".parse().unwrap(), + ) + .await + .unwrap_err(); + assert!(format!("{missing_epoch_error:#}").contains("invalid device boot epoch")); + assert!(!session.is_stop_requested()); + { + let mut d = device.lock().await; + d.status.boot_epoch = format!("{:032x}", 0); + d.status.serial = Some(serial_status(0, 57600)); + } for (n, baud) in [(1, 57600), (2, 115200)] { if n == 2 { state From 95af5ada2ff9480827b85cd685a3b9f16ceba544 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=91=A8=E7=9D=BF?= Date: Fri, 9 Oct 2026 12:15:05 +0800 Subject: [PATCH 14/14] fix(ostool): align serial epoch and parameter defaults --- docs/axloader-serial-ownership.md | 2 +- ostool-server/src/serial/runtime.rs | 6 ++++-- ostool-server/tests/axloader_serial.rs | 21 +++++++++++++++++++++ ostool/src/axloader.rs | 26 ++++++++++++++++++++++---- 4 files changed, 48 insertions(+), 7 deletions(-) diff --git a/docs/axloader-serial-ownership.md b/docs/axloader-serial-ownership.md index 69cdc984..cee65c69 100644 --- a/docs/axloader-serial-ownership.md +++ b/docs/axloader-serial-ownership.md @@ -88,7 +88,7 @@ ostool axloader run --device http://DEVICE:2999 --kernel kernel.elf \ ostool axloader continue --device http://DEVICE:2999 ``` -`run` 自动配置本地口并输出串口,在交互终端转发键盘输入;Unix 非交互模式只输出,沿用现有终端行为。CLI 对设备状态和管理器绑定请求使用 60 秒总等待期限;每次请求的串口扫描仍按 `max(5 秒, 组合数 × 2 秒)` 结束,遇到 `ready=false`、扫描超时或发现失败会重新读取状态并发起下一次请求。交互输入复用 `sterm::Input` 的可取消非阻塞读取,不留下等待换行的工作线程。Ctrl-C 归还租约,尚在装载器时尽力撤销绑定。`continue` 只执行 direct 放行,调用方随后上传和启动时仍须带设备状态中的匹配令牌。 +`run` 自动配置本地口并输出串口,在交互终端转发键盘输入;Unix 非交互模式只输出,沿用现有终端行为。CLI 使用本次设备上报的 UART 参数;参数缺失时使用协议默认值 `115200 8N1`、无流控。Web UI 的持久化覆盖只属于 server session,CLI 暂不读取该板卡配置。CLI 对设备状态和管理器绑定请求使用 60 秒总等待期限;每次请求的串口扫描仍按 `max(5 秒, 组合数 × 2 秒)` 结束,遇到 `ready=false`、扫描超时或发现失败会重新读取状态并发起下一次请求。交互输入复用 `sterm::Input` 的可取消非阻塞读取,不留下等待换行的工作线程。Ctrl-C 归还租约,尚在装载器时尽力撤销绑定。`continue` 只执行 direct 放行,调用方随后上传和启动时仍须带设备状态中的匹配令牌。 ## 4. 验证与迁移 diff --git a/ostool-server/src/serial/runtime.rs b/ostool-server/src/serial/runtime.rs index 668c80ab..bcd8cc32 100644 --- a/ostool-server/src/serial/runtime.rs +++ b/ostool-server/src/serial/runtime.rs @@ -84,7 +84,7 @@ impl SessionSerialRuntime { return; } let epoch = self.snapshot().boot_epoch; - if epoch.is_some() { + if epoch.as_deref().is_some_and(|epoch| !epoch.is_empty()) { *self.retired_epoch.lock().expect("serial epoch lock") = epoch; } let (reply, rx) = oneshot::channel(); @@ -580,7 +580,9 @@ impl SessionSerialRuntime { status.phase = SerialRuntimePhase::Recovering; status.port = active.port; status.binding_id = active.binding_id; - status.boot_epoch = Some(device.boot_epoch.clone()); + if !device.boot_epoch.is_empty() { + status.boot_epoch = Some(device.boot_epoch.clone()); + } status.parameters = device.serial.as_ref().and_then(|serial| serial.parameters); status.warning = device .serial diff --git a/ostool-server/tests/axloader_serial.rs b/ostool-server/tests/axloader_serial.rs index 932fdb6b..432f2bf0 100644 --- a/ostool-server/tests/axloader_serial.rs +++ b/ostool-server/tests/axloader_serial.rs @@ -379,6 +379,27 @@ async fn serial_null_session_binds_reuses_uart_and_releases_real_reader() { .unwrap_err(); assert!(format!("{missing_epoch_error:#}").contains("invalid device boot epoch")); assert!(!session.is_stop_requested()); + session.serial_runtime.restart(true).await; + let repeated_missing_epoch = LoaderAnnouncement { + protocol_version: DEVICE_PROTOCOL_VERSION, + mac_address: mac, + current_mac_address: mac, + arch: BootArch::X86_64, + loader_version: "test-v6".into(), + boot_epoch: String::new(), + http_port: device_port, + serial_id: Some(format!("{:032x}", 0)), + serial_ready: true, + }; + let repeated_error = ostool_server::device::reconcile( + state.clone(), + repeated_missing_epoch, + "127.0.0.1:12345".parse().unwrap(), + ) + .await + .unwrap_err(); + assert!(format!("{repeated_error:#}").contains("invalid device boot epoch")); + assert!(!session.is_stop_requested()); { let mut d = device.lock().await; d.status.boot_epoch = format!("{:032x}", 0); diff --git a/ostool/src/axloader.rs b/ostool/src/axloader.rs index 8cdf04d0..fdc9926e 100644 --- a/ostool/src/axloader.rs +++ b/ostool/src/axloader.rs @@ -3,7 +3,7 @@ use anyhow::{Context, ensure}; use clap::Subcommand; use httpboot_protocol::{ DEVICE_PROTOCOL_VERSION, DeviceBootImage, DeviceBootJob, LoaderDeviceStatus, SerialBinding, - SerialBindingMode, + SerialBindingMode, SerialParameters, }; use ostool_serial::{BindError, BindRequest, NativeBackend, SerialManager}; use reqwest::Client; @@ -102,9 +102,7 @@ pub async fn execute(command: AxloaderCommand) -> anyhow::Result<()> { ensure_v6(&observed)?; continue; } - let parameters = serial - .parameters - .context("device UART parameters are unknown")?; + let parameters = effective_parameters(serial.parameters); ostool_serial::validate_host_parameters(parameters)?; binding = SerialBinding { serial_id: serial.serial_id.clone(), @@ -284,6 +282,10 @@ fn ensure_v6(status: &LoaderDeviceStatus) -> anyhow::Result<()> { Ok(()) } +fn effective_parameters(reported: Option) -> SerialParameters { + reported.unwrap_or_default() +} + fn retryable_bind_error(error: &BindError) -> bool { matches!( error, @@ -300,6 +302,22 @@ fn image(bytes: &[u8]) -> DeviceBootImage { .collect(), } } + +#[cfg(test)] +mod tests { + use super::effective_parameters; + use httpboot_protocol::{SerialFlowControl, SerialParity, SerialStopBits}; + + #[test] + fn missing_firmware_parameters_use_protocol_defaults() { + let parameters = effective_parameters(None); + assert_eq!(parameters.baud_rate, 115_200); + assert_eq!(parameters.data_bits, 8); + assert_eq!(parameters.parity, SerialParity::None); + assert_eq!(parameters.stop_bits, SerialStopBits::One); + assert_eq!(parameters.flow_control, SerialFlowControl::None); + } +} fn binding_nonce() -> u128 { // A process-local token is a correlation identifier, not an authentication secret. use std::sync::atomic::{AtomicU64, Ordering};