Skip to content

Fix pre-existing CI failures on macOS Intel and model download tests - #58

Merged
Josef-Haupt merged 3 commits into
mainfrom
fix-ci-pre-existing-failures
Jul 20, 2026
Merged

Fix pre-existing CI failures on macOS Intel and model download tests#58
Josef-Haupt merged 3 commits into
mainfrom
fix-ci-pre-existing-failures

Conversation

@Josef-Haupt

Copy link
Copy Markdown
Member

Fixes three pre-existing failures on main. None of them originate from #54, but they are what is turning that PR's CI red.

The load_model "download flakes" are the 300s timeout

They are not interrupted downloads, so retries would not have helped. The v3.0 models are ~520 MiB and Zenodo serves them at ~2 MiB/s, so a single download needs ~5-6 min against a global timeout = 300. The tqdm progress bars in run 29565314026 show them being killed mid-download at full speed:

Job Progress when killed
macos-15, 3.11 530M/542M (98%, ~5s from finishing)
macos-15, 3.13 504M/542M (93%)
ubuntu-24.04-arm, 3.12 455M/542M (84%)

This also explains why only the v3.0 models (~516 MiB) ever fail while the 13-73 MiB v2.4/geo models never do. The Read on closed or unwrapped SSL socket in the tracebacks is just pytest-timeout interrupting the read, not the cause.

load_model tests now get 1800s via a pytest_collection_modifyitems hook. Every other test keeps the 300s guard, so real hangs are still caught.

Two real failures that were hidden behind it

Because tox stops at the first failing command, a timeout in the load_model run meant the rest of the suite never executed. The macos-15-intel 3.12 job on main did get past it, and shows both of these already present.

Float32 time tolerance. Not an x86-vs-ARM issue: it is numpy 1.26 vs 2.x promotion. start_time/end_time are float32, and on numpy 1.x the intermediate products stay float32 too, while numpy 2 widens them to float64 via NEP 50. Speeds that are not exactly representable therefore drift ~1 ULP. The tests asserted assert_allclose's default rtol=1e-7, which is below float32 eps (~1.19e-7), so the assertion was never sound on float32 data. macOS Intel is the only runner pinned to numpy 1.26 (via tensorflow <2.17). Now rtol=1e-6; start_time was at 7.5e-8 and one perturbation from flaking, so both are loosened.

v3.0 torch backend on macOS Intel. pt = ["torch >= 2.0.0"] has no upper pin, so macOS Intel resolves torch 2.2.2 (the last x86 macOS wheel), which is too old for the v3.0 TorchScript model. torch.jit.load runs inside the inference worker rather than at model-load time, which is why test_v3_0_pt passes while every v3.0 pt inference test fails with RuntimeError: Analysis was cancelled. The onnx backend works on the same machines. Skipped via a new ensure_v3_0_torch_backend_or_skip(), matching the existing tensorflow <2.17 pin pattern for that platform.

Not addressed here

test_tflite_fp32_twice_two_sessions_parallel_processes_fork hangs nondeterministically (it timed out on ubuntu-24.04 / 3.12, passed on 3.11 and 3.13). It is the documented fork-in-a-multi-threaded-process hazard, matching the QueueFeederThread stacks stuck in waiter.acquire() - the same root cause as the existing ensure_not_mac_or_skip() # reason unknown line.

Skipping it on Linux would make it dead code: use_fork_or_skip() only proceeds on Linux/macOS and macOS is already skipped. That is a delete-or-redesign decision, left for a follow-up. Note it will likely surface more often now that the suite actually runs to completion.

Verification

  • pytest-timeout resolves 1800s for all 5 load_model tests and 300s for everything else, checked through the plugin's own API.
  • The float32 drift was reproduced locally at 1.0596381286823678e-07, matching the CI log's 1.05963813e-07; it fails at rtol=1e-7 and passes at 1e-6.
  • The skip helper gates on Darwin/x86_64 only (arm64 macOS, Linux and Windows still run).
  • 188 tests pass locally, including the v3.0 pt tests, confirming the skip does not over-apply.
  • No new ruff or mypy findings; the pre-existing ones are identical on main.

🤖 Generated with Claude Code

The load_model tests were not failing because of interrupted downloads but
because of the global 300s pytest timeout: the v3.0 models are ~520 MiB and
Zenodo serves them at ~2 MiB/s, so they need 5-6 min. The progress bars in CI
show them being killed at 84-98% while still downloading at full speed. Give
load_model tests 1800s and leave the 300s guard in place everywhere else.

Because tox stops at the first failing command, these timeouts also hid the
rest of the suite, which masked two real failures on macOS Intel:

- start_time/end_time are float32, but on numpy 1.x the intermediate products
  stay float32 as well (numpy 2 widens them to float64 via NEP 50), so speeds
  that are not exactly representable drift by ~1 ULP. The tests asserted the
  assert_allclose default rtol of 1e-7, which is below float32 eps (~1.19e-7).
  macOS Intel is the only runner pinned to numpy 1.26 (via tensorflow <2.17).

- torch 2.2.2 is the last release with x86 macOS wheels and is too old for the
  v3.0 TorchScript model. The model loads fine, but the inference workers die
  and the session is cancelled, so skip the v3.0 torch tests there.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 17, 2026 09:30

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR addresses pre-existing CI instability by preventing large model downloads from being killed by the global pytest timeout, stabilizing float32 time assertions across NumPy versions, and skipping v3.0 Torch backend inference tests on macOS Intel where the available Torch wheel is too old.

Changes:

  • Add a pytest_collection_modifyitems hook to apply a longer timeout to tests marked load_model.
  • Loosen assert_allclose tolerance for float32-based time calculations in encoding structured-array tests.
  • Introduce and apply an Intel-macOS skip helper for v3.0 Torch backend inference tests.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated no comments.

Show a summary per file
File Description
src/birdnet_tests/helper.py Adds ensure_v3_0_torch_backend_or_skip() to skip v3.0 Torch inference on Intel macOS.
src/birdnet_tests/conftest.py Applies a per-test timeout override (1800s) for tests marked load_model.
src/birdnet_tests/acoustic_models/v3_0/model_py/test_predict/test_acoustic_predict_model_v3_0.py Uses the new skip helper for v3.0 Torch backend predict-session tests.
src/birdnet_tests/acoustic_models/v3_0/model_py/test_encode/test_acoustic_encode_model_v3_0.py Uses the new skip helper for v3.0 Torch backend encode-session tests.
src/birdnet_tests/acoustic_models/inference/encoding/encoding_result_py/test_encoding_to_structured_array.py Adjusts float32 time comparisons to tolerate 1-ULP drift across NumPy promotion behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Some tests deadlock in a C-level multiprocessing wait. pytest-timeout's default
signal method cannot interrupt a thread blocked in a C call, so those hangs ran
until the 360-minute job limit killed them - burning ~24 runner-hours per hang
and producing no diagnostic. Switch to the thread method, which kills the process
and dumps stacks, turning such hangs into a fast failure with a traceback. Windows
already used this method (it has no SIGALRM) and never exhibited the hang.

Also drop timeout-minutes from 360 to 60. The slowest passing job is ~30 min, so
this is only a backstop against a wedged run; pytest-timeout bounds individual
tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Josef-Haupt

Copy link
Copy Markdown
Member Author

Follow-up: the "never-finishing" jobs are a separate deadlock, now bounded

The first run of this PR fixed macOS Intel and the download timeouts (both macos-15-intel jobs pass, no download failures), but surfaced two things it did not cause:

  1. Two fork-test failures (test_acoustic_session_v2_4.py, perch_v2) - caught by pytest-timeout at 300s, so the job fails cleanly.
  2. Jobs that hang for the full 6h job limit. These are pre-existing and independent of this PR: PR Apply softmax option #54, which has none of these changes, had two jobs hanging for ~6h at the same time.

Root cause of the hangs

From main's cancelled ubuntu-arm 3.13 job (cancelled jobs publish logs; hanging ones do not): it reached 98%, then two xdist workers wedged - test_v3_0_encode_pt_and_onnx_are_close_with_custom_segment_size[5.0] and test_custom_from_analyzer_v2_4_append_hidden_tf_fp32 - and the job burned the full timeout-minutes: 360 before being killed, leaving orphan python processes. pytest-timeout never fired once, despite being active at method: signal, 300s.

The signal method runs its handler between Python bytecodes, so it cannot interrupt a thread blocked in a C-level multiprocessing wait - which is exactly where a deadlocked fork child sits. Windows has no SIGALRM and therefore already uses method: thread; its jobs never hang.

This commit

  • timeout_method = "thread" in pyproject.toml. The thread method calls os._exit() and dumps stacks, so a wedged test dies in seconds with a traceback instead of hanging. Verified locally: a C-level deadlock under --timeout=5 --timeout-method=thread is killed in 5.8s with a stack dump. This makes every platform behave like Windows, which already passes this suite with this method.
  • timeout-minutes: 60 (was 360). The slowest passing job is ~30 min; this is only a backstop against a wedged run, since pytest-timeout now bounds individual tests.

This turns the 6-hour silent hangs into fast, diagnosable failures. The underlying deadlock itself (the library's process manager inherits a global start method that a prior use_fork_or_skip() test permanently set via set_start_method(..., force=True), and it never pins its own context) is a source-level fix left for a follow-up - this change makes it observable first.

A push to a PR did not cancel the previous run, so a wedged run kept burning
runner hours until manually stopped. Add a concurrency group that cancels the
in-progress run for the same PR ref; pushes to main still run to completion.

The fork/TensorFlow deadlock hangs in native code while holding the GIL, which
pytest-timeout cannot interrupt in either the signal or thread method, so the
job-level limit is the only thing that stops it. Tighten it from 60 to 50 min:
the slowest healthy job is ~32 min, so this keeps headroom while bounding the
cost of a hang.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Josef-Haupt

Copy link
Copy Markdown
Member Author

Run 2 analysis + decision: harden CI, leave library runtime as-is

The thread-method run finished 7 passed, 6 cancelled, 1 failed. Breakdown:

  • 6 "cancelled" = jobs that hit timeout-minutes (GitHub labels a job-cap kill as cancelled, not failure). Each ran the full cap and hung with the same fingerprint as before: ~89%, a wedged worker, orphan python left behind. e.g. ubuntu 3.11 worker gw0 wedged on test_full_pipeline_np and never moved for 48 min.
  • 1 failure (macos-15, 3.13) is unrelated: test_load_litert_and_tf_after_each_other_is_possible failed cleanly in 20s. A macOS litert/TF load-order assertion, pre-existing, separate from the hang. Flagging for a later look.
  • 7 pass, including both macos-15-intel again.

The thread-method change did not stop the hangs (fired 0 times)

Important correction to my earlier claim. My local proof deadlocked a threading.Lock, which releases the GIL while waiting, so the timer thread ran and killed it in 5.8s. The real CI hang is a fork of a TensorFlow-initialized process (Linux default start method is fork through 3.13; the process manager inherits it). The forked child deadlocks inside native code while holding the GIL, so:

  • signal method can't fire (execution never returns to Python between bytecodes), and
  • thread method can't fire either (the timer thread can't acquire the GIL).

pytest-timeout cannot rescue this class of hang in any mode. Windows never hangs because it uses spawn, not fork.

Decision

Per maintainer: harden CI only, leave the library runtime unchanged, treat the deadlock as known-flaky. The root fix (pinning the process manager to a spawn/forkserver context instead of inheriting the global default) is a runtime behavior change and is deferred.

This commit:

  • concurrency group with cancel-in-progress for PRs - a new push now cancels the superseded run instead of letting it hang for hours (which is what happened with the first run and required a manual cancel).
  • timeout-minutes: 50 (from 60) - the job cap is the only thing that can stop this hang; slowest healthy job is ~32 min, so this keeps headroom while bounding the waste.

timeout_method = "thread" is kept: it's harmless, is what Windows already uses, and does catch GIL-releasing hangs and slow tests even though it can't catch this native-lock one.

@Josef-Haupt
Josef-Haupt merged commit 7579515 into main Jul 20, 2026
8 of 14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants