no-mistakes daemon单例锁实现解析:为什么文件锁不需要陈旧检测
【免费下载链接】no-mistakesgit push no-mistakes项目地址: https://gitcode.com/GitHub_Trending/no/no-mistakes
no-mistakes是一款让git push之后自动执行 AI 代码审查与修复守护流程的开源工具。它的核心是一个长期驻留的daemon 守护进程,负责管理工作树、流水线执行与崩溃恢复。守护进程最经典的安全难题就是单例锁:如何保证同一时刻只有一个 daemon 拥有同一个数据目录?本文从源码角度解析no-mistakes daemon 单例锁的设计,并回答一个反直觉的问题——为什么基于文件锁的实现完全不需要"陈旧检测"(staleness check)。
1. 先搞懂背景:daemon 为什么必须是单例
no-mistakes 的工作方式是:你正常git push no-mistakes,Git 钩子把任务交给本地 daemon,daemon 在一个独立工作树里跑流水线、管理 agent、做崩溃恢复。相关设计在 docs/src/content/docs/concepts/daemon.md 中有完整说明。
正因为 daemon 启动时会执行一系列全局性、破坏性操作:
- 把上次崩溃遗留的运行标记为失败(stale-run recovery)
- 清理孤立的 worktree 目录
- 绑定 IPC socket
所以同一NM_HOME下如果同时跑两个 daemon,后启动的那个会把正在运行的任务的 worktree 误删、把活着的运行误判为崩溃。这就是单例锁必须存在的原因。
2. 核心实现:三行代码 + 操作系统内核
单例锁的实现在 internal/daemon/lock.go,入口函数acquireSingletonLock的逻辑可以概括为三步:
- 打开锁文件:
<NM_HOME>/daemon.lock(路径定义见 internal/paths/paths.go) - 尝试加锁:调用
tryLockFile做非阻塞排他文件锁 - 记录持有者信息:把 PID 和启动时间写入文件(仅供诊断,不参与安全机制)
平台差异封装在两个文件里:
| 平台 | 文件 | 系统调用 | 关键点 |
|---|---|---|---|
| Linux/macOS | internal/daemon/lock_unix.go | flock(LOCK_EX\|LOCK_NB) | 锁挂在"打开文件描述"上,内核在进程退出/崩溃时自动释放 |
| Windows | internal/daemon/lock_windows.go | LockFileEx字节范围锁 | 锁字节放在0xFFFFFFFF偏移,避开文件头部数据区 |
🔑整个方案的核心只有一行系统调用,其余代码都是错误处理和诊断信息。
3. 为什么不需要"陈旧检测"?
传统实现(比如 PID 文件方案)遇到"锁文件存在但持有者已死"的尴尬场景,通常需要自己写一套陈旧检测:读 PID、判断进程是否存活、对比启动时间、超时后强制接管……这套逻辑本身就是竞态的重灾区。
而 no-mistakes 的文件锁天生没有这个问题,原因一句话就能说清:
flock / LockFileEx 这类 OS 原生文件锁,由内核在持有进程死亡(包括被 SIGKILL)时自动释放。锁文件里留着"锁"这个状态,就必然意味着有一个活着的进程持有它——锁永远不会"陈旧"。
源码注释把这一点写得很直白(internal/daemon/lock.go):
内核会在持有进程退出或死亡时自动释放锁——即使没有显式 unlock。这种"自清理"特性正是它不需要像 PID 文件那样做"持有者是否还活着"的陈旧检查的原因:锁只可能被一个确实在运行的进程持有。
对比一下两种方案的失效模式:
- PID 文件:进程被
kill -9后 PID 文件还在 → 需要你写检测代码 → 检测代码可能误判(PID 复用)→ 需要超时、心跳……复杂度指数级上升 - OS 文件锁:进程被
kill -9后内核立即释放锁 → 下一个进程立刻能拿到锁 →零检测代码,零竞态窗口
这就是"把安全性下沉给操作系统"的经典收益。
4. 细节一:被拒绝时如何告诉用户"谁在占用"
锁本身不提供"谁持有"的信息。no-mistakes 的做法是诊断信息与安全机制分离:
- 抢到锁的一方,尽力(best-effort)把
{PID, StartedAt}写入daemon.lock(lock.go) - 抢锁失败的一方,读回这份记录,报错信息里带上"pid 12345, started 2026-08-29T03:00:00Z"
注意注释特意强调:这次写入失败也不致命——因为安全由 OS 锁保证,写入只是让错误提示更友好。这种"核心机制不依赖任何 best-effort 环节"的设计,是它值得学习的地方。
5. 细节二:获取时机——先锁,再动任何东西
锁的获取时机在 internal/daemon/daemon.go 的RunWithOptions里,注释写明了约束:
单例锁必须在崩溃恢复之前、socket 绑定之前获取,并持有一个进程的整个生命周期。
这个顺序不是随意排布。设想一个没有锁的启动流程:第二个 daemon 先"恢复崩溃现场"(把活着的第一个 daemon 的运行标成失败、删掉 worktree),再去绑 socket——灾难就发生了。
对应的回归测试 internal/daemon/singleton_test.go 验证的正是这个场景:
- 第二个 daemon 启动必须快速失败(
ErrSingletonLockHeld),绝不允许它走到 socket 绑定 - 第一个 daemon 在整个过程中必须保持可达
📌 项目根目录的 AGENTS.md 也把这个设计列为守护性约束:"内核在任何进程死亡时释放它,因此持锁永远意味着持有者存活,不需要任何陈旧启发式"。
6. 防御纵深:锁不是唯一一层
值得新手理解的是,no-mistakes 并没有把安全押在单例锁一个点上,而是层层设防(见 docs/src/content/docs/concepts/daemon.md 与 AGENTS.md):
- 单例锁(本文主角):阻止第二个 daemon 启动
- socket 防抢占:IPC 层在删除 socket 文件前先拨号探测,有人在应答就拒绝接管(internal/ipc/client.go)
- 进程扫描对账:internal/daemon/collision.go 处理更隐蔽的情况——同一个目录用了不同路径拼写(如符号链接的
NM_HOME)导致锁文件不同、socket 也不同。此时通过进程列表发现冲突 daemon:健康的拒绝启动,僵死的则杀掉并清理(collision.go) - PID 文件:注意 PID 文件是可以陈旧的,它只做身份记录;"陈旧 socket / 陈旧 PID 文件"由启动时的自愈逻辑处理
各层互为独立安全层,任何一层被绕过都不会直接导致数据破坏。
7. 给开发者的启示 🧭
把 no-mistakes daemon 单例锁的设计提炼成三条可复用的经验:
- 能用 OS 原生机制,就不要自己造。flock/LockFileEx 的"进程死亡自动释放"是内核保证的语义,自研的陈旧检测在正确性上永远打不过它。
- 安全机制与诊断信息分离。锁的持有者是内核事实,写进文件的 PID 记录只是"友好提示",写失败无所谓——这让你的核心路径永远只有一个依赖。
- 明确"获取时机即正确性"。单例锁保护的是破坏性全局操作,所以必须"先锁后做一切",并用回归测试固化这个顺序。
对日常使用 no-mistakes 的用户来说,你几乎不需要感知它的存在:如果真有两个 daemon 冲突,第二个会直接报错并告诉你当前 daemon 的 PID 和启动时间,而不是悄悄把第一个搞挂。这份"安静但绝不失守"的可靠性,正是这套单例锁设计的价值所在。
【免费下载链接】no-mistakesgit push no-mistakes项目地址: https://gitcode.com/GitHub_Trending/no/no-mistakes
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考