diff --git a/CHANGELOG.md b/CHANGELOG.md index e98b39a..967e296 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,14 @@ - 新增 ZCode 插件清单与仓库市场定义,可从 `woooooooooolf/ser2mcp` 市场安装并自动加载 MCP 服务及两个 SKILL;现有 Reasonix / DSH 接入方式不受影响 +### Changed + +- 长文件传输在宿主并发能力未知时默认推荐根据估算与宿主超时显式设置 `max_duration_ms`;两个 SKILL 增加 5 步最小 happy path、BusyBox `stty raw -echo`、接收端就绪握手、大输出摘要对账和多串口实例提示 + +### Fixed + +- 修复活动文件发送取消收尾期间,`uart_close` 已开始但排队的新写入/交换等普通 I/O 仍可能抢在最终释放端口前执行的竞态;关闭状态现在同步拒绝新的普通 I/O/配置,多个并发 close 等待者也会全部被唤醒,close 返回后端口不会被 `uart_write` 隐式重开 + ## [0.8.7] - 2026-08-18 ### Added diff --git a/README.en.md b/README.en.md index 33a093c..9984253 100644 --- a/README.en.md +++ b/README.en.md @@ -106,10 +106,14 @@ Important semantic boundaries: - `uart_expect_send.newline` applies to `reply`. For a terminal reply, use `reply_mode="text"` with `newline="crlf"` instead of embedding the line ending in the reply text. - By default, `uart_expect` consumes only through the end of the pattern. Follow it with `uart_read` when `pending=true` (equivalent to `buffered_bytes > 0`). `pending=false` is still only an instantaneous snapshot; continue waiting according to the device protocol when later output is required. - Tools with arguments reject unknown fields instead of silently ignoring them. `buffer_size` can only be set by `uart_open`; close and reopen the port to change it. +- Multiple COM entries with the same USB serial may be separate UART instances exposed by one chip. Do not merge them by serial alone; confirm each port name and function. +- Once `uart_close` starts, newly queued ordinary I/O and configuration calls are rejected. `uart_write` never implicitly reopens a port after close; call `uart_open` explicitly before continuing. - `overflow_delta > 0` means that ring-buffer data was overwritten, so the current read has a gap. - The overflow fields from `uart_send_file` are return-time snapshots. Check the latest `overflow_total` with `uart_available` or `uart_read` afterward; zero is not final proof that no overflow occurred. -- `uart_send_file` blocks until it finishes by default. Optional `max_duration_ms` is an explicit automatic safeguard that returns `reason="duration_limit"`; normally, wait for the estimate-based transfer duration. +- `uart_send_file` blocks until it finishes by default. For long transfers or hosts with unknown concurrency support, explicitly set `max_duration_ms` from the estimate and host call timeout; expiry returns `reason="duration_limit"`. +- Before sending a file, have the peer emit a distinct readiness marker after applying its tty mode and immediately before entering the receiver; wait for it with `uart_expect`. Do not stream immediately after the receiver command's `uart_write` returns. - `uart_send_file` returning `reason="completed"` only means that the server finished writing. Confirm end-to-end integrity with peer byte counts and a hash of the decoded content. +- For large output or file reconciliation, have the peer return only `wc -c` plus `sha256sum` (or `md5sum` when unavailable); do not pull the complete content into the agent context. ## Validation and Development diff --git a/README.md b/README.md index 1b92dc9..1a4204e 100644 --- a/README.md +++ b/README.md @@ -106,10 +106,14 @@ Reasonix 与 ZCode 安装插件后会同时获得这两个 SKILL。Claude Code - `uart_expect_send.newline` 作用于 `reply`;终端回复可传 `reply_mode="text"` 和 `newline="crlf"`,不需要把行尾嵌入 reply 文本 - `uart_expect` 默认只消费到 pattern 结尾;返回的 `pending=true`(等价于 `buffered_bytes > 0`)时补一次 `uart_read`。`pending=false` 仍只是瞬时快照;确实需要 pattern 后的未来输出时继续按协议等待 - 带参数工具的未知字段会报错,不再静默忽略;`buffer_size` 只能在 `uart_open` 时设置,需调整时先关闭再重新打开端口 +- `uart_list_ports` 中相同 USB serial 的多个 COM 项可能是同一芯片的多个串口实例;不能只凭 serial 合并,应按端口名与功能确认 +- `uart_close` 一经开始会拒绝排队的新普通 I/O/配置;返回后 `uart_write` 不会隐式重开端口,继续操作必须显式 `uart_open` - `overflow_delta > 0` 表示环形缓冲已有数据被覆盖,当前读取结果存在缺口 - `uart_send_file` 的 overflow 是返回时快照;返回后用 `uart_available` / `uart_read` 再确认最新 `overflow_total`,0 不代表最终无溢出 -- `uart_send_file` 默认同步阻塞至结束;可选 `max_duration_ms` 只在显式设置时自动止损并返回 `reason="duration_limit"`,通常仍应根据估算等待完成 +- `uart_send_file` 默认同步阻塞至结束;长传输或宿主并发能力未知时,默认根据估算与宿主调用超时显式设置 `max_duration_ms`,到限返回 `reason="duration_limit"` +- 文件发送前应让对端在完成 tty 模式切换后、紧邻接收命令前输出独立就绪标记,`uart_expect` 命中后再发送;不要在接收命令的 `uart_write` 返回后立刻推流 - `uart_send_file` 的 `reason="completed"` 只表示服务器已完成写入;端到端完整性必须用对端长度和解码后哈希确认 +- 大输出或文件对账让板端只返回 `wc -c` 与 `sha256sum`(不可用时 `md5sum`)摘要,不要把完整内容读进 Agent 上下文 ## 验证与开发 diff --git a/skills/ser2mcp-file-transfer/SKILL.md b/skills/ser2mcp-file-transfer/SKILL.md index 073639c..8467e5c 100644 --- a/skills/ser2mcp-file-transfer/SKILL.md +++ b/skills/ser2mcp-file-transfer/SKILL.md @@ -5,16 +5,28 @@ description: 使用 ser2mcp 经 UART/COM 串口发送本地文件或固件。用 # ser2mcp 文件传输 +## 5 步最小 happy path + +以下按长度接收的 Linux Shell 模板适合原始二进制;先把 `N`、路径、端口和时限替换为估算结果与实际值: + +1. `uart_send_estimate {path: "C:/tmp/fw.bin", mode: "text", chunk_size: 256, baudrate: 115200}`。 +2. `uart_expect {port: "COM3", data: "stty raw -echo; printf SER2MCP_%s RX_READY; dd bs=1 count=N of=/tmp/fw.bin", mode: "text", newline: "lf", pattern: "SER2MCP_RX_READY", pattern_mode: "text", match_scope: "new", read_mode: "text-escaped"}`,确认 tty 已切换且即将进入接收命令。 +3. 长传输调用 `uart_send_file {port: "COM3", path: "C:/tmp/fw.bin", mode: "text", chunk_size: 256, max_duration_ms: ...}`;时限按下文规则由估算和宿主超时确定。 +4. 接收结束后执行 `stty sane; wc -c /tmp/fw.bin; sha256sum /tmp/fw.bin`(无 `sha256sum` 时用 `md5sum`),只读取长度和哈希摘要并与源文件对账。 +5. 调用 `uart_available {port: "COM3"}` 检查最终 `overflow_total`,然后 `uart_close {port: "COM3"}`。 + ## 必须遵守 - 先确认用户授权把指定本地文件发送到目标设备。`path` 可指向 ser2mcp 进程有权访问的任意普通文件,服务端不限制目录。 - 先调用 `uart_send_estimate`,向用户说明预计字节数和耗时,再调用一次 `uart_send_file`;不要循环调用 `uart_write`。 -- `uart_send_file` 默认同步阻塞至结束;估算可接受时优先等待完成,不要把取消或短时限当作常规分段机制。 +- `uart_send_file` 默认同步阻塞至结束。预计为长传输(例如至少 30 秒)或宿主并发能力未知时,默认显式设置 `max_duration_ms` 自动止损;不要把取消或短时限当作常规分段机制。 - 根据对端缓冲能力选择 `chunk_size`,不确定时从默认值 256 开始;无流控时宁小勿大。 - 把 `reason` 解释为服务器端结束状态,不解释为对端完整接收。 - 传输完成后在对端核对字节数和解码后哈希;只有对账一致才能确认端到端完整性。 +- 大输出只读取板端 `wc -c` 与 `sha256sum`(不可用时 `md5sum`)结果,不要把接收文件或完整日志读进 Agent 上下文。 - `uart_send_file` 返回后立即调用 `uart_available` 或 `uart_read`,以最新 `overflow_total` 确认上行缓冲是否覆盖;发送返回中的 0 不是最终无溢出证明。 - ser2mcp 不主动发送 EOF。开始发送前先确定对端按长度结束,还是需要调用方另发 EOF。 +- 准备对端时先让它在 tty 模式切换后、紧邻接收命令前输出独立的就绪标记,并用 `uart_expect` 命中后再发送文件;只调用 `uart_write` 后立刻发送会与板端执行 `stty`/进入接收命令竞态。 ## 执行流程 @@ -22,14 +34,14 @@ description: 使用 ser2mcp 经 UART/COM 串口发送本地文件或固件。用 2. 确定对端接收方式: - 对端可按确定长度读取:优先用 `dd bs=1 count=N`,无需 EOF。 - 对端使用 icanon `cat`:可用 base64,并在发送后另发 `\x04` 结束输入。 - - 对端需要原始二进制:先关闭 tty 字节转换,例如 Linux 使用 `stty raw`。 + - 对端需要原始二进制:先关闭 tty 字节转换和回显,例如 Linux 使用 `stty raw -echo`;BusyBox 的 `raw` 不保证关闭 echo。 3. 调用估算: ```text uart_send_estimate {path, mode?, chunk_size?, gap_ms?, baudrate?} ``` -4. 准备对端接收,再调用一次: +4. 准备对端接收并等待它输出就绪标记,再调用一次: ```text uart_send_file {port, path, mode?, chunk_size?, gap_ms?, max_duration_ms?} @@ -58,11 +70,13 @@ description: 使用 ser2mcp 经 UART/COM 串口发送本地文件或固件。用 | `mode="base64"` | 连续编码整个文件,padding 仅在 EOF;每 76 字符换行,末尾补 `\n`。适合文本安全通道或 icanon 行缓冲。 | | `chunk_size` | 原始文件分片大小,默认 256,范围 `1..=1 MiB`。应不大于对端可安全接收的缓冲;base64 输出约为原始数据的 1.34 倍并含换行。 | | `gap_ms` | 分片间隔,默认 0,最大 60000。仅在设备处理能力低于串口持续输入速率时增加。 | -| `max_duration_ms` | 可选自动止损时限,默认不限制。仅在不能接受无限等待或调用预算明确时设置;达到后在检查点返回部分进度。 | +| `max_duration_ms` | 可选自动止损时限,默认不限制。长传输或宿主并发能力未知时默认显式设置;达到后在检查点返回部分进度。 | | `baudrate` | 只用于估算,默认 115200;应与实际串口波特率一致。 | 理论参考:1 MiB @ 115200 时,text 下限约 91 秒,base64 约 123 秒,均未计 flush、调度和 `gap_ms` 开销。以 `uart_send_estimate` 的当前结果为准。 +长传输的时限选择:先用 estimate 取得 `est_time_ms`。若宿主工具超时已知,先确保它大于完整传输估算;`max_duration_ms` 可从 `max(est_time_ms × 1.5, est_time_ms + 30000)` 起步,并至少给宿主保留 5–10 秒返回余量。若宿主超时容不下估算与余量,应先提高宿主超时或缩小任务,而不是设置一个必然截断的短时限;被截断的 base64 流不能假定可直接续传。 + ## 解释发送结果 - `reason="completed"`:服务器发送循环已把全部输出字节写入串口驱动;仍需对端对账。 @@ -75,17 +89,17 @@ description: 使用 ser2mcp 经 UART/COM 串口发送本地文件或固件。用 - `chunks`:已完成写入的输出分片数。 - `overflow_delta` / `overflow_total`:从发送开始到生成返回时,读线程已观察到的上行环形缓冲覆盖快照。串口驱动或线路中的尾部字节可能在返回后继续推高计数;即使返回 0,也要用随后的 `uart_available` / `uart_read` 获取最新 `overflow_total`。这些字段不是下行传输完整性证明。 -发送期间服务端允许用 `uart_available` 查看 `send.active`、`sent_bytes`、`total_bytes`、`chunks` 和 `last_reason`,也允许 `uart_send_cancel` 请求取消;宿主必须支持并发调用,或在前一次会话/任务停止等待后仍能访问同一 ser2mcp 服务。严格串行且持续等待当前调用的宿主无法同时发出取消,此时依赖事前估算或显式 `max_duration_ms`。普通 I/O、配置和 expect 调用仍会等待全局 I/O 锁。 +发送期间服务端允许用 `uart_available` 查看 `send.active`、`sent_bytes`、`total_bytes`、`chunks` 和 `last_reason`,也允许 `uart_send_cancel` 请求取消;宿主必须支持并发调用,或在前一次会话/任务停止等待后仍能访问同一 ser2mcp 服务。严格串行且持续等待当前调用的宿主无法同时发出取消,此时长传输必须依赖事前估算和显式 `max_duration_ms`。普通 I/O、配置和 expect 调用仍会等待全局 I/O 锁。 ## 对端接收示例 Base64 写入 Linux 文件: ```text -uart_write {port, data: "stty -echo; cat > /tmp/f.b64", mode: "text", newline: "lf"} +uart_expect {port, data: "stty -echo; printf SER2MCP_%s RX_READY; cat > /tmp/f.b64", mode: "text", newline: "lf", pattern: "SER2MCP_RX_READY", pattern_mode: "text", match_scope: "new", read_mode: "text-escaped"} uart_send_file {port, path: "C:/tmp/fw.bin", mode: "base64", chunk_size: 256} uart_write {port, data: "04"} -uart_exchange {port, data: "wc -c /tmp/f.b64; base64 -d < /tmp/f.b64 | sha256sum", mode: "text", newline: "lf", read_mode: "text-escaped"} +uart_exchange {port, data: "stty sane; wc -c /tmp/f.b64; base64 -d < /tmp/f.b64 | sha256sum", mode: "text", newline: "lf", read_mode: "text-escaped"} ``` 确认对端编码文件字节数等于 `sent_bytes`,解码后 SHA-256 等于源文件。 @@ -93,9 +107,9 @@ uart_exchange {port, data: "wc -c /tmp/f.b64; base64 -d < /tmp/f.b64 | sha256sum 原始二进制按长度接收: ```text -uart_write {port, data: "stty raw; dd bs=1 count=65536 of=/tmp/f.bin", mode: "text", newline: "lf"} +uart_expect {port, data: "stty raw -echo; printf SER2MCP_%s RX_READY; dd bs=1 count=65536 of=/tmp/f.bin", mode: "text", newline: "lf", pattern: "SER2MCP_RX_READY", pattern_mode: "text", match_scope: "new", read_mode: "text-escaped"} uart_send_file {port, path: "C:/tmp/f.bin", mode: "text", chunk_size: 1024} uart_exchange {port, data: "stty sane; wc -c /tmp/f.bin; sha256sum /tmp/f.bin", mode: "text", newline: "lf", read_mode: "text-escaped"} ``` -把 `count` 设为源文件精确字节数。默认 tty 的 IXON、ICRNL 等转换会破坏任意二进制;发送原始字节前确认已进入 raw 模式。启动接收命令时使用 `newline="lf"`,避免 `\r\n` 中残留的 `\n` 被文件接收程序读入。 +把 `count` 设为源文件精确字节数。默认 tty 的 IXON、ICRNL 等转换会破坏任意二进制;发送原始字节前必须等到 raw/-echo 切换后的独立就绪标记,不能在接收命令 `uart_write` 返回后立刻发送。启动接收命令时使用 `newline="lf"`,避免 `\r\n` 中残留的 `\n` 被文件接收程序读入。对账只读取 `wc -c` 和哈希摘要;BusyBox 缺少 `sha256sum` 时可改用 `md5sum`,并与源文件的同算法结果比较。 diff --git a/skills/ser2mcp-usage/SKILL.md b/skills/ser2mcp-usage/SKILL.md index 246a557..d4a0b83 100644 --- a/skills/ser2mcp-usage/SKILL.md +++ b/skills/ser2mcp-usage/SKILL.md @@ -5,15 +5,27 @@ description: 通过 ser2mcp 的 uart_* MCP 工具操作 UART/COM 串口设备。 # ser2mcp 串口操作 +## 5 步最小 happy path + +以下 Linux Shell 示例可直接作为起点;把 `COM3`、波特率、命令、行尾和完成 pattern 换成目标设备的实际协议: + +1. `uart_list_ports {}`,按端口名和用途选择目标,不只按 USB serial 合并。 +2. `uart_open {port: "COM3", baudrate: 115200}`。 +3. `uart_expect {port: "COM3", data: "printf 'SER2MCP_%s\\n' OK", mode: "text", newline: "lf", pattern: "SER2MCP_OK", pattern_mode: "text", match_scope: "new", read_mode: "text-escaped"}`。 +4. 确认 `matched=true` 且 `overflow_delta=0`;仅当 `pending=true` 时补一次 `uart_read {port: "COM3", read_mode: "text-escaped"}`。 +5. `uart_close {port: "COM3"}`。 + ## 必须遵守 - 按 `uart_list_ports → uart_open → 交互 → uart_close` 操作;重复打开同一端口前先关闭。 +- `uart_list_ports` 中相同 USB serial 对应多个 COM 项时,可能是同一芯片暴露的多个串口实例;保留每个端口名并按实际功能逐一确认。 - 除 `uart_list_ports` 和 `uart_send_estimate` 外,调用时都传 `port`。 - 一次只发送一条命令,并用设备协议定义的响应特征判断完成;不要用 sleep 盲等。 - 把 `matched=true` 解释为“pattern 按所选原始字节/忽略 ANSI 语义在匹配范围内命中”,不要直接解释为当前事务成功。 - 终端命令和 `uart_expect_send.reply` 显式带行尾。通常用 `newline="crlf"`;已知设备只需 LF 时用 `lf`。 - 把 `reason="idle"` 解释为“字节流暂时静默”,不要解释为命令已完成。 - 检查每次读取结果的 `overflow_delta`;大于 0 表示数据已被覆盖,当前结果有缺口。 +- 大输出优先让板端重定向到文件,再只读 `wc -c` 与 `sha256sum`(不可用时 `md5sum`)摘要对账;不要把完整内容读进 Agent 上下文。 - 大文件或固件使用 `ser2mcp-file-transfer`,不要循环调用 `uart_write`。 ## 选择工具 @@ -105,6 +117,7 @@ uart_expect_send {port: "COM3", pattern: "Hit any key", pattern_mode: "text", re - 需要显式结束标记且不能关闭回显:把标记拆开写在命令中,例如等待 `SLEEP-DONE-MARK` 时发送 `sleep 8; printf '%s%s\n' 'SLEEP-DONE-' 'MARK'`。回显不含连续的完整 pattern,实际输出才包含。 - 需要清除板端当前输入行:仅在确认 tty 为 icanon 时发送 `\x15`(Ctrl+U);需要中断当前命令时可发送 `\x03`(Ctrl+C)。`uart_clear` 只清宿主缓冲,不清板端状态。 - 输出缺失或设备拔出:调用 `uart_available` 检查 `read_error` 和 `overflow_total`。 +- `uart_close` 已开始时,新的普通 I/O/配置会报错;`closed=true` 返回后端口保持关闭,`uart_write` 不会隐式重开,继续操作前必须显式 `uart_open`。 ## 资源边界 @@ -113,4 +126,4 @@ uart_expect_send {port: "COM3", pattern: "Hit any key", pattern_mode: "text", re - 所有带参数的工具都拒绝未知字段;拼写错误或把 `buffer_size` 传给 `uart_configure` 会返回参数错误 - read/exchange/expect `timeout_ms`:最大 `300000` - expect pattern:编码后最大 `64 KiB` -- 普通 I/O、配置、expect 和 close 共享全局 I/O 锁;文件发送期间会排队。`uart_available` / `uart_clear` 不持有该锁;宿主允许并发或后续任务仍能访问同一服务时,`uart_send_cancel` 可请求取消。 +- 普通 I/O、配置、expect 和 close 共享全局 I/O 锁;文件发送期间会排队。关闭一经开始,排队的新普通 I/O/配置会被拒绝。`uart_available` / `uart_clear` 不持有该锁;宿主允许并发或后续任务仍能访问同一服务时,`uart_send_cancel` 可请求取消。 diff --git a/src/manager.rs b/src/manager.rs index ac27006..8fedfff 100644 --- a/src/manager.rs +++ b/src/manager.rs @@ -9,8 +9,9 @@ //! ``` //! - 读线程只做"读串口 → 写缓冲",永不阻塞在向 host 发送上; //! - 缓冲写满后覆盖最旧数据并累计溢出计数,数据缺口可被上层检测; -//! - 写/读/交换/配置/期待/文件发送/关闭经 `io_lock` 串行化;available/clear/cancel -//! 不持有该锁,可在文件发送期间查询、清缓冲或请求取消; +//! - 写/读/交换/配置/期待/文件发送/关闭经 `io_lock` 串行化;关闭一经开始,后续 +//! 排队的普通 I/O/配置会拒绝执行,避免在释放端口前产生新的外部副作用; +//! - available/clear/cancel 不持有该锁,可在文件发送期间查询、清缓冲或请求取消; //! - 文件发送每片检查点检测:可选时限、取消标志(uart_send_cancel / uart_close)、 //! 客户端取消令牌、端口是否仍打开、读线程致命错误(设备物理断开等)。 @@ -339,7 +340,7 @@ impl SendState { p.chunks = 0; } - /// 发送循环退出:记录结束原因并唤醒等待者(`uart_close` 中断路径)。 + /// 发送循环退出:记录结束原因并唤醒所有等待者(并发 `uart_close` 中断路径)。 fn finish(&self, reason: &str, sent_bytes: u64, chunks: u64) { { let mut p = self.progress.lock().unwrap(); @@ -348,7 +349,7 @@ impl SendState { p.sent_bytes = sent_bytes; p.chunks = chunks; } - self.done.notify_one(); + self.done.notify_waiters(); } fn update(&self, sent_bytes: u64, chunks: u64) { @@ -357,11 +358,15 @@ impl SendState { p.chunks = chunks; } - /// 等待当前发送退出。仅应在已 `cancel()` 且 `is_active()` 为 true 时调用; - /// tokio Notify 会保留已发出的通知(permit),不会错过退出瞬间。 + /// 等待当前发送退出。先注册通知再检查状态,避免 `finish()` 恰好发生在状态检查 + /// 与等待注册之间时丢失唤醒;`notify_waiters` 同时支持多个并发 close 等待者。 async fn wait_done(&self) { - while self.is_active() { - self.done.notified().await; + loop { + let notified = self.done.notified(); + if !self.is_active() { + return; + } + notified.await; } } } @@ -404,10 +409,24 @@ struct ActivePort { last_overflow: Arc>, /// 文件发送会话状态(进度/取消/完成通知)。 send: Arc, - /// 关闭已开始;阻止新的文件发送会话在关闭等待期间插入。 + /// 关闭已开始;阻止新的普通 I/O/配置在关闭等待期间插入。 closing: AtomicBool, } +impl ActivePort { + /// 关闭开始后,不允许排队的普通 I/O/配置在最终释放句柄前继续执行。 + fn ensure_io_allowed(&self) -> Result<(), String> { + if self.closing.load(Ordering::SeqCst) { + Err(format!( + "端口 {} 正在关闭,拒绝新的 I/O;请等待 uart_close 返回后再按需 uart_open", + self.port_name + )) + } else { + Ok(()) + } + } +} + /// 串口管理器。 #[derive(Default)] pub struct SerialManager { @@ -557,6 +576,7 @@ impl SerialManager { let ap = ports .get_mut(port_name) .ok_or_else(|| format!("端口 {port_name} 未打开,请先调用 uart_open"))?; + ap.ensure_io_allowed()?; { let mut port = ap.port.lock().unwrap(); if let Some(v) = baudrate { @@ -600,6 +620,7 @@ impl SerialManager { let ap = ports .get(port_name) .ok_or_else(|| format!("端口 {port_name} 未打开,请先调用 uart_open"))?; + ap.ensure_io_allowed()?; write_locked(&ap.port, data) } @@ -619,6 +640,7 @@ impl SerialManager { let ap = ports .get(port_name) .ok_or_else(|| format!("端口 {port_name} 未打开,请先调用 uart_open"))?; + ap.ensure_io_allowed()?; (ap.buffer.clone(), ap.last_overflow.clone(), ap.port.clone()) }; // 历史未读数据仍随本次结果返回,但不能让它在新命令响应到达前立即满足 @@ -662,21 +684,18 @@ impl SerialManager { ct: Option<&CancellationToken>, ) -> Result { let _guard = self.io_lock.lock().await; - let (port, buffer, send, closing) = { + let (port, buffer, send) = { let ports = self.ports.lock().unwrap(); let ap = ports .get(port_name) .ok_or_else(|| format!("端口 {port_name} 未打开,请先调用 uart_open"))?; - ( - ap.port.clone(), - ap.buffer.clone(), - ap.send.clone(), - ap.closing.load(Ordering::SeqCst), - ) + ap.ensure_io_allowed()?; + // 与 closing 检查在同一个端口表临界区内发布 active。这样 close 要么先 + // 置 closing,令本次发送直接报错;要么随后必定观察到 active 并取消, + // 不会落入“检查时未 active、随后开始整次发送”的窗口。 + ap.send.begin(total_bytes); + (ap.port.clone(), ap.buffer.clone(), ap.send.clone()) }; - if closing { - return Err(format!("端口 {port_name} 正在关闭")); - } // 文件发送不消费上行缓冲,也不应借用 last_overflow(读取工具的消费基线) // 计算增量。直接对环形缓冲的单调累计计数取调用前后快照,才能报告发送期间 // 已被读线程观察到的覆盖。返回后,仍在串口驱动/线路中的字节可能继续推高 @@ -684,7 +703,6 @@ impl SerialManager { let overflow_before = buffer.stats().1; let started = Instant::now(); let max_duration = max_duration_ms.map(Duration::from_millis); - send.begin(total_bytes); let mut sent_bytes = 0u64; let mut chunks_done = 0u64; let mut reason = "completed"; @@ -842,6 +860,7 @@ impl SerialManager { let ap = ports .get(port_name) .ok_or_else(|| format!("端口 {port_name} 未打开,请先调用 uart_open"))?; + ap.ensure_io_allowed()?; (ap.buffer.clone(), ap.last_overflow.clone(), ap.port.clone()) }; @@ -943,6 +962,7 @@ impl SerialManager { let ap = ports .get(port_name) .ok_or_else(|| format!("端口 {port_name} 未打开,请先调用 uart_open"))?; + ap.ensure_io_allowed()?; (ap.buffer.clone(), ap.last_overflow.clone(), ap.port.clone()) }; self.read_locked( @@ -1176,3 +1196,34 @@ fn stop_bits_to_u8(v: StopBits) -> u8 { StopBits::Two => 2, } } + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn send_state_wakes_all_close_waiters() { + let send = Arc::new(SendState::new()); + send.begin(1024); + + let waiter_a = { + let send = send.clone(); + tokio::spawn(async move { send.wait_done().await }) + }; + let waiter_b = { + let send = send.clone(); + tokio::spawn(async move { send.wait_done().await }) + }; + + // 让两个任务都进入 wait_done;修复前 notify_one 只能唤醒其中一个。 + tokio::time::sleep(Duration::from_millis(10)).await; + send.finish("cancelled", 0, 0); + + tokio::time::timeout(Duration::from_secs(1), async { + waiter_a.await.expect("第一个 close 等待任务不应崩溃"); + waiter_b.await.expect("第二个 close 等待任务不应崩溃"); + }) + .await + .expect("所有并发 close 等待者都应被唤醒"); + } +} diff --git a/src/server.rs b/src/server.rs index 82c1365..867ea82 100644 --- a/src/server.rs +++ b/src/server.rs @@ -48,6 +48,7 @@ const INSTRUCTIONS: &str = r##"ser2mcp:UART 串口 MCP 服务器,原样透 核心流程:uart_list_ports → uart_open {port} → 交互 → uart_close {port}。 - 除 uart_list_ports 和 uart_send_estimate 外,其余工具都需要 port;重复打开前先关闭。 +- uart_list_ports 中相同 USB serial 对应多个 COM 项时,可能是同一芯片暴露的多个串口实例,不能只凭 serial 合并;按端口名与实际功能逐一确认。 - 二进制使用 mode/read_mode="hex";终端命令和 uart_expect_send.reply 使用 text 时都应显式指定 newline,读取优先用 read_mode="text-escaped"。 - 有设备协议定义的响应特征时用 uart_expect。matched=true 只证明 pattern 按所选原始字节/忽略 ANSI 语义命中,不代表事务成功;按终端提示符、AT 状态码、事务标识或二进制帧字段选择锚点。 - 需要命中即回复时用 uart_expect_send;只有无稳定锚点的短响应才用 uart_exchange 的 idle 收尾。uart_exchange 保留并返回历史缓冲,但会先等到本次写入后至少一批新上行数据,才允许 idle/max_bytes 收尾。 @@ -61,9 +62,11 @@ const INSTRUCTIONS: &str = r##"ser2mcp:UART 串口 MCP 服务器,原样透 文件发送: - 先确认本地 path 与目标设备均在用户授权范围内;服务端可读取进程有权访问的任意普通文件,不限制目录。 - 按 uart_send_estimate → 准备对端 → uart_send_file 一次调用 → EOF/按长度结束 → 对端长度和哈希对账执行;不要循环 uart_write。 +- 准备对端时,让它在 tty 模式切换后、紧邻接收命令前输出独立就绪标记;用 uart_expect 命中后再 send_file,不能在接收命令的 uart_write 返回后立刻推流。 - reason 只表示服务器端结束状态;completed 不代表对端完整接收。base64 的 sent_bytes 包含编码和换行,不能与 raw_bytes 直接比较。 - send_file 的 overflow 字段是生成返回时的上行缓冲快照,0 不等于最终无溢出;返回后再调用 uart_available / uart_read 确认最新 overflow_total。 -- ser2mcp 不主动发送 EOF。uart_send_file 默认同步阻塞至结束;可显式设置 max_duration_ms 自动止损。发送期间普通 I/O/配置/expect 会等待全局 I/O 锁;宿主支持并发或后续请求仍可访问同一服务时,uart_available 可查进度,uart_send_cancel 或目标端口 uart_close 可中止。 +- ser2mcp 不主动发送 EOF。uart_send_file 默认同步阻塞至结束;长传输或宿主并发能力未知时,默认根据 estimate 与宿主调用超时显式设置 max_duration_ms 自动止损。发送期间普通 I/O/配置/expect 会等待全局 I/O 锁;宿主支持并发或后续请求仍可访问同一服务时,uart_available 可查进度,uart_send_cancel 或目标端口 uart_close 可中止。 +- uart_close 一经开始会拒绝排队的新普通 I/O/配置;返回 closed=true 后端口保持关闭,uart_write 等不会自动重开,必须显式 uart_open。 详细决策与故障处理见 ser2mcp-usage SKILL;文件/固件传输见 ser2mcp-file-transfer SKILL。"##; @@ -272,8 +275,9 @@ pub struct SendFileArgs { pub chunk_size: Option, /// 片间间隔(毫秒),默认 0,上限 60000(每片写完 flush 已天然限速到波特率上限)。 pub gap_ms: Option, - /// 可选自动止损时限(毫秒,必须 >= 1)。默认不限制并保持阻塞等待;达到时限后 - /// 在下一个分片或间隔检查点停止并返回 reason="duration_limit"。 + /// 可选自动止损时限(毫秒,必须 >= 1)。默认不限制并保持阻塞等待;长传输或 + /// 宿主并发能力未知时建议根据 estimate 与宿主调用超时显式设置。达到时限后在 + /// 下一个分片或间隔检查点停止并返回 reason="duration_limit"。 pub max_duration_ms: Option, } @@ -510,7 +514,7 @@ impl Ser2Mcp { /// 枚举本机当前可用的串口。 #[tool( - description = "枚举本机当前可用的串口(名称、类型、USB 描述)。串口被占用时可能不出现。" + description = "枚举本机当前可用的串口(名称、类型、USB 描述)。串口被占用时可能不出现;相同 USB serial 对应多个 COM 项时,可能是同一芯片暴露的多个串口实例,不能只凭 serial 合并。" )] async fn uart_list_ports(&self) -> Result { match self.manager.list_ports() { @@ -645,7 +649,7 @@ impl Ser2Mcp { /// 向串口发送数据(只发不等回复),返回实际写入字节数。 #[tool( - description = "向串口发送数据并立即返回(port 必填,不等待回复);如需发送+读取请用 uart_exchange。" + description = "向已打开串口发送数据并立即返回(port 必填,不等待回复,不会自动打开端口);端口未打开或正在关闭时报错。如需发送+读取请用 uart_exchange。" )] async fn uart_write( &self, @@ -1147,7 +1151,7 @@ impl Ser2Mcp { /// 关闭串口:若目标端口正在发送文件,先请求取消并等待发送退出(最长 30 秒), /// 再停止并回收读线程、释放端口句柄。 #[tool( - description = "关闭指定串口并释放端口(port 必填;后续可重新 uart_open)。目标端口正在 uart_send_file 时会先请求取消并等待其退出(最长 30 秒),再关闭端口。" + description = "关闭指定串口并释放端口(port 必填;后续可重新 uart_open)。关闭一经开始会拒绝排队的新普通 I/O/配置;目标端口正在 uart_send_file 时会先请求取消并等待其退出(最长 30 秒),再关闭端口。" )] async fn uart_close( &self, diff --git a/tests/loopback.rs b/tests/loopback.rs index 19c699c..7ec502d 100644 --- a/tests/loopback.rs +++ b/tests/loopback.rs @@ -155,6 +155,20 @@ async fn wait_for_buffered(client: &rmcp::Peer, port: &str, mi panic!("等待回环数据进入缓冲超时"); } +async fn wait_for_send_active(client: &rmcp::Peer, port: &str) { + for _ in 0..100 { + let r = call(client, "uart_available", json!({"port": port})) + .await + .expect("uart_available 调用失败"); + let v = r.structured_content.expect("应有结构化返回"); + if v["send"]["active"] == json!(true) { + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!("等待文件发送进入 active 状态超时"); +} + #[tokio::test] #[ignore = "需要真实回环硬件(TX-RX 短接)"] async fn loopback_send_file_all() { @@ -560,7 +574,7 @@ async fn loopback_send_file_all() { assert_eq!(available["send"]["active"], json!(false)); assert_eq!(available["send"]["last_reason"], json!("duration_limit")); - // ============ 场景 7:uart_close 并发中断 ============ + // ============ 场景 7:uart_close 并发中断,close→write 不产生副作用 ============ let client3 = client.clone(); let port3 = port.clone(); let path3 = tmp.path.clone(); @@ -568,13 +582,47 @@ async fn loopback_send_file_all() { call( &client3, "uart_send_file", - json!({"port": port3, "path": path3, "chunk_size": 256, "gap_ms": 100}), + json!({"port": port3, "path": path3, "chunk_size": 4096, "gap_ms": 60000}), ) .await }); - tokio::time::sleep(Duration::from_millis(600)).await; - let r = call(&client, "uart_close", json!({"port": port})) + wait_for_send_active(&client, &port).await; + + let close_client = client.clone(); + let close_port = port.clone(); + let close_task = tokio::spawn(async move { + call(&close_client, "uart_close", json!({"port": close_port})).await + }); + + // 在 close 与 write 并发交叠时,write 要么观察到 closing,要么观察到端口已经 + // 移除;两种结果都必须报错,不能抢在最终释放前产生外部副作用或隐式重开。 + tokio::time::sleep(Duration::from_millis(20)).await; + + let r = call( + &client, + "uart_write", + json!({"port": port, "data": "CLOSE-RACE-WRITE", "mode": "text"}), + ) + .await + .expect("关闭期间 uart_write 调用失败"); + assert!( + r.is_error.unwrap_or(false), + "关闭开始后的 uart_write 必须拒绝执行: {r:?}" + ); + let error = r + .structured_content + .as_ref() + .and_then(|v| v.get("error")) + .and_then(Value::as_str) + .unwrap_or_default(); + assert!( + error.contains("正在关闭") || error.contains("未打开"), + "close/write 交叠时应明确报告关闭中或已关闭: {r:?}" + ); + + let r = close_task .await + .expect("uart_close task 崩溃") .expect("uart_close 调用失败"); assert_eq!( r.structured_content.as_ref().and_then(|v| v.get("closed")), @@ -598,6 +646,27 @@ async fn loopback_send_file_all() { let v = r.structured_content.expect("应有结构化返回"); assert_eq!(v["open"], json!(false), "close 后端口应已关闭"); + let r = call( + &client, + "uart_write", + json!({"port": port, "data": "AFTER-CLOSE", "mode": "text"}), + ) + .await + .expect("close 返回后的 uart_write 调用失败"); + assert!( + r.is_error.unwrap_or(false), + "close 返回后的 uart_write 必须报未打开,不能隐式重开: {r:?}" + ); + assert!( + r.structured_content + .as_ref() + .and_then(|v| v.get("error")) + .and_then(Value::as_str) + .unwrap_or_default() + .contains("未打开"), + "close 返回后的错误应明确说明端口未打开: {r:?}" + ); + // ============ 场景 8:原始 JSON-RPC 取消通知(notifications/cancelled) ============ raw_cancelled_notification_test(&port, &tmp.path).await;