fix(console): 交给 node 的路径去掉 Windows verbatim 前缀(真机 sidecar 秒退根因)

真机现象(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 零告警。
This commit is contained in:
jkooe committed 2026-10-13 20:11:03 +08:00
1 parent 6adf11c85a
commit eec76ed91e
4 files changed
+253 -17

No files matched your search

+60
View File
@@ -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
+19 -6
View File
@@ -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<PathBuf, String> {
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)]
+8 -1
View File
@@ -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<PathBuf, String> {
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 的位置。
+166 -10
View File
@@ -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<dyn Fn(SidecarEvent) + Send + Sync>,
) -> Result<Arc<Self>, 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<PathBuf, String> {
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<PathBuf> {
///
/// 优先级:环境变量 `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<PathBuf, String> {
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<String> = 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<AtomicUsize> = Arc::new(AtomicUsize::new(0));