darwin-rebuild switch 后 mihomo 进入"灵车状态"——launchd 将服务永久挂起到 failure
handler,sudo log show 显示 bootstrap 时 bash store path 不存在。根因有三层:
- plist 的 ProgramArguments 引用 3 个独立 store path,任一个被 GC 回收即导致 bootstrap 失败
- nix GC daemon 的
RunAtLoad = true与 switch 的 bootout/bootstrap 形成 race NetworkStatekey 已被 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 引用的 bash、yq-go、mihomo 三个 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.nix 的 local.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.unsafeDiscardStringContext:configAttrset 包含
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.nix | builtins.toJSON → IFD pkgs.runCommand + yq-go 构建时转 YAML;self provider path 改为绝对路径;unsafeDiscardStringContext 处理 metacubexd 引用 |
modules/darwin/mihomo-client.nix | writeShellApplication 单 derivation launcher;删除 inline bash + yq-go;去 NetworkState;加 ThrottleInterval = 10;sops template .json→.yaml;SAFE_PATHS 加 /run/secrets/rendered |
modules/nixos/extra/mihomo-client.nix | sops 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.nix 的
selfProviderTemplateName 默认值。当前只有 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 + readFile | 是 | 0 | 低 |
备选 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 不是禁忌,是有账单的特权。决定要不要用,看三件事:
- 你的 CI 跑不跑
--no-build?跑就别用 - 你的 LSP 会不会因此每次输入都触发实际 build?会就别用
- 你愿不愿意把"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 print、
sudo cat /run/secrets/rendered/...),没有跑过 NixOS 的对应路径。
Darwin 上 launchd daemon 默认以 root 运行,sops 0400 root 自然能读,
所以"local-first 在 Darwin 上验证通过"被当成了"全平台验证通过"。
修复
在 modules/nixos/extra/mihomo-client.nix 里:
-
静态 user:照搬
modules/nixos/vps/mihomo-server.nix的 pattern——users.users.mihomo+users.groups.mihomo,systemd 里DynamicUser = lib.mkForce false、User/Group = "mihomo"。 -
sops template 显式 owner:
sops.templates."mihomo-self-provider.yaml" = {
content = client.selfProviderContent;
owner = "mihomo";
group = "mihomo";
mode = "0440";
};
好实践
跨平台模块在每一个 platform 都要单独验证一遍 invariant,不能假设。 具体到 nix-darwin vs NixOS:
| invariant | nix-darwin (launchd) | NixOS (systemd) |
|---|---|---|
| daemon 默认 user | root | DynamicUser(每次 UID 变) |
| 文件 path 来源 | /run/secrets/rendered/ | /run/secrets/rendered/ |
| sops template 默认权限 | 0400 root | 0400 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 五条可以带走的规则
-
Spec 落地清单按"文件 × 改动"逐格列出,不靠"同理可得"。 表格里宁可重复也不要省略;reviewer 不会做脑内插值。
-
unsafeXxx是逃生口而不是工具。 看到builtins.unsafeDiscardStringContext、with import <nixpkgs> {}这类 API,第一反应应该是"这里有更干净的 API 吗"。本次答案是pkgs.formats.yaml, 下次可能是pkgs.writeText、lib.fileset或别的。 -
IFD 有账单,付不付看 CI/LSP/builder 的需求。
builtins.readFile (someDerivation)不是错,是 trade-off。把 trade-off 写进 commit message 让 reviewer 同意,比偷偷塞进去半年后才被发现要好。 -
跨平台共享 lib 必须 per-platform 验证 invariant。 "Darwin 上能跑"不等于"NixOS 上能跑"。daemon user、文件权限、init system 差异都会以 silent failure 形式爆雷。验证命令写进 spec 的"3. 验证结果"段。
-
依赖关系显式声明 > 自动发现兜底。 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-launcher的runtimeInputs加上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 state | state = 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.yaml | launcher 自动 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.config | darwin-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.config从enabled自动翻成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 参照值。
剩下你来决定的事:
- commit 拆分(我之前建议拆 3 个:修复 / NixOS 静态 user / doc 教学+实测段)
- 是否把第 6.7 节的 checklist 落成
Taskfile.yml任务(推荐,下次类似改动直接task verify-mihomo) - 第 7 节的 known-good baseline(hash / mtime / fake-ip 段)要不要让我自动化定期对账(cron + 钉钉 / Telegram)