"""Regression tests for timed-out tasks retaining module-fixture session locks."""
import os
import subprocess
import sys
from pathlib import Path
from types import SimpleNamespace
import pytest
from e2e.timeout_diagnostics import cancel_timed_out_test
@pytest.mark.parametrize(
"cancel_test,withhold_all,teardown_error",
[(False, False, True), (True, False, False), (True, True, True)],
)
def test_module_cleanup_after_timeout(tmp_path, cancel_test, withhold_all, teardown_error):
(tmp_path / "conftest.py").write_text(
"""
import asyncio
import os
import signal
from types import SimpleNamespace
import pytest
import pytest_asyncio
import pytest_timeout
from copilot import CopilotClient, RuntimeConnection
from copilot._jsonrpc import JsonRpcClient
from copilot.session import CopilotSession
from e2e.timeout_diagnostics import add_timeout_diagnostics, cancel_timed_out_test
from e2e.timeout_diagnostics import pytest_timeout_set_timer as pytest_timeout_set_timer
active_item = None
def pytest_runtest_setup(item):
global active_item
active_item = item
@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item, call):
outcome = yield
report = outcome.get_result()
add_timeout_diagnostics(item, call, report)
if os.environ["CANCEL_TEST"] == "1" and report.when == "call" and report.failed:
assert cancel_timed_out_test(call)
@pytest_asyncio.fixture(scope="module", loop_scope="module")
async def ctx():
loop = asyncio.get_running_loop()
rpc = JsonRpcClient(None)
rpc._loop = loop
client = CopilotClient(connection=RuntimeConnection.for_uri("localhost:1234"))
client._client = rpc
client._state = "connected"
session = CopilotSession("resumed-session", rpc)
client._sessions[session.session_id] = session
state = {"armed": False, "calls": 0}
background = asyncio.create_task(asyncio.Event().wait())
async def send(message):
assert message["method"] == "session.detach"
state["calls"] += 1
if state["calls"] > 1 and os.environ["WITHHOLD_ALL"] == "0":
rpc._handle_message({"id": message["id"], "result": {"success": True}})
else:
state["armed"] = True
rpc._send_message = send
if not hasattr(signal, "SIGALRM"):
# Exercise the actual plugin's exception at the runner boundary on
# Windows, where the real thread timeout would terminate the process.
run_once = loop._run_once
def interrupt_blocked_loop():
if state["armed"] and not loop._ready:
state["armed"] = False
active_item.config.hook.pytest_timeout_cancel_timer(item=active_item)
pytest_timeout.timeout_sigalrm(
active_item, pytest_timeout._get_item_settings(active_item)
)
run_once()
loop._run_once = interrupt_blocked_loop
yield SimpleNamespace(_client=client, session=session, state=state, background=background)
try:
state["armed"] = True
await client.stop()
assert state["calls"] == 2
assert session._destroyed
assert not session._disconnect_lock.locked()
assert not rpc.pending_requests
finally:
background.cancel()
await asyncio.gather(background, return_exceptions=True)
""",
encoding="utf-8",
)
(tmp_path / "test_stalled.py").write_text(
"""
import signal
import pytest
pytestmark = [
pytest.mark.asyncio(loop_scope="module"),
pytest.mark.timeout(
1 if hasattr(signal, "SIGALRM") else 20,
method="signal" if hasattr(signal, "SIGALRM") else "thread",
),
]
async def test_first_detach_times_out(ctx):
await ctx.session.disconnect()
async def test_later_test_passes(ctx):
assert ctx.state["calls"] == 1
assert not ctx.background.done()
""",
encoding="utf-8",
)
env = dict(os.environ)
env["CANCEL_TEST"] = str(int(cancel_test))
env["WITHHOLD_ALL"] = str(int(withhold_all))
env["PYTHONPATH"] = str(Path(__file__).parent.resolve())
result = subprocess.run(
[
sys.executable,
"-m",
"pytest",
"-v",
"-s",
"-n",
"0",
"--rootdir",
str(tmp_path),
"--basetemp",
str(tmp_path / "workers"),
str(tmp_path / "test_stalled.py"),
],
cwd=tmp_path,
env=env,
capture_output=True,
text=True,
timeout=30,
check=False,
)
output = result.stdout + result.stderr
assert result.returncode == 1, output
assert "1 failed, 1 passed" in output
assert ("1 error" in output) == teardown_error
assert "disconnect_locked=True" in output
assert "outbound method=session.detach" in output
assert "from pytest-timeout" in output
@pytest.mark.parametrize("when", ["setup", "call", "teardown"])
@pytest.mark.parametrize("error", [None, AssertionError("ordinary failure")])
def test_cleanup_ignores_other_failures(when, error):
call = SimpleNamespace(
when=when, excinfo=None if error is None else SimpleNamespace(value=error)
)
assert not cancel_timed_out_test(call)