Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
420 changes: 420 additions & 0 deletions CHANGELOG.md

Large diffs are not rendered by default.

21 changes: 21 additions & 0 deletions LESSONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,14 @@
| LESSON-20261031-010 | 2026-10-31 | Profiles / URL / ref alignment | `new URL()` 接受 FTP/File/JavaScript 等非订阅协议;共享 ref 的 HTTP(S) Zod schema 并加 CI 回归 | Mitigated |
| LESSON-20261031-011 | 2026-10-31 | Profiles / Actor / remote import | 未知文件夹的远程导入曾先 Fetch 后拒绝;按 ref 在下载前校验,测试严格断言 Fetch 0 次 | Mitigated |
| LESSON-20261031-012 | 2026-10-31 | NixOS / Btrfs / Rust tests | Tauri 测试二进制链接在 Btrfs `reserve_bytes` 阻塞;Cargo check 成功,完整 Client 测试待环境复验 | Open |
| LESSON-20261103-001 | 2026-11-03 | Runtime / Windows / ref | 原样迁入原生存储路径测试在 Windows 将 `/data/ui` 错判为正确路径;改为 `Utf8Path::join` 生成预期值,重新运行 12/12 通过 | Mitigated |
| LESSON-20261104-001 | 2026-11-04 | Rust workspace / Cargo target / pnpm store | 默认 D 盘耗尽导致完整测试链接失败;恢复空间后在默认位置完成全量测试,pnpm 元数据策略检查仍告警 | Mitigated |

### LESSON-20261103-001:原生路径重写测试不能假定 Unix 分隔符

- 证据:在 Windows Rust stable 1.99.0,初次运行 `cargo test -p chimera-core-manager --lib native_store::`,12 项中 2 项因预期 `/data/ui`、`/data/providers/local.yaml` 与实际 `/data\\ui` 等平台路径不同而失败;存储租约/迁移测试 10 项通过。
- 修正:只调整 `crates/chimera-core-manager/src/native_store/paths.rs` 中的测试预期,使用 `Utf8Path::join` 生成同平台路径,未改变 `rewrite_paths` 生产逻辑。重新运行原生存储测试 12/12 通过,随后 Runtime 全库 121 通过、1 忽略。
- 防复发:跨平台迁移参考用例要区分文件系统路径(平台分隔符)与 URL/代理传输路径(协议分隔符);保留测试对 `/ws`、证书 PEM 和凭证字段不重写的断言。本轮未执行 Linux/macOS 真实桌面 E2E。

### LESSON-20261031-001:Profile Layout 未知 UID 不能静默插入文件夹

Expand Down Expand Up @@ -1061,3 +1069,16 @@ backend/Cargo.toml -p chimera --lib core::clash::proxies::tests:: -- --nocapture
- 验证命令、实际用例数量、结果、未覆盖/跳过项:无锁模式的 `cargo test --manifest-path ./backend/Cargo.toml --workspace --all-features` 仅将 `simd-json` 锁定版本更新到 0.19.0,完整 backend workspace 测试通过;随后 CI 同一入口 `pnpm test:backend`(执行 `cargo test --locked --manifest-path ./backend/Cargo.toml --workspace --all-features`)在本机通过。修复后新 SHA 的 GitHub 全平台 CI 仍待验证。
- 风险/回滚和清理证据(如涉及宿主状态):只更新 Cargo 锁文件,不修改运行时用户配置或宿主状态;若需回滚同步,需同时恢复旧 submodule gitlink 与旧锁文件。
- 关联重复事件、后续防复发措施、L3 门禁调整审批链接(如有):每次更新 runtime submodule 时检查其被主仓引用的 Cargo manifests,并在提交前运行 `pnpm test:backend`(其中 `--locked` 用于发现未同步的父锁文件)。本次未修改工作流。

### LESSON-20261104-001:完整工作区测试需同时检查 Cargo 输出盘和 pnpm store

- 日期与时区:2026-11-04 Asia/Shanghai
- 状态:Mitigated(恢复原盘空间后默认构建目录的 workspace 测试通过;pnpm 元数据策略检查仍有网络告警)
- 严重度:Low(本地验证受环境资源影响;没有确认产品断言失败)
- 模块:Rust workspace / Cargo target / pnpm dependency store
- 关联运行日志(脱敏):`cargo test --locked --quiet --manifest-path backend/Cargo.toml --workspace --all-features`。
- 真实异常、复现步骤与证据:首次使用默认 `backend/target` 时,D 盘剩余空间约 1 MiB,Tauri 测试二进制链接报 `rustc-LLVM ERROR: IO failure on output stream: no space on device`,没有到达完整测试执行。使用临时 C 盘 target 后,工作区测试推进至 Tauri 777 项测试中的 696 进度;`specta_export::tests::typescript_bindings_are_fresh` 调用 pnpm 时,其提示 C 盘全局 store 无法硬链接到 D 盘工程并改用 `D:\.pnpm-store`,随后 `[email protected]` tarball 请求多次超时。该次运行在反复重试前被中止,没有报告断言失败。
- 确认的根因与排除项:链接 `ENOSPC` 是 D 盘空间不足;pnpm 的 store 实际落在 D 盘,跨盘构建 target 并未隔离依赖缓存的空间需求。tarball 超时出现在同一空间紧张的环境中,不能单凭它认定代码或依赖本身有缺陷。
- 修复与验证:维护者已清出原构建盘空间;仅删除本次创建的临时 C 盘 Cargo target,按 `cargo metadata` 确认默认输出为 `backend/target` 后,`cargo test --locked --quiet --manifest-path backend/Cargo.toml --workspace --all-features` 在默认目录退出码 0。Tauri 测试二进制 **777 passed / 1 ignored**,其他 workspace 测试与 doctests 均通过;其中包含 local host 配置优先级/错误测试和启动诊断测试。`specta_export::tests::typescript_bindings_are_fresh` 的内部 pnpm 打印 supply-chain metadata fetch 超时及“可能需要安装”的警告,但随后 Prettier 成功处理生成文件,绑定相等断言通过(1/1)。发布版本快进后,pnpm 1,642 条锁文件策略检查通过;`pnpm test:manifest` 的 Deno 测试阶段因持续无输出被中止,相关 assertions 未验证。不要把 pnpm 内部元数据告警或未完成的 Deno 测试记成成功。
- 防复发:运行全量 Cargo 测试前同时检查 `backend/target` 和 pnpm store 所在盘的剩余空间;若切换 `CARGO_TARGET_DIR`,仍需为项目盘上的 pnpm store 留出空间。代理/缓存失败时记录实际下载目标和有效 store,不把未完成运行称为测试通过。
- 风险/回滚和清理证据:未触碰用户配置、宿主代理/TUN 或服务进程;清理目标仅为本次创建的临时 Cargo 构建目录。
19 changes: 11 additions & 8 deletions backend/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion backend/chimera-runtime
150 changes: 124 additions & 26 deletions backend/tauri/src/core/actor_v2/local_host.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,59 +3,104 @@
//! Chimera keeps its application paths and core selector, while the shared
//! lifecycle transaction is owned by `chimera-core-manager`.

#[cfg(target_os = "macos")]
use std::sync::Arc;

use anyhow::Result;
use anyhow::Result as AnyResult;
use camino::Utf8PathBuf;
use chimera_core_manager::{
ControlOptions, CoreControl, CoreKind, CoreManager as RuntimeManager, CoreSpec, LocalIpcPolicy,
ManagerOptions,
ControlOptions, CoreControl, CoreKind, CoreManager, CoreSpec, LocalIpcPolicy, ManagerOptions,
native_store::{FsNativeStore, StoreOwner, legacy_kind},
};
use chimera_utils::process::AtomicStopStrategy;
use snafu::{ResultExt, Snafu};

use crate::{config::chimera::ClashCore, utils::path::PathResolver};

/// Construct the local host from the application's resolved directories.
#[derive(Debug, Snafu)]
pub enum Error {
#[snafu(display("failed to inspect {}", path.display()))]
InspectConfig {
path: std::path::PathBuf,
source: std::io::Error,
},
#[snafu(display("failed to read core kind from {}", path.display()))]
ReadCoreKind {
path: std::path::PathBuf,
source: chimera_core_manager::native_store::StoreError,
},
#[snafu(display("failed to build core manager"))]
BuildManager { source: chimera_core_manager::Error },
#[snafu(display("path is not valid UTF-8: {}", path.display()))]
InvalidPath { path: std::path::PathBuf },
}

/// Builds the local core host with the stop strategy shared by its process supervisors.
///
/// The private manager runtime directory holds epochs and effective configs;
/// staging is a sibling because caller-owned config bytes are materialized
/// only for one control operation. The core working directory remains the
/// user's data directory so existing GeoIP and GeoSite assets stay visible.
pub(crate) async fn build(paths: &PathResolver) -> Result<CoreControl> {
/// # Errors
/// Returns an error if configuration inspection or core manager construction fails.
pub async fn build(
paths: &PathResolver,
shutdown_helper: &std::path::Path,
stop_strategy: Arc<AtomicStopStrategy>,
) -> Result<CoreControl, Error> {
let runtime_root = paths.app_config_dir().join("runtime");
let manager_options = ManagerOptions {
runtime_dir: Some(to_utf8(runtime_root.join("control"))?),
let options = ManagerOptions {
runtime_dir: Some(
to_utf8(runtime_root.join("control")).map_err(|path| Error::InvalidPath { path })?,
),
local_ipc_policy: LocalIpcPolicy::Disable,
#[cfg(windows)]
windows_shutdown_helper: Some(std::env::current_exe()?),
// A local macOS host may need the user to answer a DNS authorization
// dialog. Service hosts already run with administrator privileges.
windows_shutdown_helper: Some(shutdown_helper.to_owned()),
// Local macOS mode may require the user to authorize DNS changes.
#[cfg(target_os = "macos")]
dns_timeout: std::time::Duration::from_secs(60),
..ManagerOptions::default()
};
let working_dir =
to_utf8(paths.app_data_dir().to_owned()).map_err(|path| Error::InvalidPath { path })?;
let native_store = Arc::new(FsNativeStore::new(
working_dir.clone(),
StoreOwner::current(),
configured_core_kind(paths)?,
));
let manager = CoreManager::builder(options)
.native_store(native_store)
.stop_strategy(stop_strategy);

let manager = RuntimeManager::builder(manager_options);
#[cfg(target_os = "macos")]
let manager = manager.dns_controller(Arc::new(
chimera_core_manager::dns::macos::MacosDnsController::new(
"State:/Network/Service/chimera-dns/DNS".into(),
),
));
let manager = manager.build().await?;
let source_dir = to_utf8(runtime_root.join("staging"))?;
let working_dir = to_utf8(paths.app_data_dir().to_owned())?;

let manager = manager.build().await.context(BuildManagerSnafu)?;
let source_dir =
to_utf8(runtime_root.join("staging")).map_err(|path| Error::InvalidPath { path })?;

Ok(CoreControl::spawn(
manager,
ControlOptions::new(source_dir, working_dir),
))
}

fn configured_core_kind(paths: &PathResolver) -> Result<Option<CoreKind>, Error> {
let typed_config = paths.application_config_path();
let config_path = if typed_config.try_exists().context(InspectConfigSnafu {
path: typed_config.as_path(),
})? {
typed_config
} else {
paths.chimera_config_path()
};
legacy_kind(config_path.as_path()).context(ReadCoreKindSnafu {
path: config_path.as_path(),
})
}

/// Resolve an application core choice to the executable identity understood
/// by the shared process manager. `ChimeraClient` deliberately keeps its own
/// kind even though it accepts the clash-rs command line.
pub(crate) fn core_spec(core: &ClashCore) -> Result<CoreSpec> {
pub(crate) fn core_spec(core: &ClashCore) -> AnyResult<CoreSpec> {
core_spec_with(core, |core, core_type| {
if *core == ClashCore::ChimeraClientAlpha {
let binary_name = format!("chimera-client-alpha{}", std::env::consts::EXE_SUFFIX);
Expand All @@ -72,7 +117,7 @@ fn core_spec_with(
&ClashCore,
&chimera_utils::core::CoreType,
) -> std::io::Result<std::path::PathBuf>,
) -> Result<CoreSpec> {
) -> AnyResult<CoreSpec> {
let core_type: chimera_utils::core::CoreType = core.into();
let kind = match core {
ClashCore::ClashPremium => CoreKind::ClashPremium,
Expand All @@ -81,7 +126,8 @@ fn core_spec_with(
ClashCore::ChimeraClient => CoreKind::ChimeraClient,
ClashCore::ChimeraClientAlpha => CoreKind::ChimeraClient,
};
let binary_path = to_utf8(find_binary(core, &core_type)?)?;
let binary_path = to_utf8(find_binary(core, &core_type)?)
.map_err(|path| anyhow::anyhow!("path is not valid UTF-8: {}", path.to_string_lossy()))?;

Ok(CoreSpec {
kind,
Expand All @@ -91,9 +137,8 @@ fn core_spec_with(
})
}

fn to_utf8(path: std::path::PathBuf) -> Result<Utf8PathBuf> {
fn to_utf8(path: std::path::PathBuf) -> Result<Utf8PathBuf, std::path::PathBuf> {
Utf8PathBuf::from_path_buf(path)
.map_err(|path| anyhow::anyhow!("path is not valid UTF-8: {}", path.to_string_lossy()))
}

#[cfg(test)]
Expand All @@ -106,7 +151,15 @@ mod tests {
let paths =
PathResolver::with_base_dirs(root.path().join("config"), root.path().join("data"));

let control = build(&paths).await.unwrap();
let control = build(
&paths,
&root.path().join("helper.exe"),
Arc::new(AtomicStopStrategy::new(
chimera_utils::process::StopStrategy::default(),
)),
)
.await
.unwrap();

assert!(matches!(
control.status().state,
Expand All @@ -116,6 +169,51 @@ mod tests {
control.shutdown().await.unwrap();
}

#[tokio::test]
async fn typed_config_takes_precedence_and_legacy_parse_errors_are_preserved() {
let root = tempfile::TempDir::new().unwrap();
let paths =
PathResolver::with_base_dirs(root.path().join("config"), root.path().join("data"));
std::fs::create_dir_all(paths.app_config_dir()).unwrap();
std::fs::create_dir_all(paths.app_data_dir()).unwrap();
std::fs::write(paths.application_config_path(), "core: chimera-client\n").unwrap();
std::fs::write(paths.chimera_config_path(), "core: mihomo\n").unwrap();
assert_eq!(
configured_core_kind(&paths).unwrap(),
Some(CoreKind::ChimeraClient),
"typed config must take precedence over the legacy core choice"
);
let stop_strategy = Arc::new(AtomicStopStrategy::new(
chimera_utils::process::StopStrategy::default(),
));

let control = build(
&paths,
&root.path().join("helper.exe"),
Arc::clone(&stop_strategy),
)
.await
.expect("the typed config must be selected before the legacy core choice");
control.shutdown().await.unwrap();

std::fs::remove_file(paths.application_config_path()).unwrap();
assert_eq!(
configured_core_kind(&paths).unwrap(),
Some(CoreKind::Mihomo),
"legacy config must be used when the typed config is absent"
);
std::fs::write(paths.chimera_config_path(), "invalid: [\n").unwrap();
let result = build(&paths, &root.path().join("helper.exe"), stop_strategy).await;
let Err(error) = result else {
panic!("the malformed legacy config must be rejected");
};
let legacy_path = paths.chimera_config_path();
assert!(matches!(
error,
Error::ReadCoreKind { ref path, .. } if path == &legacy_path
));
}

#[test]
fn core_spec_preserves_each_supported_brand_and_executable_mapping() {
let root = tempfile::TempDir::new().unwrap();
Expand Down
20 changes: 17 additions & 3 deletions backend/tauri/src/core/actor_v2/local_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1263,6 +1263,20 @@ mod tests {
status::{ConfigRevisionInfo, RevisionIdInfo},
};

async fn build_local_control(
paths: &crate::utils::path::PathResolver,
) -> chimera_core_manager::CoreControl {
local_host::build(
paths,
&paths.app_config_dir().join("helper.exe"),
Arc::new(chimera_utils::process::AtomicStopStrategy::new(
chimera_utils::process::StopStrategy::default(),
)),
)
.await
.unwrap()
}

// Contract: with an isolated app root and a newly built local host, status
// must come from CoreControl as stopped/normal; a host that silently kept
// reading the unused legacy manager would not know its manager's timestamp.
Expand All @@ -1275,7 +1289,7 @@ mod tests {
root.path().join("config"),
root.path().join("data"),
);
let control = local_host::build(&paths).await.unwrap();
let control = build_local_control(&paths).await;
let runtime_paths = RuntimePaths::new(
camino::Utf8PathBuf::from_path_buf(root.path().join("config/runtime/clash.yaml"))
.unwrap(),
Expand Down Expand Up @@ -1414,7 +1428,7 @@ mod tests {
root.path().join("config"),
root.path().join("data"),
);
let control = local_host::build(&paths).await.unwrap();
let control = build_local_control(&paths).await;
let runtime_paths = RuntimePaths::new(
camino::Utf8PathBuf::from_path_buf(root.path().join("config/runtime/clash.yaml"))
.unwrap(),
Expand Down Expand Up @@ -1555,7 +1569,7 @@ mod tests {
root.path().join("config"),
root.path().join("data"),
);
let control = local_host::build(&paths).await.unwrap();
let control = build_local_control(&paths).await;
let runtime_paths = RuntimePaths::new(
camino::Utf8PathBuf::from_path_buf(root.path().join("config/runtime/clash.yaml"))
.unwrap(),
Expand Down
Loading
Loading