From eec76ed91ed35c590925d93fe6b9fa22b10de709 Mon Sep 17 00:00:00 2001 From: jkooe <62494645+jkooe@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:11:03 +0800 Subject: [PATCH] =?UTF-8?q?fix(console):=20=E4=BA=A4=E7=BB=99=20node=20?= =?UTF-8?q?=E7=9A=84=E8=B7=AF=E5=BE=84=E5=8E=BB=E6=8E=89=20Windows=20verba?= =?UTF-8?q?tim=20=E5=89=8D=E7=BC=80=EF=BC=88=E7=9C=9F=E6=9C=BA=20sidecar?= =?UTF-8?q?=20=E7=A7=92=E9=80=80=E6=A0=B9=E5=9B=A0=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 真机现象(Windows 11 ARM64,控制台 v2.0.3):点「检查更新」立刻报 「sidecar 已退出(stdout 关闭),请重试」—— sidecar 在请求进行中真的退出了。 真机 A/B(同一个 node.exe、同一份 sidecar.mjs,只换脚本参数写法): C:\…\sidecar.mjs → 2.5 秒返回结果,进程常驻 \\?\C:\…\sidecar.mjs → 0.47 秒退出,exit 1,零 JS 执行 根因:Tauri 的 resource_dir() 在 Windows 上返回 canonicalize 后的 verbatim 路径, 把它当参数交给 node,node 启动期 realpathSync 把 `\\?\C:` 拆成 `C:` → EISDIR ... lstat 'C:' → exit 1,一行 JS 都来不及执行。 本地/CI/开发模式全测不出:开发期走 CARGO_MANIFEST_DIR 的普通路径,只有打包后才暴露。 修法: - 新增 unverbatim():剥 \\?\UNC\ → \\、剥 \\?\ → 无前缀,纯字符串处理,全平台可调用 - 新增 sidecar_command():唯一把路径交给 node 的地方,单测断言其最终 program/args - resolve_node / resolve_sidecar / resolve_bundled_agent 全部出口加闸 - Sidecar::spawn 与 AgentManager::start 进程边界对 node/script/data_dir 三条路径净化 - autostart::app_exe() 净化(该路径进计划任务 -Execute) 回归:新增 3 条守卫;注入故障(去掉 sidecar_command 的 unverbatim)实测第三条如期变红, 还原后 sha256 逐字节一致。pnpm test:rust 17/17(14 → +3),clippy 零告警。 --- CHANGELOG.md | 60 ++++++++ apps/console/src-tauri/src/agent.rs | 25 +++- apps/console/src-tauri/src/autostart.rs | 9 +- apps/console/src-tauri/src/sidecar.rs | 176 ++++++++++++++++++++++-- 4 files changed, 253 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 83a9b77..632a655 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -413,6 +413,66 @@ macOS 上同样两条却是绿的 —— 典型的「只在 Windows 暴露」。 > (随包 `73ed06f` / 发布版 `a1c51d6`)—— 源于两次构建的**基准目录不同**。 > 因此二者 **sha256 必然不同**,不能用发布清单的哈希去校验控制台播种出来的那份。 +### 修复 —— 打包后 sidecar 因 Windows verbatim 路径秒退(真机症状:「sidecar 已退出(stdout 关闭),请重试」) + +上一节修好「复用已退出的 sidecar」之后,真机复验**症状变了但没解决**:不再干等 45 秒, +改为**立刻**报「检查更新失败:**sidecar 已退出(stdout 关闭),请重试**」—— +说明被拉起的 sidecar **在请求进行中真的退出了**,只是这次被立刻发现。本节是它的真根因。 + +**真机 A/B(决定性的一条)**:Windows 11 ARM64;同一个 `node.exe`、同一份 `sidecar.mjs`, +**只换脚本参数的写法**: + +| 脚本参数 | 结果 | +|---|---| +| `C:\…\sidecar.mjs` | 2.5 秒返回 `check_update` 结果,进程常驻、心跳持续 | +| `\\?\C:\…\sidecar.mjs` | **0.47 秒即退出,exit 1**,**零 JS 执行** | + +「零 JS 执行」是实测出来的:sidecar 自己的启动日志一行没写、审计日志在 `agent.start` 之后 +一片空白、判死探针文件 0 字节 —— 连第一行 JS 都没跑到。 + +**根因**:Tauri 的 `resource_dir()` 在 Windows 上返回的是 `std::fs::canonicalize` 之后的 +**verbatim 路径**(`\\?\C:\Users\…\nodeagent-console`)。把它当**参数**交给 node 时, +node 启动流程在入口模块的 `realpathSync` 里把 `\\?\C:` 拆成盘符 `C:` 与剩余部分: + +```text +Error: EISDIR: illegal operation on a directory, lstat 'C:' + at Object.realpathSync (node:fs:27xx) +``` + +→ exit 1,**一行 JS 都来不及执行**。两种界面表现都由这一条派生: +修存活检测**之前**是「写进已死进程的管道 → 干等 45 秒超时」, +修**之后**是「stdout 立即 EOF → 立刻报 sidecar 已退出」。 + +**为什么单测 / CI / 开发模式全都测不出来**:开发期 `resolve_sidecar` 走的是 +`CARGO_MANIFEST_DIR` 拼出来的**普通路径**;只有**打包之后**才会从 `resource_dir()` +拿到 verbatim 路径。CI 里 `cargo test` 又只在 `ubuntu-latest` 跑(`.github/workflows/rust.yml`), +Windows 侧只有打包(`console-windows.yml`)。于是「本地全绿 + CI 全绿 + 真机必挂」。 + +**修法**(`apps/console/src-tauri/src/sidecar.rs` + `agent.rs` + `autostart.rs`): +- 新增 `unverbatim()`:剥 `\\?\UNC\` → `\\`、剥 `\\?\` → 无前缀,其余原样返回; + **纯字符串处理,任何平台都可安全调用** —— 因此可以用 Windows 形态的字符串在 + macOS / Linux 上直接守住 Windows 分支(铁律 4:跨平台差异不必真上 Windows 才能验)。 +- 新增 `sidecar_command()`:**唯一**把路径交给 node 的地方,内部统一净化。 + 纪律只在这里守一次,单测直接断言它产出的 `program` / `args`(守「最终下发文本」, + 不是「源码里出现过某个常量」)。 +- 四个来源统一加闸:`resolve_node` / `resolve_sidecar` / `resolve_bundled_agent` 的 + **全部出口**(环境变量、resource_dir、系统 PATH / 开发路径),以及进程边界的 + `Sidecar::spawn` 与 `AgentManager::start`(对 node / script / data_dir 三条路径都净化)。 +- `autostart::app_exe()` 一并净化:该路径会被写进计划任务的 `New-ScheduledTaskAction -Execute`, + 与上述同源风险。 + +**回归**: +- 新增 3 条守卫 —— `unverbatim_去掉各类_windows_前缀`(盘符 / UNC / 普通 / POSIX / **幂等**)、 + `unverbatim_对_resource_dir_拼接后的路径同样生效`(前缀在目录上、拼完仍在最前)、 + **`交给_node_的路径必须去掉_verbatim_前缀`**(断言最终 `program` 与 `args` 文本)。 +- **注入故障实测**:把 `sidecar_command` 里的 `unverbatim` 去掉 → 第三条如期变红, + 报错正文即 `program 仍带 verbatim 前缀(示例:\\?\C:\…\node.exe)`;还原后 **sha256 逐字节一致**,复跑全绿。 +- `pnpm test:rust` **17/17**(14 → +3)。 + +> ⚠️ **本项属控制台上壳变更,不是被控端变更**,因此**不触发** `pnpm release patch`。 +> 但要让用户真正拿到修复,**必须重新构建并分发控制台安装包**。 +> ⚠️ 修复只净化了「交给 node 的路径」,**尚待在真机上跑通完整链路**(下一版安装包复验)。 + --- ## [v2.0.2] —— 2026-10-11 diff --git a/apps/console/src-tauri/src/agent.rs b/apps/console/src-tauri/src/agent.rs index bce915a..09d0446 100644 --- a/apps/console/src-tauri/src/agent.rs +++ b/apps/console/src-tauri/src/agent.rs @@ -158,14 +158,19 @@ pub fn mark_installed_version(data_dir: &Path, version: &str, source: &str) -> R /// /// 优先级:环境变量 `NODEAGENT_CONSOLE_AGENT` → 随包 resource 的 `agent.mjs` → /// 开发期的 `apps/agent/dist/index.js`。 +/// +/// ⚠️ 出口一律经 `sidecar::unverbatim`:`resource_dir()` 在 Windows 上给的是 +/// verbatim 路径(`\\?\C:\…`)。本函数的结果最终会**交给 node**(播种后作为脚本路径), +/// 带 `\\?\` 的路径会让 node **启动即 EISDIR 秒退** —— 这正是 2026-10-13 +/// 「sidecar 秒退」真机缺陷的同类风险,必须在源头堵掉。 pub fn resolve_bundled_agent(resource_dir: Option<&Path>) -> Result { if let Ok(p) = std::env::var("NODEAGENT_CONSOLE_AGENT") { if !p.is_empty() && Path::new(&p).exists() { - return Ok(PathBuf::from(p)); + return Ok(crate::sidecar::unverbatim(PathBuf::from(p))); } } if let Some(dir) = resource_dir { - let bundled = dir.join("agent.mjs"); + let bundled = crate::sidecar::unverbatim(dir.join("agent.mjs")); if bundled.exists() { return Ok(bundled); } @@ -429,16 +434,24 @@ impl AgentManager { ), ); - let mut cmd = Command::new(node); - cmd.arg(script) - .current_dir(data_dir) + // 进程边界:交给 node 的三条路径都先去掉 verbatim 前缀。 + // 被控端的脚本路径来自数据目录(本来就是普通路径),但 node 路径来自 + // `resource_dir()`(Windows 上是 `\\?\C:\…`)—— 与 2026-10-13「sidecar 秒退」 + // 同一类风险,故在此统一兜住,不依赖上游是否记得净化。 + let node = crate::sidecar::unverbatim(node.to_path_buf()); + let script = crate::sidecar::unverbatim(script.to_path_buf()); + let data_dir = crate::sidecar::unverbatim(data_dir.to_path_buf()); + + let mut cmd = Command::new(&node); + cmd.arg(&script) + .current_dir(&data_dir) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .kill_on_drop(false); // 由本管理器显式管理生命周期,不留孤儿也不误杀 // 让被控端与控制台用**同一个**数据目录(不设则各按各自规则解析) - cmd.env("NODEAGENT_HOME", data_dir); + cmd.env("NODEAGENT_HOME", &data_dir); // ⚠️ 消灭黑窗:Windows 上不加这个标志,每 spawn 一次 node 就弹一个控制台窗口 #[cfg(windows)] diff --git a/apps/console/src-tauri/src/autostart.rs b/apps/console/src-tauri/src/autostart.rs index 71acc68..7c90281 100644 --- a/apps/console/src-tauri/src/autostart.rs +++ b/apps/console/src-tauri/src/autostart.rs @@ -30,8 +30,15 @@ const LABEL: &str = "cn.jkooe.nodeagent-console"; pub const AGENT_ONLY_FLAG: &str = "--agent-only"; /// 当前可执行文件路径。 +/// +/// ⚠️ 出口经 `unverbatim`:Windows 上 `current_exe()` 可能带 `\\?\` 前缀, +/// 而这条路径会**被写进计划任务的执行路径**(`New-ScheduledTaskAction -Execute`)。 +/// 带前缀的路径不是「一定不能用」,但它会以原样进入 PowerShell 字符串与任务定义 —— +/// 与 2026-10-13「verbatim 路径交给外部程序」那类真机缺陷同源,统一在出口净化。 fn app_exe() -> Result { - std::env::current_exe().map_err(|e| format!("取当前程序路径失败:{e}")) + std::env::current_exe() + .map(crate::sidecar::unverbatim) + .map_err(|e| format!("取当前程序路径失败:{e}")) } /// macOS 上 LaunchAgent plist 的位置。 diff --git a/apps/console/src-tauri/src/sidecar.rs b/apps/console/src-tauri/src/sidecar.rs index 7b69d4d..0fa2897 100644 --- a/apps/console/src-tauri/src/sidecar.rs +++ b/apps/console/src-tauri/src/sidecar.rs @@ -25,6 +25,70 @@ use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::process::{Child, ChildStdin, Command}; use tokio::sync::oneshot; +// --------------------------------------------------------------------------- +// 路径净化:**交给外部进程**(node)的路径必须先去掉 Windows verbatim 前缀 +// --------------------------------------------------------------------------- + +/// 去掉 Windows 的 verbatim 前缀(`\\?\` 与 `\\?\UNC\`)。 +/// +/// ## 为什么非做不可(2026-10-13 真机缺陷的根因) +/// Tauri 的 `resource_dir()` 返回的是**规范化之后的 verbatim 路径** +/// (`\\?\C:\Users\…\nodeagent-console`)。把它当**参数**交给 node 时, +/// node 启动流程会在入口模块的 `realpathSync` 里把它拆成 `C:` 与剩余部分: +/// +/// ```text +/// Error: EISDIR: illegal operation on a directory, lstat 'C:' +/// at Object.realpathSync (node:fs:27xx) +/// ``` +/// +/// → **exit 1,且一行 JS 都来不及执行**(连 sidecar 自己的启动日志都写不出来)。 +/// +/// 真机 A/B(Windows 11 ARM64;控制台 v2.0.3;同一个 `node.exe`、同一份 `sidecar.mjs`): +/// +/// | 脚本参数 | 结果 | +/// |---|---| +/// | `C:\…\sidecar.mjs` | 2.5 秒返回 `check_update` 结果,进程常驻 | +/// | `\\?\C:\…\sidecar.mjs` | **0.5 秒即退出,exit 1**,零 JS 执行 | +/// +/// 控制台侧前后两种表现都由这一条派生: +/// - 修存活检测**之前**:请求写进已死进程的管道 → 干等 45 秒 → 「check_update 超时」; +/// - 修存活检测**之后**:stdout 立即 EOF → 「sidecar 已退出(stdout 关闭),请重试」。 +/// +/// ## 为什么单测 / CI / 开发模式全都测不出来 +/// 开发期 `resolve_sidecar` 走的是 `CARGO_MANIFEST_DIR` 拼出来的**普通路径**; +/// 只有**打包之后**才会从 `resource_dir()` 拿到 verbatim 路径。 +/// 于是「本地全绿 + CI 全绿 + 真机必挂」。 +/// +/// 纯字符串处理,**任何平台都可安全调用**(非 Windows 上一般原样返回), +/// 因此可以用 Windows 形态的字符串在 macOS / Linux 上直接验证 Windows 分支。 +pub(crate) fn unverbatim(path: PathBuf) -> PathBuf { + let s = path.to_string_lossy(); + if let Some(rest) = s.strip_prefix(r"\\?\UNC\") { + // \\?\UNC\server\share\x → \\server\share\x + return PathBuf::from(format!(r"\\{rest}")); + } + if let Some(rest) = s.strip_prefix(r"\\?\") { + // \\?\C:\x → C:\x + return PathBuf::from(rest); + } + path +} + +/// 构造「把 sidecar 拉起来」的那条命令 —— **唯一**把路径交给 node 的地方。 +/// +/// 「必须去掉 verbatim 前缀」这条纪律只在这里守一次;单测直接断言本函数产出的 +/// `program` / `args`,因此**在 macOS / Linux 上也守得住 Windows 分支**(守的是 +/// 「最终下发文本」,不是「源码里出现过某个常量」)。 +fn sidecar_command(node: &Path, script: &Path) -> Command { + let mut cmd = Command::new(unverbatim(node.to_path_buf())); + cmd.arg(unverbatim(script.to_path_buf())) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); // app 退出时不留孤儿进程 + cmd +} + /// sidecar 主动推上来的事件(与 `main.mjs` 的 `params.kind` 一一对应)。 #[derive(Debug, Clone)] pub enum SidecarEvent { @@ -66,12 +130,13 @@ impl Sidecar { script: &Path, on_event: Arc, ) -> Result, String> { - let mut child = Command::new(node) - .arg(script) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .kill_on_drop(true) // app 退出时不留孤儿进程 + // 进程边界:先净化一次(用于报错信息与实际下发文本一致)。 + // 真正「不得带 verbatim 前缀」这道纪律由 `sidecar_command` 兜底 —— + // 去掉它会复现真机缺陷(node 启动即 EISDIR 退出),单测会红。 + let node = unverbatim(node.to_path_buf()); + let script = unverbatim(script.to_path_buf()); + + let mut child = sidecar_command(&node, &script) .spawn() .map_err(|e| format!("启动 sidecar 失败({} {}):{e}", node.display(), script.display()))?; @@ -244,14 +309,17 @@ impl Sidecar { /// 解析 node 可执行文件。 /// /// 优先级:环境变量 `NODEAGENT_CONSOLE_NODE` → 随包 resource 的 `node` → 系统 `node`。 +/// +/// ⚠️ 出口一律经 `unverbatim`:`resource_dir()` 在 Windows 上给的是 verbatim 路径, +/// 带 `\\?\` 的路径交给 node 会**启动即 EISDIR 秒退**(详见 `unverbatim` 的说明)。 pub fn resolve_node(resource_dir: Option<&Path>) -> Result { if let Ok(p) = std::env::var("NODEAGENT_CONSOLE_NODE") { if !p.is_empty() { - return Ok(PathBuf::from(p)); + return Ok(unverbatim(PathBuf::from(p))); } } if let Some(dir) = resource_dir { - let bundled = dir.join(if cfg!(windows) { "node.exe" } else { "node" }); + let bundled = unverbatim(dir.join(if cfg!(windows) { "node.exe" } else { "node" })); if bundled.exists() { return Ok(bundled); } @@ -281,14 +349,18 @@ fn which(cmd: &str) -> Option { /// /// 优先级:环境变量 `NODEAGENT_CONSOLE_SIDECAR` → 随包 resource 的 `sidecar.mjs` → /// 开发期的 `apps/console/sidecar/dist/sidecar.mjs`(相对本 crate)。 +/// +/// ⚠️ 出口一律经 `unverbatim` —— **这一条就是 2026-10-13 真机缺陷的修复点**: +/// 打包后 `resource_dir()` 给的是 `\\?\C:\…`,交给 node 当参数会让 node 在启动期 +/// `realpathSync` 里炸成 `EISDIR ... lstat 'C:'` 并 exit 1(sidecar 秒退)。 pub fn resolve_sidecar(resource_dir: Option<&Path>) -> Result { if let Ok(p) = std::env::var("NODEAGENT_CONSOLE_SIDECAR") { if !p.is_empty() && Path::new(&p).exists() { - return Ok(PathBuf::from(p)); + return Ok(unverbatim(PathBuf::from(p))); } } if let Some(dir) = resource_dir { - let bundled = dir.join("sidecar.mjs"); + let bundled = unverbatim(dir.join("sidecar.mjs")); if bundled.exists() { return Ok(bundled); } @@ -322,6 +394,90 @@ mod tests { resolve_node(None).expect("需要 node 才能跑 sidecar 单测") } + // ----------------------------------------------------------------------- + // 「交给 node 的路径不得带 Windows verbatim 前缀」—— 2026-10-13 真机缺陷 + // + // 现象(真机):控制台点「检查更新」,sidecar 被拉起后**存活约 0.5 秒即消失**、 + // 退出码 1,且一行 JS 都没执行(连自带的启动日志都写不出来);界面报 + // 「sidecar 已退出(stdout 关闭),请重试」(修存活检测之前报「等满 45 秒超时」)。 + // 根因:`resource_dir()` 给的是 verbatim 路径(`\\?\C:\…`),当参数交给 node 后, + // node 启动期的 `realpathSync` 把 `\\?\C:` 拆成 `C:` → `EISDIR` → exit 1。 + // 为什么本地/CI 全绿:开发模式走的是仓库相对路径(普通路径),只有打包后才暴露。 + // 因此这里的守卫**必须落在「最终下发给 node 的 program / args」上**, + // 而不是「源码里有没有调用过某个函数」—— 这样在 macOS / Linux 上也能守住 Windows 分支。 + // ----------------------------------------------------------------------- + + #[test] + fn unverbatim_去掉各类_windows_前缀() { + // 常见的盘符形态 + assert_eq!( + unverbatim(PathBuf::from(r"\\?\C:\Users\u\AppData\Local\x\node.exe")), + PathBuf::from(r"C:\Users\u\AppData\Local\x\node.exe") + ); + // UNC 形态:\\?\UNC\server\share\x → \\server\share\x + assert_eq!( + unverbatim(PathBuf::from(r"\\?\UNC\srv\share\sidecar.mjs")), + PathBuf::from(r"\\srv\share\sidecar.mjs") + ); + // 普通路径原样返回(含 POSIX) + assert_eq!( + unverbatim(PathBuf::from(r"C:\a\b.mjs")), + PathBuf::from(r"C:\a\b.mjs") + ); + assert_eq!( + unverbatim(PathBuf::from("/Users/x/y.mjs")), + PathBuf::from("/Users/x/y.mjs") + ); + // 幂等:净化两次 == 净化一次(spawn 与 sidecar_command 都会调) + let once = unverbatim(PathBuf::from(r"\\?\C:\a\b.mjs")); + assert_eq!(unverbatim(once.clone()), once); + } + + #[test] + fn unverbatim_对_resource_dir_拼接后的路径同样生效() { + // 真实形态就是「resource_dir().join("sidecar.mjs")」——前缀在目录上,拼完仍在最前 + let joined = unverbatim(Path::new(r"\\?\C:\app").join("sidecar.mjs")); + let s = joined.to_string_lossy().into_owned(); + assert!(!s.contains(r"\\?\"), "拼接后仍带 verbatim 前缀:{s}"); + assert!(s.starts_with(r"C:\app"), "应剥成普通路径,实际:{s}"); + assert!(s.ends_with("sidecar.mjs"), "文件名不该被动:{s}"); + } + + #[test] + fn 交给_node_的路径必须去掉_verbatim_前缀() { + // 直接断言**最终下发给 node 的文本**(program 与 args),而不是源码长相。 + // 去掉 `sidecar_command` 里的 unverbatim → 本用例立刻变红。 + let cmd = sidecar_command( + Path::new(r"\\?\C:\Users\u\AppData\Local\nodeagent-console\node.exe"), + Path::new(r"\\?\C:\Users\u\AppData\Local\nodeagent-console\sidecar.mjs"), + ); + let std_cmd = cmd.as_std(); + + let prog = std_cmd.get_program().to_string_lossy().into_owned(); + assert!( + !prog.contains(r"\\?\"), + "program 仍带 verbatim 前缀(示例:{prog})" + ); + assert_eq!( + prog, r"C:\Users\u\AppData\Local\nodeagent-console\node.exe", + "program 应剥成普通路径" + ); + + let args: Vec = std_cmd + .get_args() + .map(|a| a.to_string_lossy().into_owned()) + .collect(); + assert!( + !args.iter().any(|a| a.contains(r"\\?\")), + "脚本参数仍带 verbatim 前缀 → node 启动即 EISDIR 退出(真机表现为「sidecar 秒退」):{args:?}" + ); + assert_eq!( + args, + vec![r"C:\Users\u\AppData\Local\nodeagent-console\sidecar.mjs".to_string()], + "脚本参数应为唯一一个、且已净化的路径" + ); + } + #[tokio::test] async fn 启动后_state_请求应返回未连接() { let seen: Arc = Arc::new(AtomicUsize::new(0));