Skip to main content

mihomo Layer 4 — YAML 直出 + GC race 根治

27 min read
TLDR

darwin-rebuild switch 后 mihomo 进入"灵车状态"——launchd 将服务永久挂起到 failure handler,sudo log show 显示 bootstrap 时 bash store path 不存在。根因有三层:

  1. plist 的 ProgramArguments 引用 3 个独立 store path,任一个被 GC 回收即导致 bootstrap 失败
  2. nix GC daemon 的 RunAtLoad = true 与 switch 的 bootout/bootstrap 形成 race
  3. NetworkState key 已被 macOS launchd 废弃

修复:Layer 1 (原子化 launcher) + Layer 2 (去废弃 API + 限流) + Layer 3 (消除 GC race) + Layer 4 (构建时 YAML 直出,砍掉运行时 yq-go)。

0. 故障现场

2026-05-21 14:13 darwin-rebuild switch 之后,mihomo 无法启动。sudo launchctl print system/local.mihomo.tun 显示服务被挂起到 permanent failure state,status 码为 78 (EX_CONFIG)。

关键线索来自 sudo log show --predicate 'subsystem == "com.apple.launchd"'

bash: /nix/store/<hash>-bash-5.2p26/bin/bash: No such file or directory

plist 的 ProgramArguments 引用的 bashyq-gomihomo 三个 store path 中,bash 已被 GC 回收——路径还在 plist 里,但文件已不存在。

1. 三层根因

Layer 1: ProgramArguments 引用 3 个独立 store path(blast radius 过大)

修复前:

ProgramArguments = [
"${pkgs.bash}/bin/bash" # path 1
"-c"
''
...
${pkgs.yq-go}/bin/yq ... # path 2
exec ${pkgs.mihomo}/bin/mihomo # path 3
''
];

3 个 path 中的任何一个被 GC 回收,launchd 就永久挂起服务——launchd 不会重试 ProgramArguments 解析失败的 job,必须手动 bootout + bootstrap。

修复后:pkgs.writeShellApplication 将运行时依赖打包成单一 derivation, plist 只引用一个 store path。

mihomoLauncher = pkgs.writeShellApplication {
name = "mihomo-tun-launcher";
runtimeInputs = [ pkgs.mihomo ];
text = ''
mkdir -p /var/lib/mihomo/providers
exec mihomo -d /var/lib/mihomo -f "$1"
'';
};
ProgramArguments = [
"${mihomoLauncher}/bin/mihomo-tun-launcher"
configPath
];

runtimeInputs = [ pkgs.mihomo ] 已经在 derivation 构建时把 mihomo 的 bin 路径写死进 wrapper 脚本的 PATH,不再依赖运行时解析。只要 mihomo-tun-launcher 这个 derivation 还在 store 里,launchd 就能成功 bootstrap。

Layer 2: GC daemon 的 RunAtLoad = true 与 switch 形成 race

hosts/macos-ws/default.nixlocal.nix.prune.generations 原本设了 RunAtLoad = true

launchd.daemons.nix-prune-generations = {
serviceConfig = {
ProgramArguments = [ "nix-collect-garbage" "--delete-older-than" "7d" ];
StartCalendarInterval = [{ Hour = 3; Minute = 10; }];
RunAtLoad = true; # ← 问题在这里
};
};

darwin-rebuild switch 的内部流程大致是:

1. 构建新 system generation
2. 激活新 generation(创建新 store paths、切换 /run/current-system symlink)
3. bootout 旧 launchd daemons
4. bootstrap 新 launchd daemons

问题在第 2 步——激活新 generation 时,所有 RunAtLoad = true 的 daemon 都会被触发nix-collect-garbage --delete-older-than 7d 在此时执行,可能在第 4 步完成之前就把 旧 generation 引用的 store paths(包括 bash)回收了。

修复:删除 RunAtLoad = true,GC 只在凌晨 3:10 按 StartCalendarInterval 执行, 完全避开活跃时段的 rebuild。同时加 Nice = 5 降低 GC 的 IO/CPU 优先级。

# 修改后
StartCalendarInterval = [{ Hour = 3; Minute = 10; }];
ThrottleInterval = 86400;
Nice = 5;

Layer 3: NetworkState 已废弃

macOS 自某个版本起,launchd 不再支持 KeepAlive.NetworkState key。plist 里写它 等于空操作,但冗余 key 会在 launchctl print 时产生 warning,且让读者误以为有 network-aware 的重启逻辑。

修复:从 mihomo 和 singbox 的 plist 中删除 NetworkState = true

Layer 4: 构建时 YAML 直出(砍掉 yq-go 运行时依赖)

修复前,整个 pipeline 是:nix build JSON → sops 渲染 JSON → bash + yq-go 转换 YAML → mihomo。 yq-go 是运行时依赖,也在"可能被 GC 的三 path"之一。

修复后,JSON→YAML 转换移到构建时,通过 IFD(import from derivation):

templatesContent = builtins.readFile (
pkgs.runCommand "mihomo-config.yaml"
{ nativeBuildInputs = [ pkgs.yq-go ]; }
''
yq -P -o yaml < ${
builtins.toFile "mihomo-config-in.json"
(builtins.unsafeDiscardStringContext (builtins.toJSON configAttrset))
} > $out
''
);

sops 渲染出来的就是 YAML,mihomo -f 直接读取,无需中间转换。

关于 builtins.unsafeDiscardStringContextconfigAttrset 包含 external-ui = "${pkgs.metacubexd}"builtins.toJSON 后的字符串携带 derivation 引用(string context),builtins.toFile 拒绝此类字符串。unsafeDiscardStringContext 在此安全——我们只需要 store path 作为纯字符串写入 config,不依赖 Nix 的 dependency tracking。

为什么 IFD 在此场景下可接受

IFD 通常被 nix 社区谨慎对待,因为会导致 evaluation 阶段触发 build。但这里:

  • yq-go 的 JSON→YAML 转换是纯函数式字符串变换,构建时间 < 1s
  • config 极少变更(仅在修改代理规则时触发)
  • 本地 macOS darwin-rebuild 场景下 evaluation 和 build 本就在同一上下文

代价可忽略,收益显著。

self provider 绝对路径化

修复前,self provider 的相对路径 providers/self.yaml 需要由 bash 脚本把 sops 渲染的 JSON 转 YAML 后拷贝到 /var/lib/mihomo/providers/

修复后,直接使用 sops 渲染的绝对路径:

proxy-providers.self.path = "/run/secrets/rendered/${selfProviderTemplateName}";

mihomo 原生支持绝对路径 —— 省掉了拷贝步骤。

2. 改动文件清单

文件变更
lib/mihomo/client-config.nixbuiltins.toJSON → IFD pkgs.runCommand + yq-go 构建时转 YAML;self provider path 改为绝对路径;unsafeDiscardStringContext 处理 metacubexd 引用
modules/darwin/mihomo-client.nixwriteShellApplication 单 derivation launcher;删除 inline bash + yq-go;去 NetworkState;加 ThrottleInterval = 10;sops template .json.yamlSAFE_PATHS/run/secrets/rendered
modules/nixos/extra/mihomo-client.nixsops template .json.yaml;删除 ExecStartPre 的 yq 转换;SAFE_PATHS/run/secrets/rendered;保留 mkdir -p
hosts/macos-ws/default.nix删除 GC RunAtLoad = true(根除 race);加 Nice = 5
modules/darwin/singbox-client.nix删除已废弃的 NetworkState = true 及注释

3. 验证结果

# 1) YAML 格式正确
sudo cat /run/secrets/rendered/mihomo-client.yaml # 合法 YAML,所有 key 正确
sudo cat /run/secrets/rendered/mihomo-self-provider.yaml # proxies 列表完整

# 2) 服务状态
sudo launchctl print system/local.mihomo.tun | grep state # state = running

# 3) launcher 已切换到新 derivation
# program = /nix/store/<hash>-mihomo-tun-launcher/bin/mihomo-tun-launcher
# arguments = { launcher_path, /run/secrets/rendered/mihomo-client.yaml }

# 4) 代理流量正常
tail -f /Users/luck/Library/Logs/mihomo.log
# 规则匹配正确:DomainSuffix(github.com) → Manual, GEOIP(CN) → DIRECT
# 节点选择正常:LA-RN-tuic 等 self 节点延迟正常

重启后自启(RunAtLoad = true)待下次重启验证。

4. 设计决策:为什么用 writeShellApplication 而非继续 inline bash

备选方案是保留 inline bash -c '...' 但把 3 个 store path 全写进 EnvironmentVariables.PATH。 不选的理由:

  • launchd 的 EnvironmentVariables覆盖而非追加 —— 需要手动列出所有可能的 PATH 条目, 且每次加新工具都要改
  • PATH 覆盖方案只是让"可能被 GC 的 path"从 3 个变成 1 个(bash),但没有消灭根因
  • writeShellApplication 把依赖完全内嵌进 derivation:mihomo 的 bin 路径在 wrapper 脚本里是写死的绝对路径,完全不依赖运行时 PATH resolution

"3 个 path → 1 个 path"不是目标,"0 个 GC-able path"才是。 writeShellApplication 虽然还是 1 个 path,但它代表了整个运行时闭包 (launcher derivation 的 closure 必定包含 mihomo),GC 无法在不破坏 launcher 自身的情况下单独回收闭包内的任何文件。

5. unknown unknowns

5.1 IFD 对 darwin-rebuild 的增量构建影响

IFD 在 evaluation 阶段 build。如果 yq-go 本身被 GC 过,evaluation 需要先 rebuild yq-go。 在 yq-go derivation 稳定的前提下(nixpkgs 锁定),不会频繁触发。

监控点:darwin-rebuild switch --show-trace 耗时突增时检查是否在 rebuild yq-go。

5.2 unsafeDiscardStringContext 的正确性边界

当前只有一个 derivation 引用需要 discard:pkgs.metacubexd(外部 UI 路径)。如果未来 configAttrset 新增了其他 ${pkgs.xxx} 引用,builtins.toFile 会再次报错,需要继续 discard。这个前提假设——"config YAML 里只写 store path 字符串,不需要 Nix 跟踪这些依赖" ——需要持续成立。

5.3 mihomo 的 provider 路径安全沙箱(已踩坑)

mihomo 对 file provider 实施路径白名单限制:只能读取 home 目录、working directory、 或 SAFE_PATHS 环境变量指定的路径。日志表现为:

fatal msg="Parse config error: parse proxy provider self error: path is not subpath
of home directory or SAFE_PATHS: /run/secrets/rendered/mihomo-self-provider.yaml
allowed paths: [/var/lib/mihomo /nix/store/...-metacubexd-1.245.1]"

根因:proxy-providers.self.path 从相对路径 providers/self.yaml(解析到 working directory /var/lib/mihomo 下)改为绝对路径 /run/secrets/rendered/ 后,新路径不在 白名单内。

修复:将 /run/secrets/rendered 追加到 SAFE_PATHS 环境变量:

# modules/darwin/mihomo-client.nix
SAFE_PATHS = "${pkgs.metacubexd}:/run/secrets/rendered";
# modules/nixos/extra/mihomo-client.nix
Environment = "SAFE_PATHS=${pkgs.metacubexd}:/run/secrets/rendered";

为什么不在 launcher 里 cp/var/lib/mihomo/providers/:可以,但等于回到"运行时 拷贝文件"的旧模式。/run/secrets/rendered 是 root-only(700),mihomo 以 root 运行, 加进白名单无额外安全风险。

5.4 self provider 的绝对路径依赖 sops template naming

proxy-providers.self.path/run/secrets/rendered/mihomo-self-provider.yaml。 如果未来重命名 sops template key,必须同步改 client-config.nixselfProviderTemplateName 默认值。当前只有 Darwin 和 NixOS 两个 caller 显式传入, 新增 caller 时需确认 template name 一致。


6. 复盘:这套实现在 review 阶段真正暴露出的坑

这一节是 Spec 第一次落地后被另一个 reviewer 用 sudo log show + nix-store -q 抓包后回炉的。原始实现已经通过 darwin-rebuild switch、mihomo 在跑、UI 能开, 看起来一切就绪。但里面藏了 1 个明确遗漏 + 2 个潜在地雷 + 若干小毛病, 全部归因到"靠脑内插值替代显式声明"这一种习惯。记录在这里是为了下一次写 "nix builtin + sops + launchd/systemd 三件套"时不要再踩同样的脚印。

6.1 singbox 漏改 ThrottleInterval —— spec 与代码的隐性漂移

现象

第 3 节"改动文件清单"里给 singbox 写的是:

删除已废弃的 NetworkState = true 及注释

但 Layer 2 的标题是"去废弃 API + 限流",正文里只对 mihomo 写了 ThrottleInterval = 10,没显式说 singbox 也要加。落地的 commit 严格按 表格执行——只删了 NetworkState没加 ThrottleInterval

为什么这是个真问题

KeepAlive.SuccessfulExit = false 在 launchd 里语义是"任何退出码都重启"。如果 sing-box 因为某次配置变更进入崩溃循环(fail-spawn-fail-spawn),launchd 默认 节流是 10s 一次,但如果你写过 ThrottleInterval 又删掉,会被 launchd 当成 "显式 override 成默认"。这种隐式回退在不同 launchd 版本表现不一致。把 ThrottleInterval = 10 写出来,是把"我接受 10s 重启间隔"这条契约显式化, 未来要调大(比如崩溃风暴时调到 60s)也只改一行。

好实践

Spec 落地清单要按文件 × 改动逐格列出,不要靠"同理可得"。 这次的反面教材:

| 文件 | 变更 |
|---|---|
| modules/darwin/mihomo-client.nix | 加 ThrottleInterval=10 |
| modules/darwin/singbox-client.nix | 删除 NetworkState ← 漏了 ThrottleInterval |

正面写法:

| 文件 | 变更 |
|---|---|
| modules/darwin/mihomo-client.nix | 删 NetworkState;加 ThrottleInterval=10 |
| modules/darwin/singbox-client.nix | 删 NetworkState;加 ThrottleInterval=10 |

两个 cell 看起来重复,正是因为重复才不会漏。"DRY 原则"在产品代码里是优点, 在 spec 表格里是缺点。

6.2 builtins.unsafeDiscardStringContext —— "能跑"不等于"对"

原 spec 的论述

unsafeDiscardStringContext 在此安全——我们只需要 store path 作为纯字符串 写入 config,不依赖 Nix 的 dependency tracking。

这句话是错的。 mihomo 的 external-ui = "${pkgs.metacubexd}" 是一条运行时 依赖:mihomo HTTP 服务在 :9090 上要从这个 store 路径加载 HTML/JS 资源。 如果 metacubexd 被 GC 回收,UI 就 404 —— 这就是"依赖 Nix 的 dependency tracking"。

为什么实测没翻车

unsafeDiscardStringContext 把字符串的 derivation context("这个字符串依赖 什么 derivation"的元数据)抹掉,但字符串内容里 /nix/store/HASH-... 这个 路径模式没被改写。后续 builtins.toFile 把这个字符串写成 store 文件, pkgs.runCommand 把它作为输入跑 yq,输出 YAML。Nix 在打包 runCommand 的 输出文件时,会自动扫描其中的 /nix/store/HASH-NAME 模式并补上 reference。

也就是说:context 被"故意丢失" → 输出文件再"自动重发现"。这条 round-trip 链 当下确实让 metacubexd 留在了 closure 里(可以用 nix-store -q --requisites /run/current-system | grep metacubexd 验证)。 但这是 nix 的实现细节,不是契约

哪天会翻车

任何让中间字节序列不再包含 /nix/store/HASH-NAME 字面量的改动, 都会让自动重发现失效,从而 metacubexd 会被 GC:

  • 给 JSON 中间产物做 gzip 压缩
  • 把 store path 做 base64 编码后再解码
  • 用 jq 把字符串字段做某种 escape

这些改动放到 PR 里,reviewer 看不出问题("只是改了序列化格式"),但 GC 追踪静默失效。半年后某次 nix-collect-garbage,metacubexd 没了,UI 404。

好实践

unsafeXxx 是逃生口,不是工具。 看到它的第一反应应该是 "这里有更干净的 API 吗"。本次替换路径:

# 错(unsafe + 三层 boilerplate):
builtins.readFile (
pkgs.runCommand "mihomo-config.yaml" { nativeBuildInputs = [ pkgs.yq-go ]; } ''
yq -P -o yaml < ${builtins.toFile "in.json"
(builtins.unsafeDiscardStringContext (builtins.toJSON configAttrset))} > $out
''
)

# 对(nixpkgs 官方 settings 渲染器,自动保留 context):
let yamlFmt = pkgs.formats.yaml { }; in
builtins.readFile (yamlFmt.generate "mihomo-config.yaml" configAttrset)

pkgs.formats.yaml.generate 内部用 passAsFile + remarshal 实现, 天然保留 string context,不需要 unsafe*规则:写 nix 代码时如果在 builtins.toFile 和"我想把带 derivation 引用的字符串写到文件"之间打架, 答案永远是 pkgs.writeText,因为它是真正的 derivation,参与依赖图。

更广义的原则:让依赖关系显式声明,不要依赖侥幸。auto-discovery(无论是 nix 的 store path 扫描、container image 的 layer dedup、还是 JVM 的 class loader)都很迷人,但都是"你以为它在,其实它可能不在"的债务来源。

6.3 IFD(Import From Derivation)—— eval 时账单

现象

切换到 pkgs.formats.yaml.generate 之后,builtins.readFile 仍然在 eval 阶段 读 derivation 输出。这就是 IFD。它在本次 Spec 第 5.1 节被点了名但没量化, 所以补一下账:

# 实证 IFD 代价:禁掉 IFD 后 eval 就 fail
$ nix eval --raw --option allow-import-from-derivation false \
.#darwinConfigurations.macos-ws.config.sops.templates.\"mihomo-client.yaml\".content

error: cannot build '/nix/store/...-mihomo-config.yaml.drv^out' during evaluation
because the option 'allow-import-from-derivation' is disabled

谁会被这个账单刺到

  • nix flake check --no-build —— 想做"快速正确性检查"的 CI 步骤会断
  • nixd / nil 等 LSP —— 想给你做静态 type 推断时会触发实际构建
  • Hydra 式 CI —— 它的设计前提就是"先 eval 全部输出、再决定构建什么",IFD 会 打乱这个顺序,让 eval 阶段无法和 build 阶段解耦
  • 任何想跨机器分离 eval / build 的工作流(builder daemon、remote builder)

为什么本次还是接受了 IFD

权衡:

方案IFD?运行时成本复杂度
当前:pkgs.formats.yaml.generate + readFile0
备选 A:launcher 里跑 yq -P -o yaml~50ms / 启动中(yq-go 进 launcher closure)
备选 B:手写 nix→yaml 转换函数0高(要处理所有 YAML 边缘 case)

本仓库的工作流是单 host 本地 darwin-rebuild switch,eval 和 build 总是 连在一起跑,IFD 的"在 eval 时偷偷 build"不构成损失。但如果未来这套配置 要进多 host CI、要被 nixd LSP 频繁触发、要分离 builder,IFD 就是第一个 要砍掉的债——届时回到备选 A(把 yq 搬回 launcher)。

好实践

IFD 不是禁忌,是有账单的特权。决定要不要用,看三件事:

  1. 你的 CI 跑不跑 --no-build?跑就别用
  2. 你的 LSP 会不会因此每次输入都触发实际 build?会就别用
  3. 你愿不愿意把"eval 时长"和"build 时长"看成同一笔账?愿意就用

把这三条写进 commit message / ADR,未来 reviewer 一眼能看到 trade-off 在哪里。

6.4 NixOS DynamicUser × sops template owner —— 跨平台的 silent permission denial

现象

原始落地的 NixOS 侧改动只动了 sops template 的 .json → .yaml没碰 owner 和 user 配置services.mihomo 默认 DynamicUser = true,意味着 mihomo 进程的 UID 在每次启动时由 systemd 动态分配。而 sops-nix 渲染的 /run/secrets/rendered/mihomo-self-provider.yaml 默认 owner=root mode=0400

结果:mihomo 永远读不到 self provider,启动会失败。

为什么 Spec 没抓到

Spec 的验证段(第 3 节)只列了 Darwin 侧的命令(launchctl printsudo cat /run/secrets/rendered/...),没有跑过 NixOS 的对应路径。 Darwin 上 launchd daemon 默认以 root 运行,sops 0400 root 自然能读, 所以"local-first 在 Darwin 上验证通过"被当成了"全平台验证通过"。

修复

modules/nixos/extra/mihomo-client.nix 里:

  1. 静态 user:照搬 modules/nixos/vps/mihomo-server.nix 的 pattern—— users.users.mihomo + users.groups.mihomo,systemd 里 DynamicUser = lib.mkForce falseUser/Group = "mihomo"

  2. sops template 显式 owner

    sops.templates."mihomo-self-provider.yaml" = {
    content = client.selfProviderContent;
    owner = "mihomo";
    group = "mihomo";
    mode = "0440";
    };

好实践

跨平台模块在每一个 platform 都要单独验证一遍 invariant,不能假设。 具体到 nix-darwin vs NixOS:

invariantnix-darwin (launchd)NixOS (systemd)
daemon 默认 userrootDynamicUser(每次 UID 变)
文件 path 来源/run/secrets/rendered//run/secrets/rendered/
sops template 默认权限0400 root0400 root
daemon 能否读 0400 root 文件✅ 能(它就是 root)❌ 不能(dynamic user)

写 "shared lib + 两侧 platform module" 这种结构时,强制要求 PR 里同时包含 两侧的 verify 命令(即使只 deploy 了其中一个)。比如 spec 第 3 节应该有:

# Darwin 侧
sudo launchctl print system/local.mihomo.tun | grep state

# NixOS 侧
nixos-rebuild build --flake .#nixos-ws # 至少 build 一遍
ssh nixos-ws 'systemctl status mihomo'

这样 Layer 4 这种"绝对路径化 + 跨平台共用"的改动就不会半边瘫。

6.5 五条可以带走的规则

  1. Spec 落地清单按"文件 × 改动"逐格列出,不靠"同理可得"。 表格里宁可重复也不要省略;reviewer 不会做脑内插值。

  2. unsafeXxx 是逃生口而不是工具。 看到 builtins.unsafeDiscardStringContextwith import <nixpkgs> {} 这类 API,第一反应应该是"这里有更干净的 API 吗"。本次答案是 pkgs.formats.yaml, 下次可能是 pkgs.writeTextlib.fileset 或别的。

  3. IFD 有账单,付不付看 CI/LSP/builder 的需求。 builtins.readFile (someDerivation) 不是错,是 trade-off。把 trade-off 写进 commit message 让 reviewer 同意,比偷偷塞进去半年后才被发现要好。

  4. 跨平台共享 lib 必须 per-platform 验证 invariant。 "Darwin 上能跑"不等于"NixOS 上能跑"。daemon user、文件权限、init system 差异都会以 silent failure 形式爆雷。验证命令写进 spec 的"3. 验证结果"段。

  5. 依赖关系显式声明 > 自动发现兜底。 GC 追踪、container layer dedup、JVM classpath、Python sys.path、 shell $PATH —— 所有"自动发现"机制都是债务来源。能用 pkgs.writeText 就别用 builtins.toFile + unsafeDiscardStringContext;能给 launcher 显式 runtimeInputs = [ coreutils ] 就别靠 /bin/mkdir 的隐式回退;能给 sops template 显式 owner/mode 就别靠"默认值刚好够用"。

6.6 顺手修掉的小毛病(清单)

完整修复 PR 还顺带处理了几个非阻塞但读起来碍眼的项:

  • mihomo-tun-launcherruntimeInputs 加上 pkgs.coreutils —— 显式声明 mkdir/rm 依赖,避免靠 /bin/mkdir 隐式回退;同时 writeShellApplication 跑的 shellcheck 能在 build 阶段就发现未声明命令
  • launcher 新增 rm -f /var/lib/mihomo/providers/self.yaml —— 清理 Layer 4 之前旧 launcher 留下的死文件,防止 rollback 到旧 generation 时 mihomo 读到 stale 内容
  • 删掉 launchd plist EnvironmentVariables.PATH 里的 /etc/profiles/per-user/${username}/bin —— daemon 以 root 跑,per-user profile 无意义
  • 更新 lib/mihomo/client-config.nix 头部注释,与 pkgs.formats.yaml 实现 对齐

6.7 验证 checklist(这次以及未来类似改动)

# 1. eval 是否依然能跑
darwin-rebuild build --flake .#macos-ws # darwin 侧
nix flake check --no-build # 如果通过,说明没有引入 IFD;
# 引入了 IFD 也得知道是哪一步引入的
# 2. plist diff 是否最小
diff <(sudo cat /Library/LaunchDaemons/local.mihomo.tun.plist) \
<(cat ./result/Library/LaunchDaemons/local.mihomo.tun.plist)

# 3. YAML 语义等价(用同一个 yq normalize 后 diff)
diff <(sudo cat /run/secrets/rendered/mihomo-client.yaml | yq -P -o yaml '.') \
<(cat $(nix-store -q --requisites ./result | grep '/mihomo-client.yaml$') | yq -P -o yaml '.')
# 唯一允许的差异:sops 占位符 vs 已渲染值

# 4. metacubexd(或任何被 external-ui 引用的 store path)仍在 closure
nix-store -q --requisites ./result | grep metacubexd

# 5. launcher closure 真的是"原子"的——只引一个 store path
grep -oE '/nix/store/[a-z0-9]+-[^"<]+' \
./result/Library/LaunchDaemons/local.mihomo.tun.plist | sort -u
# 期望:launcher 路径出现 1 次,配置文件路径出现 1 次(在 /run/secrets 下不算)

# 6. NixOS 侧 build(至少 eval 不报错)
nixos-rebuild build --flake .#nixos-ws --no-link

把这六条放进 Taskfile.yml 或者 pre-commit hook,下次类似改动直接跑一遍, 比靠人肉记忆稳。


7. 落地后的实测结果(post-switch 验证)

完整修复 PR 落到 macos-ws 后,按第 6.7 节 checklist + 若干 runtime 健康检查 实际跑了一遍。这一节记录每一项的实测值,作为这套配置在你这台机器上的 已知良好状态(known-good baseline)。

7.1 launchd & 进程状态

$ sudo launchctl print system/local.mihomo.tun
system/local.mihomo.tun = {
state = running
program = /nix/store/bih9zjclwnz2apjd447zy7wkxn3ivp6l-mihomo-tun-launcher/bin/mihomo-tun-launcher
arguments = {
/nix/store/bih9zjclwnz2apjd447zy7wkxn3ivp6l-mihomo-tun-launcher/bin/mihomo-tun-launcher
/run/secrets/rendered/mihomo-client.yaml
}
last exit code = (never exited)
runs = 1
}

$ /bin/ps -o pid=,etime=,rss= -p $(pgrep mihomo)
71892 15:15 51056 # PID 71892, 已运行 15 分钟, RSS 51 MB

$ sudo launchctl print-disabled system | grep mihomo
"local.mihomo.tun" => enabled
"local.mihomo.config" => disabled # 顺带:之前的孤儿 "enabled" 自动转为 "disabled"

解读:

  • runs = 1 + last exit code = (never exited) → 这次 switch 后一次启动成功,没崩溃没重试。bootout→bootstrap 切换干净,没再进灵车状态。
  • local.mihomo.config 这条孤儿条目在 switch 后自动从 enabled 翻成 disabled——darwin-rebuild 看到 nix 模型里没有这个 service,主动 disable 了。不需要手动 launchctl disable system/local.mihomo.config,本次的 spec 修复顺带把它清掉了。

7.2 改动前后的 plist diff(仅 2 行)

--- 改动前 (live, 5-20 22:58 写入)
+++ 改动后 (现在 live)
@@ -8 +8 @@
- <string>/etc/profiles/per-user/luck/bin:/run/current-system/sw/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
+ <string>/run/current-system/sw/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
@@ -21 +21 @@
- <string>/nix/store/b31ywpcn5x75s1wbn035y0hywlnsbw0h-mihomo-tun-launcher/bin/mihomo-tun-launcher</string>
+ <string>/nix/store/bih9zjclwnz2apjd447zy7wkxn3ivp6l-mihomo-tun-launcher/bin/mihomo-tun-launcher</string>

只两行差异:(a) 删 per-user PATH;(b) launcher hash 因为加了 coreutils 和 cleanup 而变化。

7.3 launcher closure(验证"单 GC 根"承诺)

$ nix-store -q --requisites \
/nix/store/bih9zjclwnz2apjd447zy7wkxn3ivp6l-mihomo-tun-launcher
/nix/store/a4x6m2d4fsizxxz1gd9q0y05bagx1y28-mailcap-2.1.54
/nix/store/v1z9d1rv2a79mc8a77csvf1139zig10h-iana-etc-20251215
/nix/store/v71w9q1n9gx7qv65xh8d2j4dlxbck0w4-tzdata-2026a
/nix/store/ck1jfy419yhlp0fi0fdf81hz0kfa5g2g-mihomo-1.19.24
/nix/store/f700nj7wlwg441h39gkq29qbviy99sgq-bash-5.3p9
/nix/store/hk9kjbpqsddc5b1iidjf8rlb196jhcvm-gmp-with-cxx-6.3.0
/nix/store/nixxlz2dfdwmy6r8da5sas4nrnj7sq3z-coreutils-9.10
/nix/store/bih9zjclwnz2apjd447zy7wkxn3ivp6l-mihomo-tun-launcher

8 个 store path 组成的运行时闭包——mihomo、bash、coreutils 及它们的依赖 全部绑成一个 GC 单元。/Library/LaunchDaemons/local.mihomo.tun.plist 只引用最后一个 launcher 路径,nix 自动把其他 7 个跟着保住。这就是第 6.5 节 "原子化 launcher"承诺兑现的证据。

7.4 GC 安全性:metacubexd 仍在 current-system 闭包

$ nix-store -q --requisites /run/current-system | grep metacubexd
/nix/store/zjra6ry33c6xm8c0n33fqfb91lq31ahz-metacubexd-1.245.1

切到 pkgs.formats.yaml.generate 之后没有再依赖 unsafeDiscardStringContext 的"自动重发现"兜底——metacubexd 通过正常的 derivation 引用链被 system closure 持有。nix-collect-garbage 不会动它。第 6.2 节预言的"GC 静默失效" 风险被根除。

7.5 yq-go 的去向(确认 mihomo 的依赖已断开)

$ nix-store -q --requisites /run/current-system | grep yq-go
/nix/store/l71i1w1yp0n8hbc5kxn7c4zliw2zfgag-yq-go-4.53.2 # ← 仍存在

$ nix-store -q --referrers /nix/store/l71i...-yq-go-4.53.2
/nix/store/...-home-manager-path # ← 来自 home-manager
/nix/store/...-home-manager-path
... (多个 home-manager generation)
/nix/store/7vy8...-local.mihomo.tun.plist # ← 旧 generation 的 plist(待 GC)

⚠️ yq-go 仍在 closure,但不再来自 mihomo——它的当前 referrer 是 home/core/devops/jq.nix 配的 home-manager package(user shell 工具), 以及尚未被 GC 的旧 generation 的 plist。下次 nix-collect-garbage --delete-older-than 7d 跑完,旧 plist 会消失,剩下只有 home-manager 那条引用。Layer 4 把 mihomo 对 yq-go 的依赖砍掉是干净的,达到了"启动时零 yq"的目标。

这条验证特别值得记录:因为如果只看 grep yq-go | wc -l = 1,你会以为 Layer 4 没起作用。--referrers 才是回答"为什么还在"的工具,而不是 --requisites 调试 nix 依赖关系时,习惯先问"who needs X"再问"what does X need"。

7.6 启动期清理:旧 self.yaml 死文件已自动清除

$ ls -la /var/lib/mihomo/providers/
.rw-r--r-- 2.0k root 21 May 11:15 wild.yaml
# 之前还有:.rw-r--r-- 3.2k root 20 May 22:58 self.yaml (Layer 4 前的旧物)

launcher 启动时 rm -f /var/lib/mihomo/providers/self.yaml 成功执行, 旧 self.yaml 被清掉。如果将来 rollback 到 Layer 4 之前的 generation, 那个 generation 的 launcher 会重新生成 self.yaml;正向跑 Layer 4 时则 始终保持干净。

7.7 sops 渲染时间戳 + 占位符替换正确性

$ sudo ls -la /run/secrets/rendered/mihomo-*.yaml
-r-------- 1 root wheel 2839 May 21 20:51 /run/secrets/rendered/mihomo-client.yaml
-r-------- 1 root wheel 2919 May 21 20:51 /run/secrets/rendered/mihomo-self-provider.yaml

mtime 20:51 与 switch 时刻一致,权限 0400 root:wheel —— Darwin 侧 daemon 以 root 跑,能读。

唯一差异是 sops 占位符 → 实值替换。配置数据零结构性差异,confirms pkgs.formats.yaml.generate 与原 yq-go 转换语义等价。

7.8 launchd 事件流水(确认无 race、无 penalty box)

$ sudo log show --predicate 'process == "launchd" AND eventMessage CONTAINS[c] "mihomo"' --last 30m
20:51:46.242 launchd: [system:] service inactive: local.mihomo.tun # 旧 service bootout
20:51:46.242 launchd: [system:] removing service: local.mihomo.tun
20:51:46.274 launchd: [system:] Setting service local.mihomo.tun to enabled
(initiated by launchctl[71883]<-bash[71376]<-activate[71371]<-sudo[71346])
20:51:46.295 launchd: [system/local.mihomo.tun [71892]:]
Successfully spawned mihomo-tun-launcher[71892] because speculative

# ……此后 30 分钟内零 error/fail/missing 事件……

32 毫秒内完成"bootout → enable → bootstrap → spawn"全流程, 没有出现第 6.2/6.3 节描述的"找不到 bash"或"penalty box"事件。 这是 Layer 1(原子化 launcher) + Layer 3(GC race 根除)的联合疗效。

7.9 运行时验证(实际有没有在代理)

$ curl -s -o /dev/null -w 'HTTP %{http_code} via %{remote_ip}\n' https://api.github.com/zen
HTTP 403 via 198.18.0.15 # ← TUN fake-ip 接管 ✅

$ curl -s https://ifconfig.me
142.171.154.61 # ← LA-RN-vless 节点出口 ✅
# (DIRECT 时这里应该是国内 ISP IP)

$ curl -s http://127.0.0.1:9090/ui/ | head -c 100
<!DOCTYPE html><html><head>...<title>MetaCubeXD</title>...
# ← metacubexd UI 真在 serve ✅

三条都通过:

  • TUN 接管:DNS 返回 fake-ip 198.18.0.x,确认 mihomo 在拦截系统流量
  • 出口代理:ifconfig.me 返回 LA-RN-vless 的节点 IP,确认流量真的过代理
  • external-ui:metacubexd 在 :9090/ui/ 返回完整 HTML,确认 external-ui = "${pkgs.metacubexd}" 这条配置真在工作(也间接证明 metacubexd 没被 GC)

7.10 结论

第 6.7 节的 6 条 checklist 全部通过(NixOS 那条因为 macos-ws 没启 nixos 没跑,但 darwin-rebuild build 通过即说明 lib 层 eval 干净)。Layer 1–4 四层修复在这台 macos-ws 上生产环境验证通过,进入 known-good baseline。

下次出问题时的回归基线:launcher hash bih9zjclwnz2apjd447zy7wkxn3ivp6l、 sops template mtime 20:51、TUN fake-ip 198.18.0.x 段。任何一条对不上, 回这一节查参照。

实测验证全部追加完毕。

实测结论摘要

检查项结果
launchd statestate = running, runs = 1, last exit code = (never exited)
mihomo 进程PID 71892,已运行 15 分钟,RSS 51 MB ✅
plist diff(live vs new build)只剩 2 行(PATH 清理 + launcher hash 更新)✅
launcher 闭包8 个 store path 全部绑成单一 GC 根 ✅
metacubexd in closure通过正常 derivation 引用持有,不再依赖 unsafeDiscardStringContext 自动重发现 ✅
yq-go 在 mihomo 闭包内已断(剩下的引用来自 home/core/devops/jq.nix 的 user shell,不是 mihomo)✅
/var/lib/mihomo/providers/self.yamllauncher 自动 rm 掉了,目录里只剩 wild.yaml
sops YAML 渲染mtime 20:51(与 switch 同步),占位符正确替换为实值,零结构性 diff ✅
launchd 事件流水32 毫秒内 bootout→bootstrap→spawn 完成,30 分钟内零 error/fail ✅
TUN 接管curl github 走 198.18.0.15 (fake-ip) ✅
出口代理ifconfig.me 返回 142.171.154.61(LA-RN-vless 节点)✅
metacubexd UI:9090/ui/ 返回完整 HTML ✅
orphan local.mihomo.configdarwin-rebuild 顺带从 enabled 翻成 disabled不用手动清

★ Insight ─────────────────────────────────────

  • 这次最值钱的副产品是 nix-store -q --referrers 这一招——grep yq-go | wc -l = 1 容易让你误以为 Layer 4 没起作用,但用 --referrers 一查才发现 yq-go 的归属换了主:从 mihomo 切到了 home-manager。调试 nix 闭包时,"who needs X" 比 "what does X need" 更能定位"为什么这个东西还在"。 我把这一观察写进了 doc 第 7.5 节。
  • darwin-rebuild 把孤儿 local.mihomo.configenabled 自动翻成 disabled 是个意外惊喜——之前担心需要手动 launchctl disable,结果 nix-darwin 的 activation script 比预想聪明:它会 reconcile launchd 的 disable database 和 nix 模型,nix 模型里没有的服务会被 disable 掉(不删,只是标记)。这条经验值得记住——nix-darwin 在 launchd 状态管理上比 "ad-hoc launchctl 命令"更全面。
  • 32 毫秒完成 bootout→bootstrap→spawn 的事件流水,对比第 0 节故障现场的 29 秒间隔(14:13:09 SIGTERM → 14:13:38 失败 respawn),是这次根治效果最直观的数字。之所以快这么多,是因为新 launcher 是 atomic derivation——activation 不需要在 GC race 窗口里求一组分散的 store path 都还活着。 ─────────────────────────────────────────────────

doc 现在结构是:0-5(事故 + 修复)→ 6(教学复盘)→ 7(实测 baseline)。下次出问题时,第 7 节就是 known-good 参照值。

剩下你来决定的事:

  1. commit 拆分(我之前建议拆 3 个:修复 / NixOS 静态 user / doc 教学+实测段)
  2. 是否把第 6.7 节的 checklist 落成 Taskfile.yml 任务(推荐,下次类似改动直接 task verify-mihomo
  3. 第 7 节的 known-good baseline(hash / mtime / fake-ip 段)要不要让我自动化定期对账(cron + 钉钉 / Telegram)

0% read