From 5146333785560824c097b3db4174aa00be128a1a Mon Sep 17 00:00:00 2001 From: hellices Date: Fri, 23 Oct 2026 18:04:09 +0900 Subject: [PATCH 1/2] ci: run the test suite on four xdist workers Every CI test job ran the whole suite in one process. On Python 3.12 the coverage job took 33-50 minutes, past its old 45-minute limit (#454) and within ten minutes of the new 60 on korvid-runners. pytest-xdist was already a dev dependency that nothing ran (#468). The Linux test matrix and the Windows full-suite step now pass -n 4. Hosted runners for public repositories have four vCPUs, and a fixed count keeps a korvid-runners pod, which requests one CPU on a shared eight-vCPU node, from claiming every core. pytest-cov combines the workers' data, so the 80% gate is unchanged. Locally the exact CI command took 8m27s against 27m46s in one process, with the same 92.92% coverage. xdist needs every worker to collect the same IDs. test_pulse.py derived two from gzip.compress() output, whose header carries the time it was written, so collections a second apart disagreed and xdist aborted. Those cases are now named. test_ci_workflow.py and test_platforms.py pin the new commands, and the timeout comment now describes the old single-process duration as history. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 8 +++++--- docs/dev/quality-gates.md | 2 +- tests/k8s/test_pulse.py | 3 +++ tests/test_ci_workflow.py | 23 ++++++++++++++++++++++- tests/test_platforms.py | 4 ++-- 5 files changed, 33 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8a969e540..0c8148daa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -138,7 +138,9 @@ jobs: test: needs: changes runs-on: ${{ needs.changes.outputs.runner == 'korvid-runners' && 'korvid-runners' || 'ubuntu-latest' }} - # The full suite under coverage takes 39-46 minutes on Python 3.12 (#454). + # In one process the suite under coverage took 39-46 minutes on Python + # 3.12 (#454); it now runs on four xdist workers (#468), and the limit + # stays as headroom for a loaded runner. timeout-minutes: 60 strategy: matrix: @@ -159,7 +161,7 @@ jobs: - run: uvx deptry src/ # A docs-only change still gets a full suite run, on one version. - if: needs.changes.outputs.code == 'true' || matrix.python-version == '3.12' - run: uv run pytest --cov --cov-fail-under=80 + run: uv run pytest -n 4 --cov --cov-fail-under=80 windows-test: needs: changes @@ -199,7 +201,7 @@ jobs: # The docs landing harness ran above in a fresh process; do not execute # it a second time in the long-lived Windows pytest process. - if: needs.changes.outputs.code == 'true' - run: uv run pytest -q --ignore=tests/windows/test_native_terminal.py --ignore=tests/test_docs_landing_behavior.py --deselect=tests/core/test_audit.py::test_concurrent_appends_across_instances --randomly-seed=${{ github.run_id }} + run: uv run pytest -n 4 -q --ignore=tests/windows/test_native_terminal.py --ignore=tests/test_docs_landing_behavior.py --deselect=tests/core/test_audit.py::test_concurrent_appends_across_instances --randomly-seed=${{ github.run_id }} pre-commit: needs: changes diff --git a/docs/dev/quality-gates.md b/docs/dev/quality-gates.md index be65968fd..9a3e7c03b 100644 --- a/docs/dev/quality-gates.md +++ b/docs/dev/quality-gates.md @@ -137,7 +137,7 @@ gate wastes more time than it saves. | job | what it establishes | |---|---| | `changes` | classifies the diff so a docs-only change skips redundant test legs | -| `test` (3.11, 3.12, 3.13) | ruff, `ruff format --check`, mypy, `tach`, `deptry`, `pytest --cov --cov-fail-under=80` | +| `test` (3.11, 3.12, 3.13) | ruff, `ruff format --check`, mypy, `tach`, `deptry`, `pytest -n 4 --cov --cov-fail-under=80` (four xdist workers; coverage is combined across them) | | `windows-test` (3.12) | OS-specific behaviour — path handling, terminal, keyring | | `pre-commit` | the layer-1 hooks over every file | | `security` | `pip-audit` on the locked runtime resolution **including every extra**, `zizmor` on the workflows | diff --git a/tests/k8s/test_pulse.py b/tests/k8s/test_pulse.py index 7cd6d7343..d3e1f1af2 100644 --- a/tests/k8s/test_pulse.py +++ b/tests/k8s/test_pulse.py @@ -247,6 +247,9 @@ async def refresh( ("gzip", gzip.compress(b'{"items": []}') + b"trailing"), ("br", b"unsupported"), ], + # Named, not derived from the payload: a gzip header carries the time it + # was written, and xdist aborts when its workers collect different IDs. + ids=["gzip-not-compressed", "gzip-truncated", "gzip-trailing-bytes", "br-unsupported"], ) async def test_invalid_compression_fails_closed_and_closes_response( pulse: ModuleType, encoding: str, payload: bytes diff --git a/tests/test_ci_workflow.py b/tests/test_ci_workflow.py index 1e05ee83e..351e0f1a5 100644 --- a/tests/test_ci_workflow.py +++ b/tests/test_ci_workflow.py @@ -72,7 +72,9 @@ WINDOWS_SEED = "${{ github.run_id }}" CI_JOB_TIMEOUTS = { "changes": 10, - # The full suite under coverage takes 39-46 minutes on Python 3.12 (#454). + # In one process the suite under coverage took 39-46 minutes on Python + # 3.12 (#454); it now runs on four xdist workers (#468), and the limit + # stays as headroom for a loaded runner. "test": 60, "windows-test": 45, "pre-commit": 20, @@ -431,6 +433,25 @@ def test_windows_pytest_processes_print_and_share_one_deterministic_seed() -> No assert sum(run.count("--randomly-seed=") for run in runs) == 2 +def test_the_full_suite_runs_on_four_xdist_workers() -> None: + """In one process the suite took the 3.12 coverage job past 45 minutes + (#454); four workers run it, with the same coverage, in under a third of + the time (#468). A fixed count rather than `auto`: a korvid-runners pod + requests one CPU on a shared eight-vCPU node.""" + jobs = _jobs(CI_WORKFLOW) + linux_suite = [run for run in _run_steps(jobs["test"]) if run.startswith("uv run pytest")] + windows_suite = [ + run + for run in _run_steps(jobs["windows-test"]) + if run.startswith("uv run pytest") + and "--ignore=tests/windows/test_native_terminal.py" in run + ] + + assert linux_suite == ["uv run pytest -n 4 --cov --cov-fail-under=80"] + assert len(windows_suite) == 1 + assert windows_suite[0].startswith("uv run pytest -n 4 -q ") + + def test_experimental_ty_job_is_honestly_advisory() -> None: job = _jobs(CI_WORKFLOW)["ty-experimental"] runs = _run_steps(job) diff --git a/tests/test_platforms.py b/tests/test_platforms.py index e62b6659d..d49675e30 100644 --- a/tests/test_platforms.py +++ b/tests/test_platforms.py @@ -233,7 +233,7 @@ def test_ci_workflow_defines_the_required_windows_test_job() -> None: assert "uv sync --locked --dev --all-extras" in runs audit_regression = "tests/core/test_audit.py::test_concurrent_appends_across_instances" full_suite = ( - "uv run pytest -q --ignore=tests/windows/test_native_terminal.py" + "uv run pytest -n 4 -q --ignore=tests/windows/test_native_terminal.py" " --ignore=tests/test_docs_landing_behavior.py" f" --deselect={audit_regression}" " --randomly-seed=${{ github.run_id }}" @@ -268,7 +268,7 @@ def test_ci_windows_docs_harness_runs_once_in_fresh_process() -> None: f"uv run pytest -p no:tach tests/windows/test_native_terminal.py {audit_regression} -q" ) full_suite = ( - "uv run pytest -q --ignore=tests/windows/test_native_terminal.py" + "uv run pytest -n 4 -q --ignore=tests/windows/test_native_terminal.py" " --ignore=tests/test_docs_landing_behavior.py" f" --deselect={audit_regression}" " --randomly-seed=${{ github.run_id }}" From a19a7f5125a043ce1c6f52c1aee411ab052b517f Mon Sep 17 00:00:00 2001 From: hellices Date: Fri, 23 Oct 2026 18:40:44 +0900 Subject: [PATCH 2/2] test: count every xdist worker option in the Windows pin The Windows pin only checked that the command started with `uv run pytest -n 4 -q`, so a command that later passed `-n 1` would still pass it while xdist ran one worker, because the last `-n` wins. _xdist_worker_counts now splits the command with shlex and returns every worker count in each spelling pytest accepts (-n 4, -n4, --numprocesses 4, --numprocesses=4), and the pin requires exactly ["4"]. Six cases pin the helper. Appending `-n 1` to the Windows command in ci.yml passed the old pin and fails the new one. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/test_ci_workflow.py | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/test_ci_workflow.py b/tests/test_ci_workflow.py index 351e0f1a5..c70c3709d 100644 --- a/tests/test_ci_workflow.py +++ b/tests/test_ci_workflow.py @@ -4,6 +4,7 @@ import json import os +import shlex import shutil import subprocess from collections.abc import Mapping @@ -450,6 +451,41 @@ def test_the_full_suite_runs_on_four_xdist_workers() -> None: assert linux_suite == ["uv run pytest -n 4 --cov --cov-fail-under=80"] assert len(windows_suite) == 1 assert windows_suite[0].startswith("uv run pytest -n 4 -q ") + assert _xdist_worker_counts(windows_suite[0]) == ["4"] + + +def _xdist_worker_counts(run: str) -> list[str]: + """Every worker count `run` passes to pytest-xdist, in order. + + xdist takes the last of repeated `-n` options, so a pin has to see all of + them, in each spelling pytest accepts. + """ + tokens = shlex.split(run) + counts: list[str] = [] + for index, token in enumerate(tokens): + if token in ("-n", "--numprocesses"): + counts.append(tokens[index + 1] if index + 1 < len(tokens) else "") + elif token.startswith("--numprocesses="): + counts.append(token.partition("=")[2]) + elif token.startswith("-n") and not token.startswith("--"): + counts.append(token[2:]) + return counts + + +@pytest.mark.parametrize( + ("run", "counts"), + [ + ("uv run pytest -n 4 -q", ["4"]), + ("uv run pytest -n 4 -q --ignore=tests/x.py -n 1", ["4", "1"]), + ("uv run pytest -n4 -q", ["4"]), + ("uv run pytest --numprocesses 4", ["4"]), + ("uv run pytest --numprocesses=4 -n 2", ["4", "2"]), + ("uv run pytest -q --no-header", []), + ], + ids=["spaced", "repeated", "attached", "long", "long-then-short", "none"], +) +def test_the_worker_count_check_sees_every_n_option(run: str, counts: list[str]) -> None: + assert _xdist_worker_counts(run) == counts def test_experimental_ty_job_is_honestly_advisory() -> None: