Skip to content

fix(startup): surface startup failures instead of dying silently - #86

Closed
std-external wants to merge 4 commits into
std-microblock:masterfrom
std-external:fix/startup-diagnostics
Closed

std-external wants to merge 4 commits into
std-microblock:masterfrom
std-external:fix/startup-diagnostics

Conversation

@std-external

Copy link
Copy Markdown
Contributor

问题

Windows 上双击 CeleMod 完全没有反应(没有窗口、没有弹框),%USERPROFILE%\.celemod\celemod.log 里只有:

[INFO] [backend] System webview runtime: 154.0.4258.62
[INFO] [backend] CeleMod v1.2.1 (3be2745…)
[ERROR] [backend] panic: panicked at src-tauri\src\backend.rs:6413:10:
error while running CeleMod: PluginInitialization("http", "拒绝访问。 (os error 5)")

原因

  1. tauri-plugin-http 2.5.9 在启动时(Builder::setup)会 create_dir_all(app_cache_dir) 再打开 <app cache>/.cookies(插件 src/lib.rs:31-46)。这个路径不可写时 ? 直接抛 io 错误,Tauri 在 tauri-2.11.5/src/plugin.rs:1009 把它包成 Error::PluginInitialization,Builder::run() 就返回 Err。
  2. backend.rs:6413(v1.2.1,master 是 :6488)是 .expect("error while running CeleMod") → panic。release 构建是 windows_subsystem = "windows"(main.rs:1)+隐藏控制台(initialize_windows_console),panic 只会写进日志,用户侧就是「点了没反应」。
  3. 客户端能做的其实只有一件事:%LOCALAPPDATA%\com.celemod.desktop 写不进去(杀软 / ACL / 组策略 / 重定向的 AppData)。但一个网络插件的缓存文件不该让整个启动器直接死掉。

改动

  • native_dialog.rs(新增):不依赖 webview 的原生弹框(Windows MessageBoxW / macOS osascript / Linux zenity–kdialog)。
  • backend.rs:run() 不再 .expect,失败时写日志 + 弹原生框,显示原始错误和日志路径,点「是」直接打开日志文件夹;错误里出现 os error 5 / 拒绝访问 时附上针对性提示(删缓存目录 / 加杀软白名单)。
  • backend.rs:注册 http 插件前先探测同一个 cookie 缓存(http_plugin_cache)。能写就照常注册;遇到残留的 .cookies 先删掉重试一次;仍然不行就跳过该插件继续启动并弹框说明(网络功能不可用,但界面、日志、本地启动仍可用),而不是零 UI 退出。
  • webview_runtime.rs:缺 WebView2 时直接用官方 Evergreen Bootstrapper 直链自动下载并启动安装(/silent /install),失败再回退到打开下载页;提示里带上日志路径。
  • logging.rs:不再每次启动把唯一日志清空 —— 保留最近 5 次(celemod.1.log … celemod.5.log);并在启动最早就写入版本 / commit / exe / cwd / os,这样「缺运行库」「插件初始化失败」这类早期失败也自带版本信息。
  • 新增 open_log_folder 命令;标题栏版本号右键改为打开日志文件夹(Shift+左键 仍是打开日志文件)。

验证

  • cargo test --lib:114 passed(新增 logging::tests 4 个日志轮转测试、http_plugin_cache_tests 3 个缓存探测/修复测试)
  • 前端:pnpm typecheck 通过;test:catalog-download(3)、test:crash-trace(3)、test:deep-link(3)、test:update-notes(5) 全绿
  • 说明:pnpm test:i18n 在 master 上本来就是坏的(脚本指向不存在的 scripts/deepseek-translator.test.cjs,仓库里只有 deepseek-translator.cjs),与本次改动无关。

备注

  • 降级模式(跳过 http 插件)下 Mod 列表 / 下载 / 更新会不可用,弹框里会写明;如果更希望「宁可退出也别半残」,把 if http_available 那段去掉、只保留可见错误弹窗即可。
  • 插件注册顺序上,http 从原来的 dialog 之前移到了 deep-link 之前,两者没有依赖关系。

A failing plugin initialization aborts `Builder::run` before the first window
exists; release builds run with `windows_subsystem = "windows"` and a hidden
console, so the `.expect` panic left the user with no window, no dialog and
nothing but one line in the log.

- report a failed `run` with a native error dialog that shows the raw error,
  the log path and a button to open the log folder
- probe the cookie cache of `tauri-plugin-http` before registering it: that
  plugin creates `<app cache>/.cookies` while the app starts and a machine where
  the path is not writable (antivirus, ACLs, redirected %LOCALAPPDATA%) fails
  with "拒绝访问。 (os error 5)"; repair a stale cookie file, otherwise start
  without the plugin instead of taking the whole launcher down
- install the WebView2 Evergreen Bootstrapper directly when the runtime is
  missing, and keep the log path in the prompt
- keep the five previous runs as celemod.1.log … celemod.5.log instead of
  truncating the only log, and log version/exe/cwd/os at the very start
- add an `open_log_folder` command (right click on the title bar version)
@std-external

Copy link
Copy Markdown
Contributor Author

补充一条验证:这次改动的 Windows 分支在 Linux 上编译不到,所以我单独用 x86_64-pc-windows-gnu 交叉检查了新代码 —— native_dialog.rs(MessageBoxW 路径)和 webview_runtime.rs 里的 bootstrapper 下载/启动逻辑都能通过 cargo check。整个 crate 交叉编译到 Windows 会在 tauri-build 的资源步骤失败(package.metadata does not exist / 缺 resource.lib),和本次改动无关。

…re set

The probe swallowed the io error, so the dialog told the user the cache
directory is not writable without saying which call was denied. Keep the
operation and the system message (e.g. "open …\.cookies (拒绝访问。 (os error
5))") for the log and the dialog.

Also correct the degraded-mode text: the Mod list, downloads, launching and
the local features keep working through the Rust side, only the requests the
frontend makes through tauri-plugin-http (update info, Mod site search,
Everest/Loenn version lists) are skipped.
@std-external

Copy link
Copy Markdown
Contributor Author

回答两个问题,顺便把 PR 又收紧了一点(commit d2167d2):

1) 降级之后不是没窗口。 主窗口和前端照常起来 —— 建窗口跟这个插件无关,前端资源也是打进 exe 的。顺序是:先弹一个原生提示框 → 点「确定」→ 正常启动(只是不注册 http 插件)。

失效的只有前端直接发请求的那几项(src/celemod-ui/src/lib/http.ts:1 全部走 @tauri-apps/plugin-http,没有第二条路径):一键更新检查(api/updateInfo.ts,含靠它开关的 featureVisible 入口)、Mod 站搜索(api/wegfan.ts)、Everest/Loenn 版本列表(maddie480)。不受影响:Mod 列表(get_mod_catalog,Rust 侧 ureq)、下载/安装(download_mod_batch 等)、启动游戏、Profile、黑名单、日志。

如果更想「宁可退出也别半残」,把 if http_available 那段去掉、只留可见错误弹窗即可;也可以用更彻底的做法:给依赖关掉 cookies feature(tauri-plugin-http = { version = "2", default-features = false, features = ["rustls-tls", "http2", "charset", "macos-system-configuration"] })—— 插件的 setup 里那段 cookie 逻辑是 #[cfg(feature = "cookies")],关掉后它根本不碰文件系统,这类故障直接消失(前提是那几个接口不依赖 cookie 会话)。

2) 现在能告诉用户到底哪一步被拒了。 commit d2167d2 把探测时的 io 错误留下来,日志和弹框里会直接写 create <目录> (…) 或 open <目录>\.cookies (拒绝访问。 (os error 5)),还会在删掉残留 .cookies 重试失败后把两次原因都带上。

A read-only `.cookies` (or one with a file-level deny) makes
`File::options().create(true).append(true).read(true).open()` fail with
ACCESS_DENIED on Windows, and that open is the *only* other way this plugin can
kill the launcher: a store it merely cannot *parse* is dropped silently by
tauri-plugin-http (`CookieStoreMutex::load(..).unwrap_or_else(..)`).

- clear the read-only attribute, move the file to `.cookies.bad-<ms>` and probe
  again before giving up (verified under Wine: read-only file →
  `open … (Access denied. (os error 5))` → backup + fresh store)
- back up a store the plugin cannot parse (e.g. BOM-only, seen in the wild) and
  start from an empty one, logging what was found
- log which operation failed, so a `create <dir>` failure (blocked folder) and
  an `open <dir>\.cookies` failure (blocked file) can be told apart
- tests: fresh directory, usable store, unopenable file, unparsable store,
  directory that cannot be used at all
@std-external

Copy link
Copy Markdown
Contributor Author

新证据(.cookies 里只有一个 UTF-8 BOM,删掉就能启动)查完了,结论比预想的更明确,PR 也补了 commit 1940c46。

插件的 cookie 加载路径(tauri-plugin-http 2.5.9 + cookie_store 0.22.1)

// tauri-plugin-http-2.5.9/src/lib.rs:31-56(setup,随 Builder::initialize 执行)
let cache_dir = app.path().app_cache_dir()?;          // ① dirs::cache_dir()/{identifier}
std::fs::create_dir_all(&cache_dir)?;                 // ② 唯一可能在这里失败
let file = File::options().create(true).append(true).read(true)
                         .open(cache_dir.join(".cookies"))?;   // ③ 或者这里
let reader = BufReader::new(file);
CookieStoreMutex::load(path.clone(), reader).unwrap_or_else(|_e| {
    /* 静默:空 store(warn 只在开了 tracing feature 时才有,默认没有) */
    CookieStoreMutex::new(path, Default::default())
})

reqwest_cookie_store.rs:68-75:CookieStoreMutex::load = cookie_store::serde::load(reader, |c| serde_json::from_str(c))。

所以:文件内容损坏(空 / BOM / 非法 JSON)不会 Err,插件静默退回空 store —— 损坏内容本身不可能产生 PluginInitialization。
能真正把启动器打死的只有 ②/③ 两个文件操作,也就是「目录建不出来」或「.cookies 打不开」。

那 .cookies 为什么打不开:只读属性 → 正好是 os error 5

Windows 上 CreateFile 对带只读属性的文件请求写权限会直接返回 ERROR_ACCESS_DENIED,而插件的 open 同时带 create+append(写)。这条我在 Wine 里用真实 Windows 语义跑了一遍(把 prepare_http_cache 原样编成 x86_64-pc-windows-gnu 程序):

=== .cookies 存在、设成只读
[WARN] Replaced the unusable cookie file …\.cookies
       (open …\.cookies (Access denied. (os error 5))); it was moved to …\.cookies.bad-1791804066734
  result: Ok (插件可以正常注册,启动器能起来)
  dir: .cookies (0 B)  .cookies.bad-… (2 B,原内容保住)
=== .cookies 只有一个 BOM
[WARN] Cookie file …\.cookies is not a usable cookie store (it starts with a UTF-8 BOM, …); it was moved to …
  result: Ok
=== .cookies = "[]"(正常)→ 原样不动   === 目录本身是个文件 → Err(降级)  === 目录不存在 → 建好

两个用户其实是同一条路径、同一个错误码,区别只在失败的操作不同:

用户 A 用户 B(本次)
失败的操作 create <目录> 或 open <目录>\.cookies 被拒 open <目录>\.cookies 被拒(目录 ACL 正常)
日志 PluginInitialization("http", "拒绝访问。 (os error 5)") 同一行(如果他的 log 里也是这行)
用户侧表现 完全一样:双击没反应、零 UI(release 无控制台) 同左
现在怎么区分 新版日志/弹框里会直接写 create … 还是 open …\.cookies 同左

只读属性/文件级 ACL 为什么会出现:清理或备份工具、从压缩包/其他机器还原、杀软的"保护"改写属性等都可能;顺带一提插件自己写的是 serde_json::to_string(&cookies)(空 store 是 []),永远不会写 BOM,所以那个 BOM 文件是外部工具造出来的。

现在的行为(prepare_http_cache)

  1. 照插件的方式探测 create_dir_all + open(.cookies);
  2. 打不开 → 清掉只读属性 → 改名成 .cookies.bad-<毫秒>(改不动才删)→ 再建一个空的 → 再探测;成功就照常注册 http 插件(不降级);
  3. 能打开但内容不是可用的 store(BOM / 非 JSON)→ 备份一份 + 从空 store 开始,并写一条 [WARN](这样下次有日志可查);
  4. 仍然失败才降级跳过插件并弹框,弹框里带上是 create 还是 open 在拒绝。

测试:cargo test --lib 现在 116 passed(新增 5 个:全新目录、正常 store、打不开的文件、解析不了的 store、完全不可用的目录),另外 Windows 侧行为用上面的 Wine 用例单独跑过。

@std-external

Copy link
Copy Markdown
Contributor Author

行号更正(上一条评论里的两处):CookieStoreMutex::load 实际在 reqwest_cookie_store.rs:63-69(cookie_store::serde::load 在 :67);插件写盘是 cookies_to_str(:71) + tokio::fs::write(:91)。src/lib.rs 那边是精确的:setup :31、app_cache_dir() :38、create_dir_all :39、open :42-46、load(...).unwrap_or_else :49-55。

An antivirus or sync client that holds `.cookies` exclusively for a moment
fails the same open with a sharing violation (os error 32, not 5). Verified
under Wine: a 300 ms exclusive handle is survived by the retries, a permanent
one still ends in the degraded start with the reason reported.
@std-external

std-external commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor Author

更正 + 根因结论(那个 .cookies 的内容是 [],不是 BOM;我上一条里「BOM/损坏」的说法作废)。

源码层能确证的部分

谁写、什么时候写、怎么写(tauri-plugin-http 2.5.9)

位置 行为
src/lib.rs:41-46 启动注册插件时 File::options().create(true).append(true).read(true).open(cache_dir/.cookies)? —— Rust 映射成 CreateFileW(OPEN_ALWAYS, GENERIC_READ | FILE_APPEND_DATA, FILE_SHARE_READ|WRITE|DELETE),不 truncate,读完就把句柄关掉(文件在运行期不被持有)
src/lib.rs:48-55 CookieStoreMutex::load(...).unwrap_or_else(|_| 空 store) —— 解析失败静默吞掉(warn 只在 #[cfg(feature="tracing")])
reqwest_cookie_store.rs:71-84 cookies_to_str():取「未过期且 persistent」的 cookie → serde_json::to_string;空库里就是 []
reqwest_cookie_store.rs:86-106 request_save() → tokio::fs::write(&path, &cookie_str)(= File::create truncate + write_all),异步;失败只在 tracing 下记日志
reqwest_cookie_store.rs:110-116 set_cookies():每个带 Set-Cookie 的响应后立刻异步存一次(所以写盘时机是「有 cookie 的响应之后 + 退出时」)
src/lib.rs:67-80 on_event(RunEvent::Exit):退出前再存一次并等它完成

[] / 0 字节 / 文件不存在 的差异:cookie_store::serde::load(cookie_store-0.22.1/src/serde.rs:15-49)只做 read_to_string + serde_json::from_str::<Vec<Cookie>> → CookieStore::from_cookies,从不写/删文件(整个 crate 里没有任何 remove_file/File::create)。所以:

  • [] → Ok(空库)
  • 0 字节 / 半截 JSON → 解析 Err → 插件 unwrap_or_else → 空库
  • 文件不存在 → OPEN_ALWAYS 创建(0 字节)→ 同样解析 Err → 空库

三者最终都是空库、都不会报错;唯一区别是「文件不存在」那一步需要目录的写权限。也就是说:内容(含 0 字节)永远不会让启动失败,只有那个 open 失败会让启动失败。

Windows 错误码矩阵(Wine + 真实 Win32 语义实测,用例源码在 /var/cache/orange-tmp/celemod-113c/wintest)

场景 open() 返回 下一步
文件带 FILE_ATTRIBUTE_READONLY(资源管理器勾「只读」) Access denied. (os error 5) ← 实测 清属性 + 备份 + 重建 → 照常启动
文件级 DACL 拒绝写入(父目录完全控制也挡不住) ERROR_ACCESS_DENIED (5)(Win32 语义;文件对象自己的安全描述符说了算) 同上(清属性没用,改走「改名/删除」失败→降级)
父目录不可写且 .cookies 不存在(创建被拒) ERROR_ACCESS_DENIED (5) 降级 + 弹框(create <目录> 写进日志)
被别的进程独占打开(杀软/同步盘扫描) Sharing violation. (os error 32) ← 实测(改名/删除也是 32) 先重试;仍失败 → 降级 + 弹框(reason 带 os error 32)
8.3 短名 / 大小写不同的路径 不变(同一个文件对象,判定与路径拼写无关) — 真正的变体是目录本身被 junction/重定向:用 dir /a 查 <JUNCTION>

结论:只读属性/文件 ACL → 5;被占用 → 32。 所以「用户 A 的 5」和「第二个用户的 5」是同一条分支(open 被拒 → PluginInitialization → .expect panic),只是触发物不同;如果第二个用户的日志里是 32 而不是 5,那就是被占用而不是属性/ACL —— 日志里的 io 错误码是两个场景唯一可靠的区分点。

PR 这轮加了什么(commit e0ce5f9)

  • 探测加 200 ms × 3 次重试:Wine 实测「文件被独占 300 ms」→ 重试后 Ok(插件照常注册、文件不改动);「永久独占」→ Err(32) → 降级并弹框。这样杀软的一次性扫描不再被误判成坏文件。
  • 其余兜底不变:只读 → 清属性 + 备份 + 重建;内容不可解析 → 备份 + 建空库;都不行才降级,且弹框/日志里带上「哪一步 + 哪个 io 错误码」。

cargo test --lib 仍 116 passed。

仍属推测的部分

第二个用户的 .cookies 已经被删掉了,拿不到它的属性(只读位)和文件级 ACL,celemod.log 也被这次成功启动覆盖了 —— 所以「他的 open 到底为什么被拒(只读属性 / 文件 ACL / 被临时占用)」目前只是推测,能确证的只是「open 被拒 ⇒ 启动器零 UI 退出」这条分支本身。

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants