Skip to content

feat(cli): add JSON Lines control channel for headless frontends - #363

Open
TimmySheep wants to merge 1 commit into
MicYou-Dev:masterfrom
TimmySheep:feat/cli-jsonl-control-channel
Open

TimmySheep wants to merge 1 commit into
MicYou-Dev:masterfrom
TimmySheep:feat/cli-jsonl-control-channel

Conversation

@TimmySheep

Copy link
Copy Markdown

背景

micyou-cli serve 目前只输出面向终端的人类可读日志,缺少供第三方前端与自动化脚本使用的运行时控制接口。

变更

  • 增加 opt-in serve --jsonl;默认 CLI 行为与现有文本输出保持不变。
  • 通过 stdin/stdout 提供带版本号和请求 ID 的双向 JSON Lines 协议;诊断日志写入 stderr。
  • 暴露静音、监听、DSP、频谱、连接配置和设备查询命令,并转发服务事件。
  • 对输入行和事件队列设限、节流遥测,并使用专用 stdin 读取线程避免不可取消的异步 stdin 读取妨碍退出。

本地验证

  • cargo test --locked -p micyou-cli:3 项通过。
  • cargo build --locked -p micyou-cli 与 cargo build --locked -p micyou-cli --release:通过。
  • cargo clippy --locked --workspace --all-targets:通过。
  • bun install --frozen-lockfile 与 bun run build:通过。
  • ./gradlew :composeApp:assembleDebug:通过(JDK 21、项目 Gradle 9.8 wrapper、Android API 37)。
  • cargo test --locked -- --test-threads=1:137/141 通过。1 个文件名大小写查找测试和 3 个 UDP 测试(WouldBlock)在未打补丁的固定基线也复现,未归因于此改动。

Windows 构建及真实设备音频端到端尚未验证;请以 CI 结果补充跨平台验收。

@micyou-automation micyou-automation Bot added waitmerge 等待合并 size/xl 500-999 行 labels Oct 8, 2026
@OrientCOMPASS

Copy link
Copy Markdown
Contributor

感谢这个 PR,方向我是认可的:给 serve 加一条 opt-in 的机器可读通道、默认行为完全不变、stdout/stderr/stdin 三条流职责清晰,这些都是对的。几个设计细节也看得出踩过坑——输入行 64 KiB 硬上限、遥测走 try_send 不阻塞音频回调、ready 之前的事件先缓冲再补发、用专用线程读 stdin 绕开 tokio 那个不可取消的阻塞读(注释还写清了原因),这些我都很满意。micyou-cli 此前零测试,这次带进来 3 个也是好事。

但这个 PR 现在不能合:我在真实二进制上实测确认,serve --jsonl 在所有退出路径上都会永久挂死,进程无法结束。这条不修,整个特性是不可用的。下面按优先级列出问题,都附了复现步骤,麻烦你自己跑一遍确认。


环境说明

Linux x86_64 / rustc 1.99.0 / debug 构建。沙箱里没有真实声卡,只有 ALSA 的 null 设备,服务能正常起来(ready 0.2s 内到达)。你自述的 cargo test(3 passed)、cargo build、clippy 全绿我都复核为真。


🔴 阻塞:进程在任何退出路径上都不退出

现象

serve.rs 里 drop(events); drop(output); 之后 writer.join(),而写线程的循环是 while let Ok(frame) = receiver.recv()(jsonl.rs:37)。mpsc::Receiver::recv() 只有在所有 SyncSender 都被 drop 之后才返回 Disconnected——但 Output 被克隆进了 JsonlEventSink,join() 时仍有一个克隆活着,于是 recv() 永远阻塞,join() 永远不返回,后面的 mode_lock::release() 也永远执行不到。

实测结果(原版代码):

场景 结果
发 stop 命令 response 和 server_stopped 都正常收到,但进程不退出,mode.lock 残留
Ctrl+C / SIGINT 信号被 select 吃掉、服务也停了,但进程不退出,表现为"完全无视 Ctrl+C",只能 kill -9
stdin EOF 不退出
输入超长行(>64 KiB) 不退出,且 stdout / stderr 都没有任何错误提示,前端只看到服务莫名 server_stopped
启动失败(8554 端口被占用) 不退出,且 stdout 收到 0 帧

最后一条最需要你注意:start_server 失败时 run_locked 在 ? 处直接返回,同样走到 join() 挂住;而 main.rs 里的 write_standalone_error 是在 serve::run() 返回之后才执行的,所以它永远跑不到。也就是说——你为启动失败设计的 backend_start_failed 上报机制,恰恰在启动失败这条路径上是死代码,无头前端会无限等待、拿不到任何信息。

复现

cd tauri-app && cargo build -p micyou-cli

# 1) stop 之后进程不退出(124 = 被 timeout 杀掉)
echo '{"v":1,"type":"command","id":"1","name":"stop","payload":{}}' \
  | timeout 20 ./target/debug/micyou-cli serve --jsonl > /tmp/out.jsonl
echo "rc=$?"; cat /tmp/out.jsonl
ls ~/.local/share/micyou/mode.lock   # 残留

# 2) 启动失败:stdout 一个字节都没有,进程也不退出
python3 -c "import socket,time;s=socket.socket();s.setsockopt(1,2,1);s.bind(('0.0.0.0',8554));s.listen(1);time.sleep(30)" &
timeout 20 ./target/debug/micyou-cli serve --jsonl; echo "rc=$?"

根因比"漏了一个 drop"更深,请注意别只改 drop 顺序

我试过在 join() 前补上 drop(jsonl_sink)——仍然挂死。原因是 sender 的克隆并不只在 CLI 手里:

  • ServerState::new 里 state.plugins.attach_controls(state.controls()),而 Controls 内含 events: SharedEvents(settings.rs:98)
  • AudioPipelineParams.events(audio_pipeline.rs:48)→ 音频管线 OS 线程持有一份
  • transport/tcp.rs:212,643、service.rs:282,302,374 → 各网络任务持有一份

而 stop_server 对这些只做有限等待:NETWORK_TASK_JOIN_TIMEOUT = 3s、AUDIO_JOIN_TIMEOUT = 3s;join_audio_bounded(lifecycle.rs:120-142)超时后把线程句柄留在 AudioThreadState::Joining 里直接放弃——OS 线程没法 abort,它持有的 SyncSender 就永远存活。

所以问题的本质是:"靠 channel 断开来让写线程退出"这个前提不成立,CLI 无法保证所有 SharedEvents 克隆被释放。在我的环境里(无声卡,音频线程 3s 内没退出)是稳定复现;但即使换成真实设备、音频线程按时退出,"能否退出"也取决于 core 是否在有限超时内回收了每一个克隆——那是竞态,不是保证。

需要你改的方向

不要把写线程的退出条件寄托在 sender 全部 drop 上。请给它一个不依赖引用计数的显式关闭途径,并且 join() 必须有界——因为消费者不读 stdout 时写线程会卡在 write_all 里,任何基于标志位的方案都得能在这种情况下超时放弃。同时要保证关闭时已排队的帧不丢(ready/response/server_stopped 这些不能少)。

另外强烈建议补一个端到端回归测试:启动 serve --jsonl、发 stop、断言进程在 N 秒内退出且 mode.lock 被清理。这类 bug 编译器不报、clippy 不报、现有的 3 个单元测试也覆盖不到——它漏网正是因为缺这一条。


🟠 一行坏输入就会拆掉整个音频服务

jsonl.rs:337:

let line = line_result.map_err(|error| format!("invalid JSONL input: {error}"))?;

read_bounded_line 返回的 Err(超过 64 KiB,或任意一个非 UTF-8 字节)会经 channel 传到这里,? 直接结束 run_control → 停服 → 退出。

这和同一个循环里 JSON 解析错误的处理是矛盾的:parse_request 失败时(jsonl.rs:339-351)你发一个 invalid_request 帧然后 continue,服务继续跑。我实测了这个对照组,行为是正确的——收到错误帧后成功服务了下一条 getDspSettings。同样是前端笔误,一条容错、一条致命。

而且不可恢复:stdin 线程在 finished = true 后 break(jsonl.rs:319-322),此后再没有任何人从 stdin 读数据,前端继续写就撞管道写满 / EPIPE。

对一个面向"第三方前端与自动化脚本"的接口,一个字节错误就打死麦克风服务,健壮性不够;AGENTS.md 的约定也是 "validate at trust boundaries … prefer bounded limits and quarantine over panics"。

# 复现:服务被一行超长输入拆掉
python3 -c "print('{\"v\":1,'+' '*70000+'}')" \
  | timeout 20 ./target/debug/micyou-cli serve --jsonl

请把它降级成协议错误:发错误帧、丢弃到下一个 \n 重新同步、保持 stdin 线程存活。


🟠 阻塞式 send 出现在 async 上下文和音频/网络线程里

Output::send(jsonl.rs:51-55)和 Output::event(frame, false)(jsonl.rs:63)都是无超时的阻塞 SyncSender::send。调用点包括:

  • run_control 发 response(jsonl.rs:342、366)——在 async fn 里直接阻塞 tokio worker
  • Controls::set_muted → mute_state_changed(settings.rs:114-116)
  • transport/tcp.rs:564 device_connected、tcp.rs:662 udp_audio_warning、tcp.rs:720 device_disconnected
  • service.rs:494 server_stopped(在 stop_server 内部)

失败链条:前端停止读 stdout → OS 管道缓冲(约 64 KiB)写满 → 写线程卡在 write_all → 128 容量的 channel 填满 → 此后每个关键事件都会无限期阻塞其调用线程。因为阻塞发生在 tokio::select! 的处理体内部,Ctrl+C 那个分支根本无法被 poll。

实测:完全不读 stdout + 灌 900 条 setMuted → SIGINT 后 12s 毫无反应,只有 SIGQUIT 能杀掉(returncode −3),mode.lock 残留。也就是一个行为异常的前端就能让服务变成 Ctrl+C 杀不掉的僵尸。

# 复现
import json, subprocess, time, signal, os
p = subprocess.Popen(["./target/debug/micyou-cli","serve","--jsonl"],
                     stdin=subprocess.PIPE, stdout=subprocess.PIPE,  # 故意不读 stdout
                     stderr=subprocess.DEVNULL, text=True, bufsize=1)
time.sleep(3)
for i in range(900):
    p.stdin.write(json.dumps({"v":1,"type":"command","id":f"m{i}","name":"setMuted",
                              "payload":{"isMuted":i%2==0}})+"\n")
p.stdin.flush(); time.sleep(3)
p.send_signal(signal.SIGINT); print(p.wait(timeout=15))   # 超时

遥测走 try_send 的思路是对的,但关键事件的无界阻塞把这份保护抵消掉了。请给关键帧的投递也加上界限(超时后丢弃 + 记 stderr + 计数都可以),别让它能无限期阻塞调用线程。

顺带提醒一个坑:SyncSender::send_timeout 在 stable 上还不稳定,直接写会撞 error[E0658]: use of unstable library feature std_internals(我在 rustc 1.99.0 上实测过)。你可能需要用 try_send + 重试到截止时间的写法(TrySendError::Full(value) 会把值还回来,可以重试)。


🟠 其他需要处理的问题

1. 关键事件可能乱序。 emit(jsonl.rs:145-174)在 ready 之后先 drop(gate) 再 send(L172-173),锁在 send 之前释放,两个线程可以同时通过 gate 检查再以任意顺序入队。跨线程场景下 device_connected(TCP 任务)、mute_state_changed(控制循环)、server_stopped(关闭流程)之间都可能颠倒。setMuted 既回 response 又发 event,两者顺序不定会让前端拿到过期状态。我理解你注释里那个取舍(不想持锁阻塞 send),但当前实现等于对关键事件不提供任何顺序保证——要么给出保证,要么在文档里写明"不保证顺序,前端应以显式查询为准"。

2. select 循环里执行同步阻塞操作。 execute_command 是同步函数却被直接在 async 循环里调用(jsonl.rs:359),其中 listAdbDevices 会同步 spawn 并等待 adb 子进程(冷启动可能数秒)、listAudioDevices 走 cpal/ALSA 同步枚举、两个 apply* 做同步文件 I/O 和插件链 reconcile、setMuted 每次都同步读盘 load_server_prefs()。阻塞期间控制循环停摆,Ctrl+C 同样无法被 poll。请用 spawn_blocking 包起来。

3. 没有处理 SIGTERM。 AGENTS.md 里写的就是 CLI"适合终端、systemd 和脚本",你 PR 描述里的目标用户也是无头前端和自动化脚本,但 run_control 只监听 ctrl_c()(jsonl.rs:326)。systemd stop、docker stop、Node child.kill() 默认发的都是 SIGTERM,会导致脏退出:没有 stop_server、没有 server_stopped 帧、不释放 mode.lock、不关虚拟音频设备。文本模式同样缺(既有问题,不用你负责),但对机器驱动的协议来说重要性高得多,建议一并加上。


🟡 合并前需要补的

错误码语义失真。 main.rs:245-249 对所有 serve 错误一律输出 code: "backend_start_failed"。实测中一个"输入行超长"的运行期协议错误也被贴上了这个码;走到这里的还可能是 "JSONL output pipe is closed"、"error while stopping: …"。前端按 code 分支处理会被误导,请区分 backend_start_failed / protocol_error / runtime_error,或至少用中性的 fatal。

许可证头不合规。 jsonl.rs:1-3 是全仓库 101 个 .rs 文件里唯一用 SPDX 头的(其余 91 个用统一的 MicYou — … / Copyright (C) 2026 LanRhyme <…> 块),而且声明的是 GPL-3.0-or-later。项目 LICENSE 是 GPL v3 + MicYou Plugin Exception (Version 1.0),CONTRIBUTING.md:151 明确要求贡献以"GPL v3.0 with MicYou Plugin Exception"授权——你现在这个声明把 Plugin Exception 这项附加许可丢掉了,署名主体也和其他文件不一致。不是什么问题,但请换成项目标准头。

协议完全没有文档。 这是一个带版本号、要给第三方长期依赖的对外契约,但 docs/ 下没有任何说明、README 未提及 --jsonl、--help 也只有一句 flag 描述。帧结构、事件名、命令名、payload 字段、错误码、节流频率、"必须持续排空 stdout"这类关键约束,目前只能靠读源码得知。v:1 / protocolVersion:1 声称了版本化,却没有任何地方定义 v1 是什么,版本号就失去了意义。请补一份 docs/jsonl-protocol.md(帧格式 + 命令/事件表 + 错误码 + 兼容性承诺 + 一个最小驱动示例),README 的 CLI 段落加个指引。这两项(协议文档、许可证头)一旦发版给第三方就很难再改,所以要在合并前定下来。

缺初始状态快照。 前端在 ready 之后无法得知当前静音状态、监听状态、已连接设备,只能被动等事件;ready payload 里只有 protocolVersion/backendVersion/message。而 Controls 已经有 is_muted()、is_monitoring()(settings.rs:108、163),文本模式也有 micyou-cli status。请加一个 getStatus,或把这些字段直接放进 ready payload——否则前端启动时只能盲猜,或者强行发一次 setMuted 去触发事件回显。

applyConnectionSettings 校验不一致且不完整(jsonl.rs:413-427):同一段代码里 port 上限 65534、web_port 却只挡了 0(65535 放行),而错误文案写的是 "between 1 and 65535";同时与既有 CLI 也不一致——commands.rs:286 的 cmd_server_set 只校验 port != 0,65535 是合法的,同一个 server.json、两条写入路径、两套规则。另外 bind_address(未校验是否合法 IP)、output_device(未对照 audio_devices())、auto_bind、mute_sync 全部无校验直接落盘;返回 requiresRestart: true 但协议里没有 restart 命令,前端拿到这个标志无路可走。建议把校验下沉成 micyou-core::config 里的共享函数,四方复用。

还有一点请在文档里写明:applyConnectionSettings / applyDspSettings 写的是 GUI/CLI/TUI 三方共享的全局配置文件,所以能写 stdin 就等于能改全局持久化配置。这和 GUI 既有能力等价、不算提权,但 stdin 是可信控制面,不得跨信任边界转发(比如某个 Web 后端把请求转发进来)。协议本身没有鉴权,对 stdio 通道来说这是合理的(进程边界即信任边界),但需要写清楚。


⚪ 细节

  1. Output::start() 用 .expect() panic(jsonl.rs:46),与 AGENTS.md "prefer bounded limits and quarantine over panics" 相悖,建议向上返回 Result
  2. --jsonl 的 help 是英文(main.rs:58),而 serve 其余 6 个参数全中文,实测 serve --help 输出中英混杂;按仓库习惯改中文(协议内的技术性错误文本保持英文是对的,符合规范)
  3. 节流和丢弃对消费者完全不可见:audio_level 被 50ms 节流、遥测 try_send 失败静默丢弃、pre-ready 队列满时甚至 pop_front() 丢掉最老的关键事件(jsonl.rs:163-165)。前端无法区分"静音"和"被节流",建议在 ready 或周期帧里带 dropped 计数
  4. EVENT_QUEUE_CAPACITY 一词两用(channel 容量 + pre-ready 缓冲上限),建议拆成两个常量
  5. response 回显的是请求值而非实际值:applyDspSettings 已经正确回读了实际设置,但 setMuted/setMonitoring 返回的是请求值;apply_dsp_settings 内部会 normalize() 并强制把 AEC 挪到链首,建议统一回读 controls.is_muted() / is_monitoring()
  6. id 非法时仍原样回显(jsonl.rs:352-365),id 可以是最大 64 KiB 的任意嵌套 JSON 并被原封不动写回 stdout,存在轻微放大,建议非法 id 一律回 null
  7. state.controls() 每条命令都重建一次(克隆 6 个 Arc),可以在循环外取一次
  8. // Modified 2026-10-08: 这种注释全仓库仅本 PR 出现 3 处(main.rs:20、serve.rs:37、Cargo.toml:21),不是既有约定,日期也会随时间失去意义,建议改成解释"为什么"的普通注释
  9. 测试覆盖:现有 3 个只覆盖行长度上限和信封解析。缺 CRLF 行、跨 buffer 边界的长行、execute_command 各分支校验(尤其上面那段端口规则)、pre-ready 缓冲与淘汰、allow_rate 边界、非法 id/版本的 response 形状,以及上面说的端到端退出测试
  10. native 插件若走 libc printf 写 stdout 会绕过写线程持有的 stdout.lock() 并撕裂 JSON 行(Rust 侧没问题,我确认过 core/audio/plugin 三个 crate 无 println!)。概率低,文档里提一句即可
  11. -q 在 --jsonl 下语义变了:事件不再受 quiet 影响(改成固定节流),-q 只剩降低 stderr 日志级别。建议在 help 或文档里说明,否则老用户会误解

关于验证

你自述的构建/clippy/测试结果我复核过,都是真的;PR 描述里也诚实标注了"Windows 构建及真实设备音频端到端尚未验证",这点很好。但恰恰漏掉了唯一最重要的那条手工验证:实际跑一次 serve --jsonl、发一个 stop、看进程会不会退出。上面 🔴 那条用最原始的 echo ... | timeout 20 micyou-cli serve --jsonl 就能立刻发现。

麻烦修完后按这个清单再验一遍:

  • echo '{"v":1,"type":"command","id":"1","name":"stop","payload":{}}' | micyou-cli serve --jsonl 能自行退出,stdout 帧完整(ready / response / server_stopped),mode.lock 被清理
  • Ctrl+C 能退出
  • 占用 8554 端口后启动,应收到 backend_start_failed 帧并以 rc=1 退出(而不是静默挂死)
  • 超长行 / 非 UTF-8 字节只产生错误帧,服务继续运行,stdin 仍可接受后续命令
  • 完全不读 stdout 的情况下,服务不会变成 Ctrl+C 杀不掉的僵尸
  • SIGTERM 能触发干净关闭

有问题随时在这条 PR 下讨论。整体设计和代码质量我是满意的,主要卡点集中在生命周期和背压这两块,改完就可以推进了。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/xl 500-999 行 waitmerge 等待合并

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants