我拆了 sdkm 的回滚设计:每个写操作,都提前写好了翻车预案

上篇拆自定义 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 的输出该长什么样。

相关推荐
熟的生菜14 分钟前
从零造一个腱绳驱动灵巧手——仿真器篇4:Qt 界面实战——属性面板、树控件与姿势系统
开源
用户1179104883325 分钟前
鸿蒙签名失败不是玄学:5 个证据点把 hapsigntool 一次排通 GitHub:swsgbl/hmharness
开源·ai编程·harmonyos
数字幽灵26 分钟前
开源!我用 Rust 手搓了一个 Agent 原生的鸿蒙逆向分析平台
rust
用户2991422196801 小时前
GitHub 热榜项目:日榜(2026-11-07)
人工智能·开源·github
Flynt1 小时前
内存省16倍的Lightpanda实测:数字是真的,但并发代码要重写
性能优化·开源·agent
分布式存储与RustFS3 小时前
Hudi 表想摆脱 HDFS 绑定?用 Spark + Hudi 直接写进 RustFS
运维·云原生·开源·对象存储·分布式存储·s3·性能基准
miofly4 小时前
GitHub 日榜趋势速报 | 2026-11-07
开源·github
冬奇Lab15 小时前
一天一个开源项目(第255篇):Mika —— 装在安卓手机里的 AI 代理,在后台虚拟屏上替你操作 App 和网页
人工智能·开源·资讯
孟健17 小时前
实测 agent-desktop:AI 控制桌面不截图、不占鼠标键盘,连点 55 次计算器 0 失误
人工智能·架构·开源