Skip to content

FIX: re-raise what concurrency test workers throw - #786

Merged
d-chambers merged 3 commits into
devfrom
thread-helper-errors
Jul 27, 2026
Merged

FIX: re-raise what concurrency test workers throw#786
d-chambers merged 3 commits into
devfrom
thread-helper-errors

Conversation

@d-chambers

@d-chambers d-chambers commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Description

Fixes a fault in the run_in_threads test helper added by #779, reported by CodeRabbit after that PR merged.

The helper only let a worker's exception print to stderr. join() still succeeded and the worker's slot stayed None, so a test could carry on with None results:

  • Some assertions are satisfied by that. assert len({id(x) for x in results}) == 1 passes just as happily when all four workers raised and every result is None.
  • The ones that do fail report something unhelpful — an AttributeError on None — instead of the error the worker actually hit.

Workers now record what they raise, and the helper re-raises the first failure after joining.

It deliberately does not aggregate into an ExceptionGroup, which codex caught on the first revision: that type is 3.11+ while pyproject.toml declares requires-python = ">=3.10", and it rejects BaseException members — so two workers calling pytest.skip/pytest.fail would have produced a TypeError in place of the outcomes the helper exists to propagate. Later failures are dropped, which is the normal contract for a test helper.

The helper itself is not directly tested — that is more machinery than it warrants. The concurrency tests across io core, namespaces, units, the catalog and the remote cache exercise it, and they are what the fix protects.

Validation

  • Full suite: 7964 passed, 241 skipped, 2 xfailed. Nothing was relying on a swallowed exception.
  • pre-commit run --all: passed.

Changelog

none

Checklist

I have (if applicable):

  • referenced the GitHub issue this PR closes.
  • documented the new feature with docstrings and/or appropriate doc page.
  • included tests. See testing guidelines. The change is to the test infrastructure itself; the existing concurrency tests are what exercise it.
  • added the "ready_for_review" tag once the PR is ready to be reviewed.

run_in_threads only let a worker's exception print to stderr: join()
still succeeded and the worker's slot stayed None, so a test could carry
on with None results. Some assertions catch that by accident (len({id(x)
for x in results}) == 1 is satisfied by four Nones), and the ones that do
fail report an AttributeError on None rather than the real error.

Workers now record what they raise and the helper re-raises it after
joining, grouping them when more than one fails. Adds tests for the
helper itself, since a fault in it weakens every concurrency test
silently.

Reported by CodeRabbit on #779 after that PR merged.
@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Changes

Threaded test helper

Layer / File(s) Summary
Worker error propagation
tests/conftest.py
run_in_threads captures exceptions from worker threads, asserts that all workers finish after joining, and re-raises the first captured error in the calling thread.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title is concise and accurately describes the main change: propagating worker exceptions from the concurrency test helper.
Description check ✅ Passed The description follows the template with a clear problem statement, validation notes, and a checklist, with only the issue-link item left generic.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch thread-helper-errors

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@d-chambers d-chambers added the ready_for_review PR is ready for review label Jul 26, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1b45d94770

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread tests/conftest.py Outdated
if len(errors) == 1:
raise errors[0]
if errors:
raise ExceptionGroup("workers raised", errors)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Use exception aggregation compatible with Python 3.10

When two or more workers fail under Python 3.10, this branch raises NameError because the built-in ExceptionGroup was introduced in Python 3.11, while pyproject.toml still declares support for Python 3.10. The newly added multiple-exception test likewise references the unavailable name, so the test suite cannot validate this helper on a supported Python version; use a compatible aggregation mechanism or explicitly raise the minimum supported version.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Correct, fixed in 875f15d. pyproject.toml declares requires-python = ">=3.10" (line 30) while ExceptionGroup is 3.11+, and the CI matrix only runs 3.11-3.14, so nothing here would have caught it.

Rather than add a compatibility shim I dropped the aggregation: the helper now re-raises the first worker failure. That is 3.10-safe, simpler, and later failures being dropped is the normal contract for a test helper (you fix the first and re-run). See the sibling thread — the BaseException problem pushed the same way.

Comment thread tests/conftest.py Outdated
if len(errors) == 1:
raise errors[0]
if errors:
raise ExceptionGroup("workers raised", errors)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve multiple BaseException-derived worker outcomes

When multiple workers raise values derived directly from BaseException—for example, two workers calling pytest.skip() or pytest.fail()ExceptionGroup rejects them with TypeError: Cannot nest BaseExceptions in an ExceptionGroup. Since the worker deliberately catches BaseException, this replaces the actual worker outcomes instead of propagating them as promised; the aggregation must support the full type being caught.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch, and the sharper of the two. Fixed in 875f15d.

The worker catches BaseException deliberately, so two workers calling pytest.skip or pytest.fail would have hit TypeError: Cannot nest BaseExceptions in an ExceptionGroup — replacing the outcomes the helper exists to propagate, which is worse than the swallowing this PR set out to fix.

BaseExceptionGroup would have satisfied this one, but not the 3.10 floor in the sibling thread, so the helper now just re-raises the first failure: no nesting rules, no version floor. Added test_base_exception_from_worker_propagates to pin it.

@codecov

codecov Bot commented Jul 26, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 99.98%. Comparing base (9b3055f) to head (7304893).
⚠️ Report is 5 commits behind head on dev.

Additional details and impacted files
@@             Coverage Diff             @@
##               dev     #786      +/-   ##
===========================================
- Coverage   100.00%   99.98%   -0.02%     
===========================================
  Files          164      164              
  Lines        17589    17707     +118     
===========================================
+ Hits         17589    17705     +116     
- Misses           0        2       +2     
Flag Coverage Δ
network 48.22% <ø> (-0.10%) ⬇️
unittests 99.98% <ø> (-0.02%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Two problems with aggregating, both raised by codex:

ExceptionGroup is 3.11+, and pyproject declares requires-python >=3.10,
so the aggregating branch would have been a NameError on the oldest
supported interpreter (CI only runs 3.11+, so nothing would have caught
it).

It also rejects BaseException members. The worker deliberately catches
BaseException, so two workers using pytest.skip or pytest.fail would
have produced "TypeError: Cannot nest BaseExceptions in an
ExceptionGroup" in place of the outcomes it promised to propagate.

Re-raising the first failure is 3.10-safe, handles BaseException, and is
simpler. Later failures are dropped, which is the normal contract for a
test helper. Adds a test for the BaseException case.
Testing the test helper is more machinery than the helper warrants; the concurrency tests that use it already exercise it.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/conftest.py`:
- Line 141: Replace the assert-based completion check in the thread cleanup
logic with an explicit is_alive() conditional that raises AssertionError("thread
never finished; possible deadlock") when the worker remains alive, ensuring the
check remains active under Python optimization.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 2084263b-b614-4665-b783-9c4f17100939

📥 Commits

Reviewing files that changed from the base of the PR and between 9b3055f and 7304893.

📒 Files selected for processing (1)
  • tests/conftest.py

Comment thread tests/conftest.py
thread.start()
for thread in threads:
thread.join(timeout)
assert not thread.is_alive(), "thread never finished; possible deadlock"

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.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== repo files matching conftest.py =="
fd -a 'conftest\.py$' . | sed 's#^\./##' || true

echo "== tests/conftest.py around reported line =="
if [ -f tests/conftest.py ]; then
  wc -l tests/conftest.py
  sed -n '110,170p' tests/conftest.py | nl -ba -v110
else
  echo "tests/conftest.py not found"
fi

echo "== assert usage in tests/conftest.py =="
rg -n '\bassert |\bassertion|is_alive|threading|Thread|daemon' tests/conftest.py || true

echo "== Python assert optimization behavior probe =="
python3 - <<'PY'
import tempfile, os, subprocess

for opt in ("", "-O"):
    ok = False
    for stmt in ("if thread.is_alive(): raise AssertionError('alive')", "assert not thread.is_alive(), 'alive'"):
        with tempfile.NamedTemporaryFile("w", suffix=".py", delete=False) as f:
            f.write(stmt + "\n")
            f.flush()
            p = subprocess.run([opt, "python3", f.name], capture_output=True, text=True)
            print({
                "opt_flag": opt,
                "stmt": stmt,
                "rc": p.returncode,
                "stdout": p.stdout.strip(),
                "stderr": p.stderr.strip(),
            })
        os.unlink(f.name)
PY

Repository: DASDAE/dascore

Length of output: 426


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== tests/conftest.py around reported line =="
if [ -f tests/conftest.py ]; then
  awk 'NR>=110 && NR<=170 { printf "%7d\t%s\n", NR, $0 }' tests/conftest.py
else
  echo "tests/conftest.py not found"
fi

echo "== assert usage in tests/conftest.py =="
rg -n '\bassert |\bassertion|is_alive|threading|Thread|daemon' tests/conftest.py || true

echo "== Python assert optimization behavior probe =="
python3 - <<'PY'
import tempfile, os, subprocess

for opt in ("", "-O"):
    for stmt in ("if thread.is_alive(): raise AssertionError('alive')", "assert not thread.is_alive(), 'alive'"):
        with tempfile.NamedTemporaryFile("w", suffix=".py", delete=False) as f:
            f.write(stmt + "\n")
            f.flush()
            p = subprocess.run([opt, "python3", f.name], capture_output=True, text=True)
            print({
                "opt_flag": opt,
                "stmt": stmt,
                "rc": p.returncode,
                "stdout": p.stdout.strip(),
                "stderr": p.stderr.strip(),
            })
        os.unlink(f.name)
PY

Repository: DASDAE/dascore

Length of output: 4055


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import tempfile, os, subprocess, threading, time

def test_assert_case(name, stmt):
    path = tempfile.mktemp()
    code = f"thread = threading.Thread(target=time.sleep, args=(0.01,))\nthread.start()\nthread.join(0)\n{stmt}\nprint('ran')\n"
    with open(path, "w") as f:
        f.write(code)
    try:
        for opt in ("", "-O"):
            cmd = [opt, "python3", path]
            p = subprocess.run(cmd, capture_output=True, timeout=5)
            print(f"{name}: opt={op r or '<empty>'} rc={p.returncode} stdout={p.stdout!r} stderr={p.stderr!r}")
        print(f"{name}: assertion removed with -O = 'ran' not found")
    finally:
        os.unlink(path)

test_assert_case("alive-check", "if thread.is_alive(): raise AssertionError('alive')")
test_assert_case("assert-check", "assert not thread.is_alive(), 'alive'")
PY

Repository: DASDAE/dascore

Length of output: 292


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import tempfile, os, subprocess, threading, time

def run_case(name, stmt):
    path = tempfile.mktemp()
    code = f"thread = threading.Thread(target=time.sleep, args=(0.01,))\nthread.start()\nthread.join(0)\n{stmt}\nprint('ran')\n"
    with open(path, "w") as f:
        f.write(code)
    try:
        for opt in ("", "-O"):
            cmd = [opt + "python3", path] if opt else ["python3", path]
            p = subprocess.run(cmd, capture_output=True, timeout=5)
            opt_repr = "<empty>" if not opt else opt
            print(f"{name}: opt={opt_repr} rc={p.returncode} stdout={p.stdout!r} stderr={p.stderr!r}")
    finally:
        os.unlink(path)

run_case("alive-check", "if thread.is_alive(): raise AssertionError('alive')")
run_case("assert-check", "assert not thread.is_alive(), 'alive'")
PY

Repository: DASDAE/dascore

Length of output: 1020


🌐 Web query:

Python optimize -O removes assert statements documentation

💡 Result:

In Python, assert statements are designed to be used for debugging and testing purposes rather than for mandatory runtime logic [1][2]. When Python is run in optimized mode, the interpreter effectively removes these statements [1]. How to remove assert statements: You can disable assert statements by running the Python interpreter in optimized mode using one of the following methods [1]: 1. Command-line options: - Use the -O flag: This sets the built-in debug constant to False and removes assert statements from the compiled bytecode [1]. - Use the -OO flag: This performs the same optimization as -O, but also discards docstrings from the compiled bytecode [1]. 2. Environment variable: - Set the PYTHONOPTIMIZE environment variable to a non-empty string (e.g., 1 or 2) to achieve the same effect as the -O or -OO flags, respectively [1]. Important Considerations: - Assertions are not safe for production runtime checks because they are silently skipped when optimized mode is enabled [2]. Code required for program logic or security (such as input validation or permission checks) should never be placed within an assert statement [2]. - If you need to ensure code always runs, regardless of optimization settings, use standard conditional if statements instead [2]. - The behavior is linked to the debug constant; when this is False, any code explicitly written within an if debug: block will also be skipped, in addition to assert statements [1].

Citations:


Do not rely on assert for deadlock detection.

With Python optimization turned on, line 141 is removed, so live workers can pass this check and return partial results or keep non-daemon threads alive. Use if thread.is_alive(): raise AssertionError("thread never finished; possible deadlock") instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/conftest.py` at line 141, Replace the assert-based completion check in
the thread cleanup logic with an explicit is_alive() conditional that raises
AssertionError("thread never finished; possible deadlock") when the worker
remains alive, ensuring the check remains active under Python optimization.

@d-chambers
d-chambers merged commit 35f7e0d into dev Jul 27, 2026
27 of 29 checks passed
@d-chambers
d-chambers deleted the thread-helper-errors branch July 27, 2026 14:19
@d-chambers d-chambers removed the ready_for_review PR is ready for review label Aug 11, 2026
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.

1 participant