diff --git a/docs/board-lock-watcher.md b/docs/board-lock-watcher.md new file mode 100644 index 0000000..fc6bc44 --- /dev/null +++ b/docs/board-lock-watcher.md @@ -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= +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= 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=`?** + **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 总是 ``?** + **A:** 需要先在宿主机安装 `jq`,否则 watcher 无法从响应 JSON 中解析状态。 + diff --git "a/docs/\345\244\232\347\273\204\347\273\207\345\205\261\344\272\253Runner\344\275\277\347\224\250\350\257\264\346\230\216.md" "b/docs/\345\244\232\347\273\204\347\273\207\345\205\261\344\272\253Runner\344\275\277\347\224\250\350\257\264\346\230\216.md" index 3407916..ab314b3 100644 --- "a/docs/\345\244\232\347\273\204\347\273\207\345\205\261\344\272\253Runner\344\275\277\347\224\250\350\257\264\346\230\216.md" +++ "b/docs/\345\244\232\347\273\204\347\273\207\345\205\261\344\272\253Runner\344\275\277\347\224\250\350\257\264\346\230\216.md" @@ -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` 必须一致(指向同一宿主机目录)。 diff --git a/runner-wrapper/lock-watcher.sh b/runner-wrapper/lock-watcher.sh new file mode 100755 index 0000000..ad46059 --- /dev/null +++ b/runner-wrapper/lock-watcher.sh @@ -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:-} conclusion=${conclusion:-} 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 +