FIFO queue-lock 深度解析
PID-liveness FIFO lock 如何让 build 和 land 在本机跨 worktree 串行执行。
导入链与契约
锁的公共接口
QueueLock 的公共契约很小:src/lib/queue-lock.ts:43 定义 AcquireOptions,src/lib/queue-lock.ts:48 暴露 acquire/release/lane/held。这个接口克制,意味着 build-lock 和 land 可以共用一套互斥原语,而不用各自发明不同的排队语义。src/lib/queue-lock.ts:55 的 repoKey 又把锁绑定到 git common dir,不会让同一个 repo 的多个 worktree 锁分裂。
真正的设计点在 src/lib/queue-lock.ts:73 到 src/lib/queue-lock.ts:80:queueName 决定是 build 队列还是 land 队列,repo hash 决定作用域,tickets 子目录和 lock 文件分别承担排队和持锁状态。src/lib/queue-lock.ts:82 还把当前目录名作为 lane,方便等待日志显示是哪条 lane。
43export interface AcquireOptions { 44 label?: string; 45 onWait?: (info: { ahead: number; holder: LockHolder | null }) => void; 46} 47 48export interface QueueLock { 49 acquire(options?: AcquireOptions): Promise<void>; 50 release(): void; 51 readonly lane: string; 52 readonly held: boolean; 53} 54 55function repoKey(): string { 56 try { 57 const commonDir = execSync("git rev-parse --git-common-dir", { 58 encoding: "utf8", 59 }).trim(); 60 // Resolve to an absolute, worktree-independent path so every worktree of 61 // the same repo hashes to the same queue. 62 return execSync(`cd "${commonDir}" && pwd -P`, { encoding: "utf8" }).trim(); 63 } catch { 64 return process.cwd(); 65 } 66} 67 68/** 69 * Create a named FIFO lock. Each distinct `queueName` is an independent 70 * mutex — "build" and "land" never contend with each other even though 71 * they share this exact same code. 72 */ 73export function createQueueLock(queueName: string): QueueLock { 74 const QUEUE_DIR = join( 75 tmpdir(), 76 `claude-code-merge-queue-${queueName}-queue-${createHash("sha1").update(repoKey()).digest("hex").slice(0, 12)}`, 77 ); 78 const TICKETS_DIR = join(QUEUE_DIR, "tickets"); 79 const LOCK_FILE = join(QUEUE_DIR, "lock"); 80 mkdirSync(TICKETS_DIR, { recursive: true }); 81 82 const lane = basename(process.cwd()); 83 const ME = process.pid; 84 const TICKET_TS = Date.now(); 85 const TICKET_NAME = `${TICKET_TS}-${ME}`; 86 const TICKET_FILE = join(TICKETS_DIR, TICKET_NAME);
核心实现逐段拆
公平队列
排队公平性来自 src/lib/queue-lock.ts:103 的 pruneDeadTickets 和 src/lib/queue-lock.ts:122 的排序。它按 timestamp 再按 pid 排序,所以不是“谁 poll 得快谁赢”。这比简单 while-lock-file-exists 更重要,因为多个 coding agent 往往会同时结束测试,公平队列能减少重复 rebase 和饥饿。
原子抢锁在 src/lib/queue-lock.ts:140。写临时文件后用 src/lib/queue-lock.ts:144 的 linkSync 建立 lock 文件:如果目标已存在,文件系统保证失败。失败后 src/lib/queue-lock.ts:154 读取 holder,并在 src/lib/queue-lock.ts:155 判断 PID 是否还活着;死 holder 会在 src/lib/queue-lock.ts:157 被回收。
88 function alive(pid: number): boolean { 89 if (!pid || pid === ME) return pid === ME; 90 try { 91 process.kill(pid, 0); 92 return true; 93 } catch (e) { 94 return (e as NodeJS.ErrnoException).code === "EPERM"; // exists but owned by someone else 95 } 96 } 97 98 function pidOf(name: string): number { 99 const dash = name.lastIndexOf("-"); 100 return dash === -1 ? 0 : Number(name.slice(dash + 1)); 101 } 102 103 function pruneDeadTickets(): string[] { 104 let names: string[]; 105 try { 106 names = readdirSync(TICKETS_DIR); 107 } catch { 108 return []; 109 } 110 const live: string[] = []; 111 for (const name of names) { 112 if (alive(pidOf(name))) { 113 live.push(name); 114 } else { 115 try { 116 unlinkSync(join(TICKETS_DIR, name)); 117 } catch { 118 /* someone else cleaned it */ 119 } 120 } 121 } 122 live.sort((a, b) => { 123 const [ta, pa] = a.split("-").map(Number) as [number, number]; 124 const [tb, pb] = b.split("-").map(Number) as [number, number]; 125 return ta - tb || pa - pb; 126 }); 127 return live;
130 function readLockHolder(): LockHolder | null { 131 try { 132 return JSON.parse(readFileSync(LOCK_FILE, "utf8")) as LockHolder; 133 } catch { 134 return null; 135 } 136 } 137 138 // Atomically take the lock via link() (fails if it already exists). Reclaim 139 // a lock whose holder is dead. Returns true iff we now hold it. 140 function tryTakeLock(info: LockHolder): boolean { 141 const tmp = `${LOCK_FILE}.${ME}.tmp`; 142 writeFileSync(tmp, JSON.stringify(info)); 143 try { 144 linkSync(tmp, LOCK_FILE); 145 unlinkSync(tmp); 146 return true; 147 } catch (e) { 148 try { 149 unlinkSync(tmp); 150 } catch { 151 /* noop */ 152 } 153 if ((e as NodeJS.ErrnoException).code !== "EEXIST") throw e; 154 const holder = readLockHolder(); 155 if (!holder || !alive(holder.pid)) { 156 try { 157 unlinkSync(LOCK_FILE); 158 } catch { 159 /* another waiter beat us to it */ 160 } 161 } 162 return false; 163 } 164 }
关键流程
获取与释放
acquire 的循环在 src/lib/queue-lock.ts:192 开始:先写 ticket,再每轮 prune,检查自己在队列中的位置。只有 src/lib/queue-lock.ts:201 满足 ahead <= 0 且 lockFree 时才尝试拿锁。成功后 src/lib/queue-lock.ts:205 删除自己的 ticket,说明 holder 与 waiter 身份分离,队列目录不会把已持锁者重复算作等待者。
release 也保持幂等:src/lib/queue-lock.ts:167 只在 held 时删除 lock,并验证 src/lib/queue-lock.ts:170 holder pid 是自己;随后 src/lib/queue-lock.ts:179 不管是否 held 都尝试删 ticket。这让正常退出和异常恢复走同一条清理路径。
166 let HOLD = false; 167 function release(): void { 168 if (HOLD) { 169 const holder = readLockHolder(); 170 if (holder && holder.pid === ME) { 171 try { 172 unlinkSync(LOCK_FILE); 173 } catch { 174 /* already gone */ 175 } 176 } 177 HOLD = false; 178 } 179 try { 180 unlinkSync(TICKET_FILE); 181 } catch { 182 /* already gone */ 183 } 184 } 185 186 const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); 187 188 /** 189 * Wait for and take the lock. `onWait({ahead, holder})` fires whenever the 190 * queue position changes, so callers can print progress. 191 */ 192 async function acquire({ label, onWait }: AcquireOptions = {}): Promise<void> { 193 writeFileSync(TICKET_FILE, JSON.stringify({ pid: ME, lane, label, ts: TICKET_TS })); 194 let announced = -1; 195 for (;;) { 196 const queue = pruneDeadTickets(); 197 const ahead = queue.indexOf(TICKET_NAME); // 0 = our turn 198 const holder = readLockHolder(); 199 const lockFree = !holder || !alive(holder.pid); 200 201 if (ahead <= 0 && lockFree) { 202 if (tryTakeLock({ pid: ME, lane, label, ts: Date.now() })) { 203 HOLD = true; 204 try { 205 unlinkSync(TICKET_FILE); 206 } catch { 207 /* noop */ 208 } 209 return; 210 } 211 } 212 213 if (ahead > 0 && ahead !== announced) { 214 announced = ahead; 215 onWait?.({ ahead, holder: null }); 216 } else if (ahead <= 0 && holder && alive(holder.pid) && announced !== 0) { 217 announced = 0; 218 onWait?.({ ahead: 0, holder }); 219 } 220 await sleep(POLL_MS); 221 }
边界与失败
测试直接覆盖了两个最关键性质。test/queue-lock.test.ts:32 启动四个真实 worker,test/queue-lock.test.ts:49 按开始时间排序,再用 test/queue-lock.test.ts:53 断言区间不重叠。崩溃恢复测试在 test/queue-lock.test.ts:59 开始,test/queue-lock.test.ts:75 用 kill -9 杀 holder,随后 test/queue-lock.test.ts:80 启动第二个 worker,证明没有永久死锁。
32test("mutual exclusion: four concurrent workers never overlap while holding the lock", async () => { 33 const dir = mkdtempSync(join(tmpdir(), "claude-code-merge-queue-lock-test-")); 34 const resultsFile = join(dir, "results.ndjson"); 35 const queueName = `test-mutex-${process.pid}-${Date.now()}`; 36 37 const workers = [0, 1, 2, 3].map(() => spawnWorker(queueName, resultsFile, 150)); 38 await Promise.all(workers.map(waitExit)); 39 40 const events = readEvents(resultsFile); 41 const intervals = new Map<number, { start: number; end: number }>(); 42 for (const e of events) { 43 const cur = intervals.get(e.pid) ?? { start: 0, end: 0 }; 44 cur[e.event === "start" ? "start" : "end"] = e.t; 45 intervals.set(e.pid, cur); 46 } 47 assert.equal(intervals.size, 4, "expected all four workers to record start+end"); 48 49 const sorted = [...intervals.values()].sort((a, b) => a.start - b.start); 50 for (let i = 1; i < sorted.length; i++) { 51 const prev = sorted[i - 1]!; 52 const cur = sorted[i]!; 53 assert.ok(cur.start >= prev.end, `worker holding [${prev.start},${prev.end}] overlapped worker starting at ${cur.start}`); 54 } 55 56 rmSync(dir, { recursive: true, force: true }); 57}); 58 59test("crash safety: a killed holder is reclaimed, not a permanent deadlock", async () => { 60 const dir = mkdtempSync(join(tmpdir(), "claude-code-merge-queue-lock-test-")); 61 const resultsFile = join(dir, "results.ndjson"); 62 const queueName = `test-crash-${process.pid}-${Date.now()}`; 63 64 // Worker A holds the lock for far longer than the test's patience — it 65 // should never release it gracefully. It gets SIGKILLed instead. 66 const holder = spawnWorker(queueName, resultsFile, 60_000); 67 68 const deadline = Date.now() + 5_000; 69 while (Date.now() < deadline) { 70 if (existsSync(resultsFile) && readFileSync(resultsFile, "utf8").includes('"start"')) break; 71 await new Promise((r) => setTimeout(r, 50)); 72 } 73 assert.ok(existsSync(resultsFile) && readFileSync(resultsFile, "utf8").includes('"start"'), "holder never acquired the lock"); 74 75 execSync(`kill -9 ${holder.pid}`); 76 77 // Worker B should still be able to acquire the SAME queue — if the lock 78 // weren't reclaimed via PID-liveness, this would hang until the test 79 // runner's own timeout, not queue-lock's (there is no queue-lock timeout). 80 const second = spawnWorker(queueName, resultsFile, 10); 81 const code = await waitExit(second); 82 assert.equal(code, 0, "second worker should acquire and finish cleanly after the holder was killed"); 83 84 const events = readEvents(resultsFile).filter((e) => e.pid === second.pid); 85 assert.equal(events.length, 2, "second worker should have recorded both start and end"); 86 87 rmSync(dir, { recursive: true, force: true }); 88});
关联模块
这个锁不是孤立模块。land 在 src/land.ts:78 创建名为 land 的队列,build-lock 在 src/build-lock.ts:1 使用同类语义,而 ephemeral resource 在 src/lib/ephemeral.ts:105 把 claim/use/release 模式迁移到测试资源。换句话说,项目把“进程活性判断”作为基础工程原语复用。
97/** 98 * Run `fn` against a freshly created resource, prune any orphans left by a 99 * previous crashed run first, and always release the claim — including on 100 * a thrown error. If THIS process is killed before the `finally` runs, the 101 * resource isn't leaked forever: the next call to `withEphemeralResource` 102 * (in the next test run, from any lane) prunes it via `orphans()` before 103 * creating its own. 104 */ 105export async function withEphemeralResource<T>( 106 provider: EphemeralResourceProvider<T>, 107 registry: ClaimRegistry, 108 fn: (resource: T) => Promise<void>, 109): Promise<void> { 110 for (const orphan of registry.orphans()) { 111 await provider.destroyOrphan(orphan); 112 registry.release(orphan.id); 113 } 114 115 const id = `${Date.now()}-${process.pid}`; 116 const claim: Claim = { id, pid: process.pid, createdAt: Date.now() }; 117 registry.record(claim); 118 119 const resource = await provider.create(); 120 try { 121 await fn(resource); 122 } finally { 123 await provider.destroy(resource); 124 registry.release(id); 125 } 126}
源码判断
这套锁的优势是简单、可审计、无需 daemon;代价是只能协调一台机器。src/lib/queue-lock.ts:74 把状态放在 os tmpdir,跨机器不会共享;但这正好匹配项目定位:本地 parallel Claude Code agents 的交通灯,而不是云端 merge queue。