上篇拆自定义 SDK 时埋了个钩子:install 中断的残留怎么清、卸载怎么删得干净、config 写到一半断电怎么办。这篇全部接住。拆完你会发现一个反常识的事实:一个版本管理器最贵的代码,不是下载提速那几段,而是那些"以防万一"------每个写操作,都提前写好了自己怎么翻车。代码基于 master(v0.5.4),仓库链接在文末,每段都能对着源码看。
版本管理器最容易翻车的环节是什么?下载?
不是。下载挂了,大不了重来一次,损失的是流量。真正危险的是"改到一半":symlink 换了一半、PATH 插了一半、config 写了一半------三个"一半"凑一台戏,你的终端就不是终端了,是案发现场。
sdkm 对这事的态度很统一:**凡是会改系统状态的操作,动手前都先想好搞砸了怎么办。**这篇拆四个现场------install(装)、switch(切)、config(写盘)、self update(换自己),外加卸载的离职流程。看它怎么把"一半"这个词从字典里抠掉。
install:半成品永远不上台
install 的下载引擎(断点续传、镜像加速)篇三拆过了,不重复。这次看一个更日常的场景:你 sdkm install node 22 敲下去,下到 87% 的时候笔记本没电了。第二天重开机,再装一次------上次那半个尸体怎么办?
看 crates/sdkcore/src/install/mod.rs 的开头:
rust
// 先清理该 sdk 上次中断的残留,避免旧 sdk.zip/extracted 污染本次
let sdk_tmp_root = get_sdkm_home()?.join(SDKM_TMP_DIR).join(&sdk_name);
if sdk_tmp_root.exists()
&& let Err(e) = fs::remove_dir_all(&sdk_tmp_root)
{
warning!("failed to clean stale tmp at {}: {}", sdk_tmp_root.display(), e);
}
每次开工,先扫工地。上次的烂摊子直接 remove_dir_all 走人,连清不掉都只是 warning 不拦你------工地扫不干净可以边干边说,但不能不开工。
真正的核心设计在后面:**全程化妆间作业。**下载的压缩包、解压出来的文件,全放在 ~/.sdkm/.tmp/<sdk>/<版本> 这个临时目录里折腾。你的正式仓库 store/ 目录?碰都不碰。一直要到第八步,解压产物通过了所有检查,才通过 normalize_extracted_dir 搬进正式目录------这是整个流程里唯一一次"上台"。
那上台之后发现不行呢?看第九步:
rust
// ── Phase 9: 验证安装 ────────────────────────────────────
let verify_pb = InstallProgress::new_verify();
if let Err(e) = verify_extraction(&version_dir, sdk_conf.bin_dir.as_deref()) {
let _ = fs::remove_dir_all(&version_dir);
cleanup_temp(&tmp_dir)?;
bail_bug!("Installation verification failed: {}. Rolled back.", e);
}
上了台发现是水货,当场拉下来:版本目录删掉,临时目录删掉,报错文案里明晃晃写着 Rolled back.。
这套逻辑我愿称之为化妆间理论 :所有试错都在后台完成,半成品永远不见观众。化妆间里翻车,粉底糊了重拍就是;舞台上翻车,那是事故。你 Ctrl+C 一百次,store/ 目录里都不会出现一个残废版本------因为它压根还没资格进去。
顺带一个处女座细节:cleanup_temp 删完临时目录,还会看一眼父目录是不是空了,空了连父目录一起删。收工不留渣。
switch:动手之前,先拍五张照片
install 只是在自己家里折腾,switch 不一样------它动的是你的系统。一次 switch,一秒内要动四样东西:符号链接、PATH、环境变量、config 文件。任何一步跪了,都是开头说的"案发现场"。
sdkm 的解法朴素得像过马路先看红绿灯:**动手之前,先拍照。**看 crates/sdkcore/src/switch.rs:
rust
/// switch 操作前的系统状态快照,用于失败时回滚
struct SwitchSnapshot {
/// 符号链接路径(如 C:\sdkm\symlink\java)
symlink_path: PathBuf,
/// 旧链接指向的目标路径,None 表示旧链接不存在
old_symlink_target: Option<PathBuf>,
/// 环境变量旧值,None 表示变量之前不存在
old_env_values: HashMap<String, Option<String>>,
/// 本次 switch 添加到 PATH 的所有条目(回滚时按反序 remove)
added_path_entries: Vec<String>,
/// 内存级 config 快照(SdkmConfig 已 derive Clone)
old_config: SdkmConfig,
}
这五个字段不是随手拍的,每个都有讲究。
old_symlink_target 的类型是 Option<PathBuf>,那个 None 是精髓:它记住的不是"旧链接指向哪",而是"旧链接本来就不存在"。这俩在回滚的时候是两种完全不同的操作------前者要把旧链接重建回去,后者只需要把新链接删干净。分不清这个,回滚就会凭空造出一个指向旧版本的链接,给你留一个"我明明没装过这玩意,怎么 java 还能用"的灵异事件。
old_env_values 更损,是 HashMap<String, Option<String>>------套娃 Option。外层的键是"这个环境变量归不归这次 switch 管",内层的 None 是"这个变量在我动手之前压根不存在"。回滚的时候区别对待:有旧值的写回旧值,之前不存在的直接 unset。为什么不统一设成空字符串?因为 JAVA_HOME="" 和"没有 JAVA_HOME"对下游工具是两码事------空字符串是"这屋里没人",变量不存在是"这栋楼压根没这间房"。回滚要还原的是世界观,不是字符串。
added_path_entries 只记自己加的 PATH 条目------你的 PATH 里本来有一百个条目,sdkm 只动自己那两三个,回滚也只删自己加的。别人的东西,一根手指头都不碰。
照片拍完,才开始动手。执行分五步:建链接 → 设环境变量 → 加主 bin 到 PATH → 加 extra_paths → config 写盘。每一步都包在同一个宏里,try_step!:
rust
// ── try_step! 宏:执行操作,失败时自动回滚并提前返回 ──
//
// 消除 Phase 3 中 5 处重复的 `if-let-Err + rollback + return Err(e)` 模式。
// 已知权限错误(access denied / privilege not held)不标记,未知错误标记 BugReportError。
macro_rules! try_step {
($expr:expr, $snapshot:expr, $manager:expr, $msg:expr $(, $fmt_arg:expr)* $(,)?) => {
match $expr {
Err(e) => {
warning!($msg $(, $fmt_arg)*);
rollback($snapshot, $manager)?;
// 已知权限错误(access denied 5 / EACCES 13、privilege not held 1314)属用户环境问题,
// 提示自行解决;其余未知错误标记 bug report
let is_perm = e.downcast_ref::<io::Error>()
.and_then(|ie| ie.raw_os_error())
.map_or(false, |code| matches!(code, 5 | 13 | 1314));
if is_perm {
let hint = if cfg!(windows) { "run sdkm as administrator" } else { "check permissions" };
bail!("{}: {hint}", e);
}
return Err(BugReportError::wrap(e));
}
Ok(val) => val,
}
};
}
注意 5 | 13 | 1314 这三个数字------Windows 的 access denied、Unix 的 EACCES、还有 privilege not held。这三个码是操作系统在说"这不是程序的锅,是你账户没门票"。碰到它们,sdkm 提示你用管理员权限重跑;碰不到,那说明可能是自己的 bug,包一层 BugReportError 请你顺手报个 issue。区分"我翻车了"和"你被拦了",报错才不会浪费用户的时间。
回滚不是异常处理,是主流程
上面那个宏里,rollback 和成功路径写在同一行指令里、同一个待遇、同一个作者。这是我想强调的价值观:在这套源码里,回滚不是 try-catch 里随手一抹的边角料,是和主流程平起平坐的主角。
回滚本身怎么做的?看 switch.rs:
rust
/// 回滚环境变量:有旧值写回,无旧值删除
fn rollback_envs(snapshot: &SwitchSnapshot, manager: &SdkManager) -> Result<()> {
let mut restore_envs = HashMap::new();
let mut unset_keys = Vec::new();
for (key, old_val) in &snapshot.old_env_values {
if old_val.is_some() {
restore_envs.insert(key.clone(), old_val.clone());
} else {
unset_keys.push(key.clone());
}
}
if !restore_envs.is_empty() {
manager.env_operation.restore_sdk_envs(&restore_envs)?;
}
for key in unset_keys {
manager.env_operation.unset_sdk_env(&key)?;
}
Ok(())
}
/// 回滚 PATH:按反序移除所有已添加的路径条目
fn rollback_paths(snapshot: &SwitchSnapshot, manager: &SdkManager) -> Result<()> {
for path_entry in snapshot.added_path_entries.iter().rev() {
manager.env_operation.remove_sdk_path(path_entry)?;
}
Ok(())
}
两个细节值得停一下。
一个是 .rev()------PATH 条目按反序删。为什么?主 bin 先加、extra_paths 后加,PATH 的语义是有序的,谁在前谁优先。要恢复原样,就得把最后穿上的先脱掉------栈式卸妆,先穿的后脱,顺序错了世界观就歪了。
另一个是全量回滚的调用顺序。rollback 函数里四个恢复动作的执行序是 config → paths → envs → symlink,和动手时的 3a→3e 完全相反。而且每个恢复动作失败都不中断:
rust
/// 全量回滚(尽力而为策略:每个步骤失败不中断后续回滚)
/// 只输出对用户有影响的结果,不暴露内部细节
fn rollback(snapshot: &SwitchSnapshot, manager: &mut SdkManager) -> Result<()> {
let mut failures: Vec<RollbackFailure> = Vec::new();
// 按反向顺序回滚:config → paths → envs → symlink
if rollback_config(snapshot, manager).is_err() {
failures.push(RollbackFailure {
description: "Config file may be inconsistent with actual state.".into(),
suggestion: "Run `sdkm switch <sdk> <version>` again to auto-fix.",
});
}
回滚自己也可能翻车------万一恢复 PATH 的时候又跪了呢?sdkm 的处理是:记下来,继续滚剩下的 。最后把没恢复成的项目收集起来,用 RollbackFailure 打印给你,每个都带两行字:影响是什么(description),你该怎么办(suggestion)。
"Config file may be inconsistent with actual state. → Run sdkm switch <sdk> <version> again to auto-fix."------不装死,不甩栈,不给你看内部细节,只告诉你房间里哪样东西可能没归位、一句话怎么归位。回滚失败不等于世界末日,等于一张便条。
config:断电瞬间的两种世界线
现在看第三个"一半":config 写到一半断电。
传统写法是 fs::write 直接覆写 config.toml------几 KB 的文件,写一半断电,你得到半份 TOML。下次启动,解析失败,整个工具瘫痪。config 文件是版本管理器的户口本,户口本烂一半,全家都是黑户。
sdkm 的写法在 crates/sdkcore/src/config/mod.rs,一个函数,四步注释写得明明白白:
rust
/// 原子写入配置到磁盘(写入-重命名模式)
///
/// 1. 序列化为 TOML
/// 2. 写入同目录下的临时文件 config.toml.tmp.{timestamp}
/// 3. fs::rename 临时文件 → 正式文件(POSIX 原子,Windows 同卷安全)
/// 4. 失败时清理临时文件
pub fn atomic_write_to_disk(&self) -> Result<()> {
let config_path = get_sdkm_config_path()?;
let dir = config_path.parent().context("config file has no parent directory")?;
// 序列化为 TOML
let content = toml::to_string_pretty(self).context("Failed to serialize config to TOML")?;
// 生成临时文件名(基于时间戳,防并发冲突)
let ts = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_nanos();
let tmp_name = format!("{}.tmp.{:016x}", CONFIG_FILE_NAME, ts & 0xFFFFFFFFFFFFFFFF);
let tmp_path = dir.join(&tmp_name);
// 写入临时文件
fs::write(&tmp_path, &content).context("Failed to write temporary config file")?;
// rename 临时文件 → 正式文件
let rename_result = fs::rename(&tmp_path, &config_path);
if rename_result.is_err() {
// rename 失败,清理临时文件后返回错误
let _ = fs::remove_file(&tmp_path); // 尽力清理
return rename_result.context("Failed to rename temporary config to final path");
}
Ok(())
}
核心就一步:先写 config.toml.tmp.{纳秒时间戳},写完再 rename 成正式文件。而 fs::rename 在 POSIX 上是原子操作,Windows 同卷也稳。
原子,意味着不可分割。断电发生在任何一个瞬间,世界只有两种形态:**旧的 config 完整无损,或者新的 config 完整就位。**没有第三种,没有"一半"。最坏情况是磁盘上躺着一个 .tmp 垃圾文件------它孤零零的,无害,下次写入时同目录再也没人认识它。
但 sdkm 觉得光有原子写还不够,改 config 的操作前还要再拍一层快照,crates/sdkcore/src/config/snapshot.rs:
rust
/// config 操作前的配置快照,用于写入失败时回滚
pub struct ConfigSnapshot {
/// 内存级完整配置副本(SdkmConfig 已 derive Clone)
pub(crate) old_config: SdkmConfig,
/// 磁盘级原始 TOML 内容(最坏情况的磁盘级恢复)
pub(crate) old_file_content: String,
}
/// 拍摄配置快照:在修改配置之前备份
pub fn take_config_snapshot() -> Result<ConfigSnapshot> {
let old_config = SdkmConfig::read_from_disk()?;
let config_path = get_sdkm_config_path()?;
let old_file_content = fs::read_to_string(&config_path).context("Failed to read current config for snapshot")?;
Ok(ConfigSnapshot {
old_config,
old_file_content,
})
}
两份备份,两个层级:内存里一份结构化的(SdkmConfig 整个 Clone),磁盘原文一份字符串的(读出来啥样就存啥样)。恢复时先试内存级------clone 回去重新原子写入;连序列化都出问题了,就上磁盘级------当初逐字节读出来的原文,直接写回去。
考试抄两遍答案,一份记脑子里,一份写在袖口上。一份废了还有另一份,这个冗余的成本是几 KB 内存,收益是 config 永远死不了。
self update:飞行中换引擎
前面四个都是"改别人的",最后这个最刺激:替换正在运行的二进制自己。
这个需求听起来就很刑。Windows 的规矩是:不许 delete 一个正在运行的 exe,也不许覆写打开着的它------但允许 rename。因为 rename 改的只是目录条目(文件的名字指针),文件本体和进程的句柄都稳坐钓鱼台。crates/sdkcore/src/self_update.rs 的头注释里直接把这条生存法则写了出来:
rust
// - 全程 rename 不 delete:Windows 允许 rename 正在运行的 exe(MoveFileEx 改目录条目),
// 但不允许 delete/写打开 running exe------self_uninstall 注释的"running exe 自删不可靠"正指 delete,本方案避开。
飞行中换引擎的合规操作手册:不许拆旧引擎,但可以把旧引擎改名挪走。整个替换流程长这样(replace_binary 函数,有删节):
rust
// 备份当前 exe → work/.bak(rename running exe,同卷合法;不用 delete 避锁坑)
fs::rename(exe, &bak).context(
"Failed to back up current binary (SDKM_HOME on a different drive than sdkm.exe? \
keep them on the same drive; or close other sdkm processes / check dir perms)",
)?;
// 换入新二进制(exe 路径已空,纯改名)
if let Err(e) = fs::rename(new_exe, exe) {
let _ = fs::rename(&bak, exe);
bail!("Failed to place new binary: {}; rolled back to previous", e);
}
// 验证:spawn 新 exe --version,输出含目标版本号
match spawn_version(exe) {
Ok(out) if out.contains(target_version) => Ok(()),
Ok(out) => {
rollback_to_bak(exe, &bak, &work, &name);
bail!(
"new binary reported `{}` (expected v{}); rolled back to previous",
out.trim(),
target_version
);
}
Err(e) => {
rollback_to_bak(exe, &bak, &work, &name);
bail!("new binary failed to start ({}); rolled back to previous", e);
}
}
三步舞:旧 exe rename 进后台(.bak)→ 新 exe rename 上岗 → 拉起来跑一圈 --version 面试。面试标准具体到输出里必须包含目标版本号------不是"能跑就行",是"跑到对的地方才行"。面试不过?坏的新货 rename 挪去 .discard,.bak 原地复位,报错文案还是那句熟悉的味道:rolled back to previous。
新货进不了 config 的面试线,sdkm self update --rollback 还有个手动档:纯本地恢复,不联网。这里有个死结------回滚的时候旧的 exe 还在运行,它怎么把自己换下去?还是那招:把运行中的自己 rename 挪到 .discard 腾出位置(不是 delete!),让 .bak 上岗,然后再试着删 .discard。删不掉就删不掉吧------Windows 上进程还占着文件,硬删是删不掉的------留在 work 目录里不碍事,下次 self update 开场 clean_leftovers 会顺手收拾它。
这套设计里我最欣赏的细节:**删不掉的东西,改个名字晾在一边,等下次路过再收拾。**不硬刚文件系统的物理法则,也不假装问题不存在。Windows 开发者看了会沉默,Unix 开发者看了会流泪。
还有个前置动作值得一提:碰网络下载任何东西之前,先在 exe 目录建一个 .sdkm_write_test 试试能不能写。写不了直接 bail,一个字节都不下载------先看有没有门票再出门,别排了两小时队到了门口才说证件不全。
uninstall:一场体面的离职
最后是卸载。上篇埋的钩子里"删得干不干净",答案全在 crates/sdkcore/src/uninstall.rs。
卸载一个版本,sdkm 先看它是什么身份,分三种处理:
- 非当前激活版本:直接删版本目录,干净利落;
- 当前激活版本、但还有别的版本装着:先 switch 到别的版本再删------相当于离职前先把工作交接出去;
- 当前激活版本、且仅此一版:清干净全套环境再删。
第三种是重头戏。cleanup_sdk_environment 干的事,注释里写得像离职流程清单:
rust
/// 清除 SDK 的全部激活环境(卸载当前唯一版本时调用)
///
/// 尽力而为:symlink/PATH/env 清理失败仅 warning 不中断后续步骤;
/// 仅 config(current_version=None)写入失败时回滚并 bail(避免残留指向已删版本的幽灵 current)。
pub(crate) fn cleanup_sdk_environment(&mut self, sdk: &Sdk) -> Result<()> {
四步:删 symlink → 清 PATH → unset 环境变量 → config 里把 current_version 抹掉。前三步尽力而为,失败只 warning,流程继续;唯独第四步,失败就回滚 config 并直接 bail。为什么这步这么刚?注释原话:避免残留指向已删版本的幽灵 current。
人都走了,户口本上还挂着名------config 说你激活的是 node 24,store 里 node 24 已经没了,每个读到这条记录的下游逻辑都要精神错乱一次。所以别的都能将就,这一步必须刚:写不进去就整个回滚,宁可卸载失败重试,不留幽灵户口。
删版本目录这步还藏着 Windows 生存智慧:
rust
// Windows 上目录可能被杀毒/索引/进程短暂锁定,重试几次通常可恢复
let mut last_err: Option<io::Error> = None;
for attempt in 0..3u32 {
match fs::remove_dir_all(dir) {
Ok(()) => return Ok(true),
Err(e) => {
last_err = Some(e);
if attempt < 2 {
thread::sleep(Duration::from_millis(300));
}
}
}
}
删失败?睡 300 毫秒,再删。还失败?再睡 300 毫秒,再删。躲杀毒软件三板斧:等三秒,再等三秒,还等三秒。三杀之后还不行------不硬来,告诉你路径,请你自己动手,函数返回 false 但不报错。杀毒软件扫目录的那几秒锁,跟它较劲不如睡一觉。
卸载前还有个只读检测值得表扬:check_project_references 会扫一眼当前目录向上的 .sdkm.toml,看有没有项目 pin 了这个即将被删的版本。注意是模糊匹配------项目里 pin 了个 "21",你删的是 "21.0.2",也算命中。命中了就提示你:这个项目依赖即将消失,删完后它的 sdkm env 会回退到全局。提示,但不阻断------工具可以比用户多想一步,但不能替用户做决定。
所以,"安全"到底安全了什么
回到开头那三个"一半":
symlink 换一半?五张照片早拍好了,反序回滚,先穿的后脱。PATH 插一半?added_path_entries 一条条记着账,.rev() 伺候。config 写一半?原子 rename,世界线里根本没有"一半"这个形态。
四条战线看下来,这套源码的设计哲学可以用一句话打包:**每个写操作,都当自己会失败来设计。**不是悲观主义,是工程自觉------翻车预案不是文档里的一页纸,是写在主流程里的每一行代码,享受和成功路径同等的注释密度和测试覆盖。
而且你会发现它对"备胎"这个词有执念:install 的 .tmp 是备胎(半成品隔离场)、SwitchSnapshot 是备胎(旧状态全量克隆)、.bak 是备胎(旧二进制的活体保存)、.discard 是备胎(删不掉的残件的疗养院)。一个 4MB 的工具,一半的聪明劲花在"怎么把事做成"上,另一半花在"做砸了怎么收场"上。
发这篇的时候 star 到 132 了(发布当天实抓)。数字不大,但一个把回滚当主流程写的项目,值得这个曲线。
sdkm(GitHub:borenchan/sdkmate)单文件 4MB,Windows 原生,内置 41 个工具,switch 失败自动回滚、config 原子落盘、self update 带备份可回退。文档全中文,卸载行为、自更新机制都在仓库 docs/ 里。觉得这套"备胎文化"有意思,去仓库点个 star 就是最好的源码评审。
下一篇拆 Agent Skill:sdkm 怎么把自己调成 AI 顺手的样子------给 Claude 当工具用的时候,一个 CLI 的输出该长什么样。