Skip to content

audit: blocking operations with no timeout in CI scripts + workflows (sub-issue of #802) #812

Description

@zackees

Intro

Sub-issue of #802 — CI infrastructure audit slice.

Recently the Linux ARM64 (musl) cross-toolchain fetch from musl.cc hung for 135+ seconds in a release-matrix lane before failing. That single uncaught network fetch is the textbook prototype for what this audit found across the CI surface: nearly every workflow runs with GitHub Actions' default 6-hour per-job timeout, and the network-fetching steps inside them (apt, pip, cargo, curl, rustup, soldr-prepare, zigbuild's Zig + sysroot fetches) almost never set their own caps. A single stuck upstream — PyPI mirror, ESP-IDF/musl.cc artifact CDN, NuGet feed, Apple SDK source — can burn the full 6 h before GHA reaps the job.

The fix style is asymmetric:

  • Workflows — add timeout-minutes: at the job level (single line) and on individual network-heavy steps (5–20 min cap). For curl/wget add --max-time + --connect-timeout. ~95% of the wins live here.
  • Python scripts — add timeout=N kwarg to subprocess.run calls. Lower-impact since most ci/ Python is hook code that only runs locally on dev machines, but the ones that do run in CI (build_dylint_driver.py via the dylint workflow) are easy wins.

Findings — GitHub Workflows (CRITICAL surface)

The single biggest gap: 86 of 89 workflow files have no timeout-minutes: anywhere. Only bench-205.yml (30 min), acceptance-205.yml (45 min), and hw-ci.yml (5 min on one step) cap anything. Every other job, including the autonomous-release matrix, inherits GHA's 360-minute default.

SeverityFile:job/stepOperationSuggested fix
CRITICALrelease-auto.yml (entire workflow — prepare, build, publish, build-pypi, publish-pypi jobs)No timeout-minutes: on any job. Daily run on every main push, plus manual dispatch.Add timeout-minutes: 60 (or 90) at each job level.
CRITICALrelease-auto.yml line 86 — prepare stepcurl -fsSL https://pypi.org/pypi/fbuild/json — no --max-timeAdd --max-time 30 --connect-timeout 10.
CRITICALrelease-auto.yml line 403 — Verify all wheels visible on PyPI stepcurl -fsSL https://pypi.org/pypi/fbuild/${PYPI_VERSION}/json in a 300s deadline loop — inner curl has no --max-time, so one stuck request can blow the whole budget on a single iterationAdd --max-time 10 so the outer deadline loop actually polls.
CRITICALrelease-auto.yml lines 354–355 — Smoke test Linux x86_64 wheel steppython -m pip install --upgrade pip + python -m pip install dist/wheels/*.whl — no timeout, no PyPI mirror fallbackWrap in timeout 300 ... or add --timeout 60 to pip + step-level timeout-minutes: 5.
CRITICALtemplate_native_build.yml line 167 — Install Linux dependencies stepsudo apt-get update && sudo apt-get install -y musl-tools pkg-config — used by every non-Windows release matrix laneAdd step-level timeout-minutes: 5 and apt-get -o Acquire::http::Timeout=30 -o Acquire::Retries=3.
CRITICALtemplate_native_build.yml line 185 — Install xwin system dependenciessudo apt-get update && sudo apt-get install -y --no-install-recommends clang lld llvm zipStep-level timeout-minutes: 10.
CRITICALtemplate_native_build.yml line 210 — Install cross-compilation tools steppip install cargo-zigbuild — Python install that also downloads the Zig toolchain on first use. This is the closest analog to the musl.cc hang.Step-level timeout-minutes: 10; pass --timeout 60 to pip.
CRITICALtemplate_native_build.yml line 223 — Fetch Windows Python lib for PyO3 cross-compile stepcurl -fsSL -o python.zip "https://www.nuget.org/api/v2/package/${PKG}/${PYTHON_VERSION}" — NuGet feed, no --max-time, no retriesAdd --max-time 120 --connect-timeout 15 --retry 3 --retry-delay 5 + step-level timeout-minutes: 5.
CRITICALtemplate_native_build.yml line 242 — Prepare Apple SDK (Linux → mac cross) stepsoldr prepare --target ${{ inputs.target }} — downloads MacOSX.sdk.tar.xz from a remote mirror, no enforced capStep-level timeout-minutes: 15.
CRITICALtemplate_native_build.yml lines 244–277, 291–338 — Build release binaries + Build Python extension stepscargo zigbuild/soldr cargo build — Zig fetches sysroots, ring + cc-rs fetch crates; on the linux_cross aarch64-unknown-linux-musl lane this is where the recent musl.cc hang livedStep-level timeout-minutes: 45 on the build, timeout-minutes: 30 on the python extension step.
HIGHtemplate_build.yml (88 callers — every build-<board>.yml) entire jobNo timeout-minutes, default 6h. Includes apt-get install libudev-dev pkg-config, pip install esptool, soldr cargo build, fbuild build.Job-level timeout-minutes: 30.
HIGHtemplate_build.yml lines 92–93 — Install Linux dependencies stepsudo apt-get update && sudo apt-get install -y libudev-dev pkg-configStep-level timeout-minutes: 5.
HIGHtemplate_build.yml line 97 — Install esptool steppip install esptool — only runs on ESP32 boards, but no timeoutStep-level timeout-minutes: 3; --timeout 60 to pip.
HIGHtemplate_build.yml lines 74–88, 133–143 — actions/cache/restore + actions/cache/saveNo timeout-minutes on cache restore/save steps. The save can stall on GH cache backend.Step-level timeout-minutes: 10 on each.
HIGHcheck-ubuntu.yml, check-macos.yml, check-windows.ymlcheck jobNo timeout-minutes. Runs on every PR.Job-level timeout-minutes: 30.
HIGHdylint.yml entire jobNo timeout-minutes. Runs rustup toolchain install nightly-2026-03-26 --component llvm-tools-preview --component rust-src --component rustc-dev, cargo install cargo-dylint dylint-link --version 5.0.0 --locked, cargo dylint in a 3-attempt retry loop. All network-bound. Runs on every PR.Job-level timeout-minutes: 30.
HIGHdylint.yml lines 44, 46, 52 — install stepsrustup toolchain install ..., cargo install cargo-dylint dylint-link ..., uv run python ci/build_dylint_driver.py (which does git clone https://github.com/trailofbits/dylint + cargo build)Step-level timeout-minutes: 10 on each install step.
HIGHvalidate-boards.yml lines 28, 31–37 — Install PlatformIO, Install PlatformIO platformspip install platformio==..., 7x pio pkg install -g -p ... — each a full platform fetch from PlatformIO's package registry, no capsJob-level timeout-minutes: 30. Step-level timeout-minutes: 15 on the multi-platform install.
HIGHmsrv.yml jobNo timeout-minutes. PR-blocking.Job-level timeout-minutes: 20.
HIGHfmt.yml, docs.yml, crate-gate.yml, lint-subprocess.yml, loc-gate.yml jobsNo timeout-minutes. PR-blocking. Mostly short, but the soldr setup + cargo doc network paths can hang.Job-level timeout-minutes: 15.
HIGHtemplate_native_build.yml line 92 — Setup soldr stepuses: zackees/setup-soldr@v0.9.63 with: cache: true, build-cache: true, target-cache: true — downloads soldr binary + restores cache from GitHub Actions cache backend. No timeout. Cache restore is a known stall point.Step-level timeout-minutes: 10.
MEDIUMupdate-data.yml jobNo timeout-minutes. Daily scheduled. Inner curls already have --max-time 90 (good!) but git fetch, pio pkg install-equivalents via the dump_pio step, soldr setup, and cargo build --release --example dump_usb_ids have no cap.Job-level timeout-minutes: 30.
MEDIUMupdate-data.yml line 97 — Fetch + worktree the online-data branch stepgit fetch origin "${ONLINE_BRANCH}:${ONLINE_BRANCH}" — can hang on stuck remoteStep-level timeout-minutes: 5 + set GIT_HTTP_LOW_SPEED_TIME=30 GIT_HTTP_LOW_SPEED_LIMIT=1000 in env.
MEDIUMacceptance-205.yml jobtimeout-minutes: 45 set (good), but the cargo test pulls Teensyduino + arm-gcc + STM32duino which are network-bound. 45 min is OK for the upper bound, but no inner step caps.No-op or step-level cap on the cargo test step (e.g. 30 min).
MEDIUMbuild.yml (workflow_dispatch)No timeout-minutes. Calls template_native_build.yml which inherits.Job-level timeout-minutes: 60.
LOWadd-to-project.yml jobNo timeout-minutes. Issue/PR-triggered, GitHub-API-only.Job-level timeout-minutes: 5.
LOWhw-ci.ymlbringup job (overall)Only the Run bring-up tests step has timeout-minutes: 5. Other steps (cargo run, gh issue create) can hang. Self-hosted only, so blast radius is the runner host, not GHA minutes.Job-level timeout-minutes: 30.

Findings — Python scripts in ci/

Lower priority overall — most are dev-machine scripts, not run in CI. The exceptions are build_dylint_driver.py (run inside the dylint workflow on every PR), lint.py/test.py/hooks/*.py (local hook surface, but still good hygiene).

SeverityFile:lineOperationSuggested fix
HIGHci/build_dylint_driver.py:34subprocess.check_output(["rustup", "run", TOOLCHAIN_CHANNEL, "rustc", "-vV"]) — no timeout. Runs in CI dylint.yml.Add timeout=60.
HIGHci/build_dylint_driver.py:45subprocess.check_output(["rustup", "which", "--toolchain", full_toolchain, "rustc"]) — no timeout.Add timeout=30.
HIGHci/build_dylint_driver.py:26 (called from lines 122, 123, 159)run(args, check=True, ...) — wraps git clone --filter=blob:none https://github.com/trailofbits/dylint, git checkout, rustup run ... cargo build. None get a timeout.Add timeout=600 default in the run() helper (overrideable per call).
MEDIUMci/hooks/check-on-stop.py:54subprocess.run(cmd, capture_output=True, ...) — runs soldr cargo clippy --workspace --all-targets -- -D warnings and soldr cargo test --workspace. No timeout. Local-only (Stop hook) but a cold workspace test can wedge.Add timeout=1800 (30 min).
MEDIUMci/hooks/lint.py:66subprocess.run([sys.executable, lint_script, file_path]) — runs the project's ./lint wrapper which calls soldr cargo clippy. No timeout.Add timeout=600 (10 min).
MEDIUMci/hooks/code-review-on-stop.py:29subprocess.run(["git", ...]) — git status / diff. Local-only, but stuck filesystem can wedge git on Windows.Add timeout=60.
MEDIUMci/hooks/check-on-start.py:27subprocess.run(["git", "status", "--porcelain"]) — same as above.Add timeout=60.
LOWci/lint.py:23subprocess.run(cmd, ...) — runs soldr cargo clippy / soldr rustfmt. Dev-only uv run lint entry point.Add timeout=1800.
LOWci/test.py:47subprocess.run(cmd, ...) — runs soldr cargo test. Dev-only uv run test entry point.Add timeout=3600 (1h).
LOWci/bench_uv_run.py:44, 67, 73subprocess.run(cmd, ...) — runs uv run / uv sync / uv sync --reinstall-package fbuild. Bench tool, dev-only.Add timeout=600.
LOWci/build_dist.py:54run(cmd, check=True) helper used for gh workflow run / gh run view / gh run download. Dev-only script. (wait_for_run correctly uses its own deadline loop with time.sleep(15).)Add timeout=300 default in the run() helper.
LOWci/trampoline.py:48, 63subprocess.run(cmd) / subprocess.run([sys.executable, str(script), ...])uv run fbuild / uv run publish shims. Dev-only entry points; blocking is the expected behavior.Optional: leave as-is, or add timeout=86400 purely as a watchdog.

What was searched

Files covered

  • .github/workflows/*.yml — all 89 workflow files (88 build-*.yml calling template_build.yml, plus release-auto.yml, template_native_build.yml, template_build.yml, check-{ubuntu,macos,windows}.yml, bench-205.yml, acceptance-205.yml, update-data.yml, validate-boards.yml, dylint.yml, crate-gate.yml, loc-gate.yml, lint-subprocess.yml, fmt.yml, docs.yml, msrv.yml, hw-ci.yml, add-to-project.yml, build.yml).
  • ci/**/*.py — top-level + ci/hooks/, plus the build_dist.py, build_dylint_driver.py, check_flash_offsets.py, enrich_extra_flags.py, extract_pio_build_flags.py, board_sources.py, validate_boards.py, bench_uv_run.py, publish.py, trampoline.py, lint.py, test.py, find_direct_subprocess.py, check_workspace_crates.py, measure_baseline_205.py.
  • ci/hooks/*.pycheck-on-start.py, check-on-stop.py, code-review-on-stop.py, lint.py, board_context.py, crate_guard.py, forbidden_commands.py, tool_guard.py, readme_guard.py, _output.py.

Patterns searched

  • subprocess.(run|Popen|check_output|check_call|call) cross-referenced with timeout= (Python).
  • urllib, requests., socket. (Python network).
  • time.sleep + while True: / while : / deadline (busy-wait detection).
  • curl, wget, git clone, git fetch, pip install, cargo install, npm install, rustup ... install (workflow shell).
  • timeout-minutes: to inventory existing caps (only 3 workflows had any: bench-205, acceptance-205, hw-ci).
  • actions/cache / actions/upload-artifact / actions/download-artifact (network-bound action steps).

Out-of-scope notes

  • Tests inside crates (crates/*/tests/) — out of scope for this sub-issue; covered by sibling sub-issue(s) of audit: blocking operations with no timeout (meta) #802 if any auditor was assigned the Rust test surface.
  • soldr / setup-soldr internalszackees/setup-soldr is an external action. Its internal timeouts (cache restore, soldr binary fetch) are not in this repo's surface. Mitigation here is the step-level timeout-minutes: cap on the Setup soldr step itself.
  • fbuild runtime subprocess spawns — covered by the existing find_direct_subprocess.py / ban_raw_subprocess dylint gate (Migrate all subprocess spawns to running-process; eliminate direct std::process / tokio::process usage #141) which is about routing through fbuild-core::subprocess. Timeout semantics on those wrappers are a separate concern from this audit.
  • extract_pio_build_flags.py:217, measure_baseline_205.py, board_sources.py, check_flash_offsets.py, enrich_extra_flags.py — already have explicit timeouts on all network/subprocess calls. No findings here.
  • update-data.yml inline curls — already use --max-time 90 --retry 5 --retry-delay 10 (good!). Only finding is the missing job-level cap and git fetch step.
  • publish.py — pure stdlib wheel-assembly library, no subprocess/network. No findings.

Prioritized fix order (one-line summary)

  1. Add timeout-minutes: to every job in release-auto.yml and template_native_build.yml (the daily-run release matrix — biggest blast radius).
  2. Add --max-time + --connect-timeout to every curl in release-auto.yml (line 86, 403) and template_native_build.yml (line 223).
  3. Wrap pip install, apt-get install, rustup toolchain install, cargo install, soldr prepare with timeout-minutes: step caps (5–15 min depending on payload).
  4. Add timeout-minutes: 30 to template_build.yml (88 PR-triggered callers).
  5. Add timeout-minutes: 15–30 to check-{ubuntu,macos,windows}.yml, dylint.yml, validate-boards.yml, msrv.yml, fmt.yml, docs.yml, crate-gate.yml, lint-subprocess.yml, loc-gate.yml.
  6. Add timeout= to the four subprocess calls in ci/build_dylint_driver.py (only ci/ script that runs in CI without timeouts).
  7. (Optional) Add timeout= to the Stop/PostToolUse hooks for dev-machine hygiene.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Status
    Triage

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions