Files
daily_stock_analysis/scripts/ci_gate.sh
Nicholas-Xiong 8d8fa56bc7 ci(#2131): 给 backend-gate offline pytest 加 --timeout=120 + faulthandler_timeout=300 watchdog (#2140)
* ci(#2131): 给 backend-gate offline pytest 加 --timeout=120 + faulthandler_timeout=300

issue #2131 报告 backend-gate 在执行 `Offline test suite` 步骤时两次
在同一提交 `e6abcef17fbc5d655c1c49429182079f02d6552e`(PR #2123)
上间歇性无 traceback 卡住:

  workflow run 30551305640(2026-07-30):
    - Offline test suite 开始于 14:23:13 UTC
    - 最后一条测试输出 14:23:58 UTC(AlphaSift hotspot PASSED [11%])
    - job 在 22 分 38 秒无输出后被取消

  workflow run 30553352307(2026-07-30):同样在 11% 位置卡住

GitHub Actions runner 在被取消前没有 pytest 进度的任何信号,也没
有 traceback。本地 16 个 AlphaSift hotspot 测试都能稳过,意味着
问题是 CI-only 的测试执行顺序、进程级全局状态、线程/事件循环
清理或依赖行为 — 没有稳定的 assertion failure 可调试。

issue #2131 已经列出推荐排查方向之一:给 pytest 加单测试超时与
卡住时的线程栈 dump(`pytest-timeout`、`faulthandler_timeout`)。
但当前 `scripts/ci_gate.sh` 的 `offline_test_suite()` 仅运行
`python -m pytest -m "not network"`,没有任何超时或 watchdog。

本 PR 实施 issue #2131 推荐的 watchdog 改造,目标不是修复根因
(那需要 issue #2131 的等线程栈 dump 复现才能定位),而是让任何
未来 CI hang 都会留下可定位的失败信息或 post-mortem 栈,而不是
静默消亡到 GitHub Actions workflow timeout 才被取消。

改动:

1. `.github/requirements-ci.txt`:新增 `pytest-timeout>=2.3.0`
   依赖。CI 的 `setup-python` + `pip install -r` 步骤会自动拉取。

2. `scripts/ci_gate.sh` 的 `offline_test_suite()`:
     python -m pytest -m "not network" \
       --timeout=120 -o timeout_method=thread \
       -o faulthandler_timeout=300

   - `--timeout=120`:单个测试如果执行超过 2 分钟直接 fail,
     生成 pytest-timeout 的 traceback 指出是哪个 case。
   - `-o timeout_method=thread`:pytest-timeout 用 watcher 线程
     而非 signal 方法,对吞了 SIGINT/SIGTERM 的测试更可靠
     (yfinance、AlphaSift 这类的 Threads/eventloop 都
     有可能 swallow signal)。
   - `-o faulthandler_timeout=300`:pytest 内置 faulthandler
     的 watchdog,整体 pytest 5 分钟无任何输出(最末一个测试
     结束到下一个测试开始之间的「沉默期」超过 300 秒)就 dump
     全部 Python 线程栈到 stderr。这是定位卡住位置的关键信号。

3. `docs/CHANGELOG.md`:在 [Unreleased] 段加一条 [修复] entry
   描述本次改动并指明 issue #2131。

本地验证:

  $ pip install pytest-timeout
  $ bash -n scripts/ci_gate.sh && echo syntax ok
  $ python -m pytest -m "not network" --timeout=120 \
      -o timeout_method=thread -o faulthandler_timeout=300 \
      tests/test_stock_list_parser.py tests/test_stock_code_utils.py -q
  ============================== 98 passed in 2.00s ==============================

  $ flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
  0

行为不变项:

- 本地开发 `python -m pytest ...` 不带 `--timeout` 仍可任意长跑调试
  (`setup.cfg` 没在 `[tool:pytest]` 全局加 timeout — 仅 CI 路径加)。
- `scripts/ci_gate.sh` 的 syntax/flake8/deterministic phases 不受影响。
- `pytest-timeout` 只在 `scripts/ci_gate.sh` 的 offline_test_suite 被
  调用时生效,不会污染本地开发流程。

注:本 PR 不修复 issue #2131 的根本 hang 原因(仍需 thread dump 复
现定位),而是把未来的 hang 转成可定位的失败。reviewer 在 issue
#2131 上对类似方向说过「即使再次复现,定位价值很低」—— 但本 PR
的 watchdog 至少把「无信息消亡」转成「带 stacktrace 的 fail」,
当 hang 再次发生时能立刻看到卡在哪个测试的哪一行。

* ci(#2131): close PR #2140 review blocker OR-COM-cc22d635 + OR-COM-c76d8eff — docker-publish.yml 对齐 backend-gate 依赖安装

按 reviewer 在 head `e01a1835` 上的 OpenReview Bot 复核反馈,关闭以下 2 个
高置信度 compatibility blocker:

- OR-COM-cc22d635: Docker Release Publish workflow 的 Install backend gate
  dependencies 仍按旧依赖集合执行 ./scripts/ci_gate.sh,新加的
  pytest-timeout 没覆盖到发布入口
- OR-COM-c76d8eff: 与 OR-COM-cc22d635 同源,cache-dependency-path 也
  缺失 .github/requirements-ci.txt

## 改了什么

.github/workflows/docker-publish.yml:
- setup-python cache-dependency-path 对齐 ci.yml backend-gate:
  加入 requirements.txt + .github/requirements-ci.txt 两个文件
  作为 pip cache key,命中缓存
- Install backend gate dependencies 改用与 ci.yml 完全相同的
  pattern:retry loop (3 attempts, 15s backoff) + 单一
  pip install -r .github/requirements-ci.txt(该文件已 -r 递归拉
  requirements.txt,所以无需重复 pip install -r requirements.txt)
- 加注释说明 issue #2131 引入 pytest-timeout 的关联

docs/CHANGELOG.md:
- [Unreleased] #2131 entry 末尾补一句:同步修正 docker-publish.yml
  的 install 与 cache-dependency-path 对齐

## 为什么这么改

issue #2131 让 scripts/ci_gate.sh 的 offline_test_suite 用
`--timeout=120 -o timeout_method=thread -o faulthandler_timeout=300`,
这要求 pytest-timeout>=2.3.0 插件。

ci.yml 的 backend-gate 已通过 .github/requirements-ci.txt 安装该
插件,但 docker-publish.yml 仍用旧的 `pip install flake8 pytest`,
没有 pytest-timeout,发布前 gate 跑 ./scripts/ci_gate.sh 会直接
报 unrecognized --timeout=120 fail,阻断镜像发布。

reviewer OR-COM-cc22d635 / OR-COM-c76d8eff 都指这是 PR 引入的
compatibility regression(不是已有旧债),需要 PR 同步修。

ci.yml 的 install pattern 是 retry 3 次带 backoff,对齐到
docker-publish 让两个工作流完全统一,未来新加 CI-only 依赖只需改
.github/requirements-ci.txt。

## 验证情况

已本地验证:
- python -c "import yaml; yaml.safe_load(open('.github/workflows/
  docker-publish.yml'))" — YAML 语法 OK
- 对比 ci.yml 的 backend-gate install 步骤,pattern 完全一致

已 CI 验证(待 push 后跑):
- 本 PR 触发 backend-gate / docker-build / ai-governance / Change
  Detection,但 docker-publish.yml 只在 v*.*.* tag 或 workflow_dispatch
  触发,本 PR CI 不会真跑该 workflow。reviewer 复核时会做静态比对

未验证 / 风险点:
- 真实发布流程跑不通:需要 maintainer 推 v*.*.* tag 或手动
  workflow_dispatch 触发,才能验证发布前 gate 真的工作
- 但 install pattern 与 ci.yml 完全对齐,ci.yml 那边过则该边也
  应该过;问题概率很低

## 风险点与回滚

回滚:
1. revert 本 commit
2. docker-publish.yml install step 改回 pip install -r requirements.txt
   + pip install flake8 pytest
3. cache-dependency-path 删除新增两行
4. CHANGELOG.md entry 末尾去掉补充句

潜在风险:
- .github/requirements-ci.txt 递归 -r requirements.txt,release
  runner 之前装过 requirements.txt;retry loop + cache 应该吸收掉
  任何 pip 网络抖动,但首次 release 可能比之前略慢(多一次冗余
  install)。Trade-off 可接受:与 ci.yml 完全统一比省一次冗余
  install 更重要。

* docs(#2131): 收窄 PR #2140 faulthandler_timeout 措辞 + 补 PR 描述 Refs/回滚

OpenReview Bot 在 head 14416440 上已给「可以直接合入」结论,剩 2 个非阻断建议:
1. CHANGELOG 与 PR 描述里 -o faulthandler_timeout=300 写成「整体超过 5 分钟无输出 watchdog」与 pytest 实际语义不一致,应改为「单个测试(含其 teardown)超过 5 分钟时 dump 线程栈」
2. 把 issue 关联补成显式 Refs #2131 + 加最小回滚方案

本 commit 同步两条:
- docs/CHANGELOG.md:把 faulthandler_timeout 描述从「整体超过 5 分钟无输出时 dump 全部线程栈」改为「单个测试(含其 teardown)超过 5 分钟时 dump 全部线程栈」,与 python -m pytest --help 中 faulthandler_timeout 的语义对齐
- PR #2140 描述:在末尾追加 "## 关联 issue Refs #2131" 与 "## 最小回滚方案" 两段(用 gh pr edit --body-file 更新),按 .github/PULL_REQUEST_TEMPLATE.md 模板完整化

无代码逻辑改动,仅文案同步。

---------

Co-authored-by: xxiaoxiong <xxiaoxiong@nicholasxiong.cn>
2026-08-01 22:41:41 +08:00

72 lines
1.9 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
syntax_check() {
echo "==> backend-gate: Python syntax check"
python -m py_compile main.py src/config.py src/auth.py src/analyzer.py src/notification.py
python -m py_compile src/storage.py src/scheduler.py src/search_service.py
python -m py_compile src/market_analyzer.py src/stock_analyzer.py
python -m py_compile data_provider/*.py
}
flake8_checks() {
echo "==> backend-gate: flake8 critical checks"
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
}
deterministic_checks() {
echo "==> backend-gate: local deterministic checks"
./scripts/test.sh code
./scripts/test.sh yfinance
}
offline_test_suite() {
echo "==> backend-gate: offline test suite"
# ``--timeout=120`` hard-fails any single test that runs longer than two
# minutes (issue #2131: backend-gate previously hung indefinitely around
# AlphaSift hotspot cases without leaving any traceback). ``-o
# timeout_method=thread`` makes pytest-timeout use a watcher thread that
# is reliable even when the test has swallowed Ctrl-C / signal handling
# (yfinance, AlphaSift). ``-o faulthandler_timeout=300`` dumps all
# thread + interpreter stacks to stderr after five minutes of total
# test silence, giving us a post-mortem root cause for any future
# CI hang instead of ``backend-gate`` being silently cancelled by the
# workflow timeout.
python -m pytest -m "not network" \
--timeout=120 -o timeout_method=thread \
-o faulthandler_timeout=300
}
run_all() {
syntax_check
flake8_checks
deterministic_checks
offline_test_suite
echo "==> backend-gate: all checks passed"
}
phase="${1:-all}"
case "$phase" in
all)
run_all
;;
syntax)
syntax_check
;;
flake8)
flake8_checks
;;
deterministic)
deterministic_checks
;;
offline-tests)
offline_test_suite
;;
*)
echo "Usage: $0 [all|syntax|flake8|deterministic|offline-tests]" >&2
exit 2
;;
esac