diff --git a/.github/workflows/windows-probe.yml b/.github/workflows/windows-probe.yml new file mode 100644 index 000000000..3e0ab7825 --- /dev/null +++ b/.github/workflows/windows-probe.yml @@ -0,0 +1,63 @@ +# Probe only, for the fork: never part of an upstream PR. +name: Windows probe + +on: + push: + branches: + - 'probe/**' + workflow_dispatch: + +jobs: + probe: + name: session loop ${{ matrix.mode }} py${{ matrix.python-version }} + runs-on: windows-latest + timeout-minutes: 25 + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.12", "3.13", "3.14", "3.15"] + mode: [xdist] + include: + - python-version: "3.14" + mode: serial + - python-version: "3.15" + mode: serial + steps: + - name: Checkout Source Files + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup Environment + uses: ./.github/actions/setup + with: + python-version: ${{ matrix.python-version }} + + - name: Show TCP port settings + shell: bash + run: | + netsh int ipv4 show dynamicport tcp + nproc || true + + - name: Run tests and sample TIME_WAIT + shell: bash + run: | + set +e + (while true; do + echo "$(date +%T) TIME_WAIT=$(netstat -an -p tcp | grep -c TIME_WAIT)" + sleep 5 + done) > timewait.log 2>&1 & + sampler=$! + start=$(date +%s) + if [ "${{ matrix.mode }}" = serial ]; then + uv run pytest -n0 -p no:sugar -q --cov kasa \ + --timeout 90 --timeout-method thread + else + uv run pytest -n auto -p no:sugar -q --cov kasa \ + --timeout 90 --timeout-method thread + fi + rc=$? + echo "pytest exit=$rc after $(( $(date +%s) - start ))s" + kill $sampler + echo "--- TIME_WAIT samples (every 5 s)" + cat timewait.log + echo "--- peak TIME_WAIT: $(sed 's/.*TIME_WAIT=//' timewait.log | sort -n | tail -1)" + exit $rc diff --git a/pyproject.toml b/pyproject.toml index 65ca5e12b..f048c8a1a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -114,7 +114,10 @@ markers = [ "requires_dummy: test requires dummy data to pass, skipped on real devices", ] asyncio_mode = "auto" -asyncio_default_fixture_loop_scope = "function" +# One event loop for the run, not one per test: on Windows each loop opens a +# loopback TCP connection, and thousands of them use up the dynamic ports. +asyncio_default_fixture_loop_scope = "session" +asyncio_default_test_loop_scope = "session" #timeout = 10 # dist=loadgroup enables grouping of tests into single worker. # required as caplog doesn't play nicely with multiple workers. diff --git a/tests/conftest.py b/tests/conftest.py index 9fac81c20..7a51b49e9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,8 +3,6 @@ import asyncio import functools import os -import socket -import struct import sys import warnings from pathlib import Path @@ -166,35 +164,6 @@ def pytest_collection_modifyitems( item.add_marker(pytest.mark.enable_socket) -@pytest.fixture(autouse=True, scope="session") -def _socketpair_without_time_wait(): - """Stop event loop self-pipes from using up loopback ports on Windows. - - Windows has no native socketpair, so the self-pipe of every event loop is a - loopback TCP connection, and closing it leaves a port in TIME_WAIT for about - two minutes. Tests get a new event loop each, so a fast run uses up the - dynamic port range, and the next loop then fails to start or hangs. - Closing the pair with SO_LINGER set to zero releases the port immediately. - """ - if sys.platform != "win32": - yield - return - - orig_socketpair = socket.socketpair - - def _socketpair(*args, **kwargs): - pair = orig_socketpair(*args, **kwargs) - for sock in pair: - # struct linger on Windows is two unsigned shorts: l_onoff, l_linger - sock.setsockopt( - socket.SOL_SOCKET, socket.SO_LINGER, struct.pack("HH", 1, 0) - ) - return pair - - with patch("socket.socketpair", _socketpair): - yield - - @pytest.fixture(autouse=True, scope="session") def asyncio_sleep_fixture(request: pytest.FixtureRequest): # noqa: PT004 """Patch sleep to prevent tests actually waiting."""