---
date: 2026-05-09
type: error
tags: [bash, flock, fd-inheritance, slot-pool]
---

# bash 后台 fork 默认继承 FD：flock 释放不掉，下次 claim 同 slot 直接拒锁

## 现象

slot-pool 设计：`slot-claim.sh` 用 flock 在 `slot-N.lock.flock` 上拿 FD 200 的独占锁，做完后用 `exec 200>&-` 关闭。中间 `nohup bash slot-heartbeat.sh ... &` fork 一个常驻心跳守护。

测试场景：

1. claim slot-1 → 成功，daemon 起来
2. 把 slot-1 的 lock 改老（heartbeat_at 60s 前 + heartbeat_pid 改成已死 PID）
3. `slot-sweep.sh` 回收 stale lock → lock 文件被删
4. 再次 claim slot-1 → **flock 拿不到，claim_slot 返回 1**
5. 池被错误地认为"无空闲 slot"，exit 3

`slot-status.sh` 显示 slot-1=free，但 claim 拒锁。**状态显示和实际 lock 行为不一致**。

## 根因

bash 后台进程默认**继承父进程所有打开的 FD**。`flock` 是把锁挂在 OFD（Open File Description，内核级 file struct）上，而不是 FD 编号。父子共享同一个 OFD：

- 父进程：`exec 200>"$flockf"` 打开 OFD A，FD 200 → A
- `flock -n 200` 在 A 上加排他锁
- `nohup bash slot-heartbeat.sh ... &` 子进程继承 FD 200 → 同一个 A
- 父进程 `exec 200>&-` 关闭父侧 FD 200，但 A 还有子进程引用 → flock 不释放
- 子 daemon 不死之前，A 一直存在，flock 一直在

`bash -x` 上看到的：

```
++ exec 200>"${flockf}"
++ flock -n 200
++ return 1                # ← 第二次 claim 卡这里
```

## 解法

让 daemon 不继承 FD 200。三种写法（任一即可）：

1. **fork 时显式关闭 FD 200**（推荐，最干净）：

   ```bash
   nohup bash slot-heartbeat.sh "$slot" >/dev/null 2>&1 200>&- &
   ```

   `200>&-` 在子命令的 redirection 里，apply 给子进程 exec 之前的 fd table。

2. **daemon 自己关**：在 `slot-heartbeat.sh` 第一行加 `exec 200>&- 2>/dev/null || true`。
   缺点：耦合到 daemon 的实现（daemon 必须知道父进程用的是 FD 200）。

3. **在 fork 之前就释放 flock**：先 `exec 200>&-` 再 `nohup ... &`。
   等价于先弃锁再 fork，子进程根本没机会继承。本案最终选这个 + 1 双保险。

## 适用范围

- 任何 bash 脚本里 `flock` + 后台 fork 的组合都会踩这个坑（git hook、deploy 脚本、CI runner）。
- 也适用于 `nohup`、`disown`、`coproc`、subshell `&`，FD 继承是默认行为。
- 调试入口：`lsof -p <daemon-pid> | grep <flockfile>`，能看到 daemon 还持有这个文件的 FD。
- 反向：如果你 _想_ 父子共享 lock（少见），别动 FD，让继承自然发生。

## 其它教训

- `slot-status.sh` 显示 free 但 `slot-claim.sh` 拿不到，是个 race 报警信号——**lock 状态跨观察口不一致 ≈ 持锁的 FD 还在某个进程里**。后面任何 lock 库设计都要在状态显示里列出"flock 实际持有者"以方便定位。
- bash `set -euo pipefail` 下，subshell 内任意命令失败会让 subshell 提前退出，外层 `chosen="$(claim_slot ...)"` 拿到空字符串。debug 时记得加 `bash -x`。
