Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
187 changes: 187 additions & 0 deletions docs/board-lock-watcher.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
## 开发板文件锁与取消等待使用说明

本文档说明如何在多组织共享同一块开发板时,结合文件锁与 `lock-watcher.sh`,实现 **等待中的 Job 能被 Cancel 正常打断**,避免死锁。

---

### 1. 组件概览

- **`runner-wrapper/runner-wrapper.sh`**:为 Runner 注入 Job Started / Completed 钩子。
- **`runner-wrapper/pre-job-lock.sh`**:在 Job 开始前获取板子级文件锁(`flock`),并通过后台子进程持有锁。
- **`runner-wrapper/post-job-lock.sh`**:在 Job 结束时创建 `.release` 标记,唤醒持锁子进程释放锁。
- **`runner-wrapper/lock-watcher.sh`**(新增):运行在宿主机上的守护脚本,周期性查询某个仓库下 Actions Run 的状态;当发现持锁 Run 已被 **Cancel** 时,强制清理解锁文件,避免后续等待 Job 永久卡死。

锁文件结构(默认目录 `/tmp/github-runner-locks`):

- `${RESOURCE_ID}.lock`:flock 使用的锁文件
- `${RESOURCE_ID}.holder`:当前持锁信息,格式为 `PID RUNNER_NAME RUN_ID RUN_ATTEMPT`
- `${RESOURCE_ID}.${RUNNER_NAME}.${RUN_ID}.${RUN_ATTEMPT}.release`:释放标记,由 `post-job-lock.sh` 创建

---

### 2. Runner 端配置(各组织 .env)

在各组织对应的 `.env` 中(示例:`.env.linebridge` / `.env.yoinspiration`):

```bash
ORG=<org-name>
REPO=test-runner
GH_PAT=ghp_xxx # Runner 注册用 Classic PAT

RUNNER_RESOURCE_ID_ROC_RK3568_PC=board-roc-rk3568-pc
RUNNER_LOCK_HOST_PATH=/tmp/github-runner-locks
RUNNER_LOCK_DIR=/tmp/github-runner-locks
```

注意:

- 多组织共享同一块板子时,所有相关 `.env` 中的
- `RUNNER_RESOURCE_ID_ROC_RK3568_PC`
- `RUNNER_LOCK_HOST_PATH`
- `RUNNER_LOCK_DIR`
必须保持一致。

修改完 `.env` 后,重启对应 Runner:

```bash
ENV_FILE=.env.linebridge ./runner.sh restart
ENV_FILE=.env.yoinspiration ./runner.sh restart
```

---

### 3. 宿主机上配置 lock-watcher

#### 3.1 实例数量建议

- 默认推荐:**每个参与共享同一块板子的仓库,各启动一个 `lock-watcher.sh` 实例**,即每个仓库一份 `ORG/REPO/GITHUB_TOKEN` 配置。
- 例如:`linebridge/test-runner`、`yoinspiration/test-runner` 各有一份 `.env.watcher.*` 与一个对应的 watcher 进程。

#### 3.2 准备 PAT

在对应组织账号下创建 **Fine-grained PAT**(推荐):

- 选择包含 `test-runner` 的仓库(例如 `linebridge/test-runner`)。
- 在 **Repository permissions** 中将 **Actions** 设置为 **Read-only**。

生成后得到 `github_pat_xxx`。

#### 3.2 创建 watcher 环境文件

在仓库根目录创建 `.env.watcher`(示例为监控 `linebridge/test-runner` 与 `board-roc-rk3568-pc` 板):

```bash
ORG=linebridge
REPO=test-runner
GITHUB_TOKEN=github_pat_xxx
RUNNER_RESOURCE_ID=board-roc-rk3568-pc
RUNNER_LOCK_DIR=/tmp/github-runner-locks
INTERVAL=10
```

> 如需为其他组织(例如 `yoinspiration`)单独监控,可再创建一个环境文件(如 `.env.watcher.yoinspiration`),修改 `ORG` / `REPO` / `GITHUB_TOKEN` 后启动第二个 watcher 实例。

#### 3.3 安装依赖

在宿主机上安装 `jq` 以解析 GitHub API 返回的 JSON:

```bash
sudo apt update
sudo apt install -y jq
```

---

### 4. 启动 lock-watcher

在宿主机上打开一个长期运行的终端(建议放在 tmux/screen 或 systemd 服务中):

```bash
cd /home/fei/os-internship/github-runners

source .env.watcher

./runner-wrapper/lock-watcher.sh
```

启动成功后,终端会打印类似:

```text
[lock-watcher] monitoring linebridge/test-runner, resource=board-roc-rk3568-pc, lock_dir=/tmp/github-runner-locks, interval=10s
```

运行过程中示例日志:

- 正常持锁:

```text
[lock-watcher] run_id=22663158623 status=in_progress conclusion=<none> pid=1511 holder_runner=DESKTOP-...-runner-roc-rk3568-pc
```

- 对应 workflow 在 GitHub 上被 Cancel 后:

```text
[lock-watcher] run_id=22663158623 status=completed conclusion=cancelled pid=1511 holder_runner=DESKTOP-...-runner-roc-rk3568-pc
[lock-watcher] detected cancelled workflow, force releasing lock for board-roc-rk3568-pc
```

表示 watcher 已检测到 Cancel,并强制清理解锁文件。

---

### 5. 典型验证流程

以下以 `linebridge/test-runner` 与 `yoinspiration/test-runner` 共享 `board-roc-rk3568-pc` 板为例。

#### 5.1 触发占板子的 holder

在 `linebridge/test-runner` 仓库中:

1. 打开 Actions,选择 `board-lock-holder` workflow。
2. 点击 **Run workflow** 触发一次运行。
3. 在日志中看到:

- `Waiting for lock: board-roc-rk3568-pc`
- `Acquired lock for board-roc-rk3568-pc`

表示 holder 成功持有锁并占用板子。

#### 5.2 触发等待的 waiter

在 `yoinspiration/test-runner` 仓库中:

1. 打开 Actions,选择 `board-lock-waiter` workflow。
2. 点击 **Run workflow**。
3. 在日志中可以看到:

- `Waiting for lock: board-roc-rk3568-pc`

表示 waiter 正在等待同一块板子的锁。

#### 5.3 Cancel 并观察自动解锁

1. 在 `linebridge/test-runner` 的 `board-lock-holder` 运行页面点击 **Cancel workflow**。
2. 几秒后,宿主机上 `lock-watcher.sh` 日志应出现:

```text
[lock-watcher] run_id=... status=completed conclusion=cancelled ...
[lock-watcher] detected cancelled workflow, force releasing lock for board-roc-rk3568-pc
```

3. 此时 `yoinspiration` 侧的 `board-lock-waiter` 将在锁释放后继续执行,直至 Job 完成,而不会长时间卡在等待状态。

---

### 6. 常见问题

- **Q: watcher 日志中一直是 `status=in_progress conclusion=<none>`?**
**A:** Run 还在运行中,尚未 Cancel 或完成,watcher 只会记录状态,不会释放锁。需要在 GitHub 页面上点击 Cancel,且等状态变为 Cancelled 后再观察。

- **Q: watcher 日志中频繁出现 `empty response for run_id=..., skip`?**
**A:** 对应的 Run 不属于当前 `ORG/REPO`,或 `GITHUB_TOKEN` 对该仓库没有足够的 Actions 读取权限。请确认:
- `.env.watcher` 中的 `ORG` / `REPO` 是否与 Run 实际所在仓库一致;
- Fine-grained PAT 是否勾选了对应仓库,并将 Actions 权限设为 Read-only。

- **Q: 没有安装 jq 时,status / conclusion 总是 `<none>`?**
**A:** 需要先在宿主机安装 `jq`,否则 watcher 无法从响应 JSON 中解析状态。

1 change: 1 addition & 0 deletions docs/多组织共享Runner使用说明.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ RUNNER_LOCK_DIR=/tmp/github-runner-locks

关键要求:

- 只有设置了 `RUNNER_RESOURCE_ID_PHYTIUMPI` / `RUNNER_RESOURCE_ID_ROC_RK3568_PC` 的板子才会启用 runner-wrapper 与锁目录挂载,未配置的板子仍使用默认 `run.sh`,不参与锁协调。
- 两套配置的 `RUNNER_RESOURCE_ID_*` 必须一致(同板卡共享同一锁)。
- 两套配置的 `RUNNER_LOCK_HOST_PATH` 必须一致(指向同一宿主机目录)。

Expand Down
101 changes: 101 additions & 0 deletions runner-wrapper/lock-watcher.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
#!/usr/bin/env bash
set -euo pipefail

# lock-watcher.sh - 简单的文件锁清理脚本
#
# 场景:
# - pre-job-lock.sh 在获取锁时可能因为网络/runner 异常导致锁长期不释放
# - GitHub 前端 Cancel workflow 后,run 状态变为 cancelled,但本地锁文件还在
# - 本脚本定期检查 holder 文件中的 run_id,对应 run 如已 cancelled,则强制清理解锁
#
# 使用方式(示例,在宿主机上运行):
# export ORG=yoinspiration
# export REPO=test-runner
# export GITHUB_TOKEN=github_pat_xxx # 具备 Actions 只读权限
# export RUNNER_RESOURCE_ID=board-roc-rk3568-pc
# export RUNNER_LOCK_DIR=/tmp/github-runner-locks
# ./runner-wrapper/lock-watcher.sh
#
# 必要环境变量:
# ORG, REPO, GITHUB_TOKEN
# 可选环境变量:
# RUNNER_RESOURCE_ID(默认:default-hardware 或自行设置)
# RUNNER_LOCK_DIR(默认:/tmp/github-runner-locks)
# INTERVAL(轮询间隔秒,默认 10)

: "${ORG:?ORG is required, e.g. yoinspiration}"
: "${REPO:?REPO is required, e.g. test-runner}"
: "${GITHUB_TOKEN:?GITHUB_TOKEN is required (with Actions read permission)}"

RUNNER_RESOURCE_ID="${RUNNER_RESOURCE_ID:-default-hardware}"
LOCK_DIR="${RUNNER_LOCK_DIR:-/tmp/github-runner-locks}"
INTERVAL="${INTERVAL:-10}"

api_base="${GITHUB_API_URL:-https://api.github.com}"

echo "[lock-watcher] monitoring ${ORG}/${REPO}, resource=${RUNNER_RESOURCE_ID}, lock_dir=${LOCK_DIR}, interval=${INTERVAL}s"

while true; do
holder_file="${LOCK_DIR}/${RUNNER_RESOURCE_ID}.holder"

if [[ ! -f "${holder_file}" ]]; then
sleep "${INTERVAL}"
continue
fi

# holder 文件格式: PID RUNNER_NAME RUN_ID RUN_ATTEMPT
holder_pid=""
holder_runner=""
holder_run_id=""
holder_run_attempt=""
if ! read -r holder_pid holder_runner holder_run_id holder_run_attempt < "${holder_file}"; then
echo "[lock-watcher] failed to read holder file ${holder_file}" >&2
sleep "${INTERVAL}"
continue
fi

if [[ -z "${holder_run_id:-}" || "${holder_run_id}" == "unknown" ]]; then
sleep "${INTERVAL}"
continue
fi

run_url="${api_base}/repos/${ORG}/${REPO}/actions/runs/${holder_run_id}"
resp="$(curl -fsSL \
-H "Authorization: Bearer ${GITHUB_TOKEN}" \
-H "Accept: application/vnd.github+json" \
"${run_url}" || true)"

if [[ -z "${resp}" ]]; then
echo "[lock-watcher] empty response for run_id=${holder_run_id}, skip" >&2
sleep "${INTERVAL}"
continue
fi

# 优先使用 jq 解析;若 jq 不可用,则退化为空字符串(不报错)
if command -v jq >/dev/null 2>&1; then
status="$(printf '%s\n' "${resp}" | jq -r '.status // empty' 2>/dev/null || true)"
conclusion="$(printf '%s\n' "${resp}" | jq -r '.conclusion // empty' 2>/dev/null || true)"
else
status=""
conclusion=""
fi

echo "[lock-watcher] run_id=${holder_run_id} status=${status:-<none>} conclusion=${conclusion:-<none>} pid=${holder_pid} holder_runner=${holder_runner}"

# 如果 workflow 已取消,认为这个锁可以强制释放
if [[ "${status}" == "completed" && "${conclusion}" == "cancelled" ]]; then
echo "[lock-watcher] detected cancelled workflow, force releasing lock for ${RUNNER_RESOURCE_ID}" >&2

# 尝试在宿主机上杀掉同名 PID(注意:容器内/宿主机 PID 命名空间不同,可能杀不到,仅 best-effort)
if [[ -n "${holder_pid}" && "${holder_pid}" =~ ^[0-9]+$ ]]; then
kill "${holder_pid}" 2>/dev/null || true
fi

# 清理 holder 和对应的 release 标记,让后续等待不再被旧锁阻塞
rm -f "${holder_file}" 2>/dev/null || true
rm -f "${LOCK_DIR}/${RUNNER_RESOURCE_ID}."*.release 2>/dev/null || true
fi

sleep "${INTERVAL}"
done