|
2 | 2 | # pylint: disable=unused-argument |
3 | 3 | # pylint: disable=unused-variable |
4 | 4 |
|
| 5 | + |
5 | 6 | import asyncio |
6 | | -import subprocess |
7 | | -import sys |
8 | | -import time |
9 | | -from collections.abc import Callable, Iterator |
10 | | -from contextlib import contextmanager |
11 | | -from pathlib import Path |
12 | | -from typing import NamedTuple |
| 7 | +from collections.abc import Awaitable, Callable |
| 8 | +from typing import Any |
| 9 | +from unittest.mock import AsyncMock |
13 | 10 |
|
14 | 11 | import pytest |
15 | | -import requests |
16 | | -from fastapi import FastAPI, Query, Request |
| 12 | +from fastapi import Request |
17 | 13 | from servicelib.fastapi.requests_decorators import cancel_on_disconnect |
18 | 14 |
|
19 | | -CURRENT_FILE = Path(sys.argv[0] if __name__ == "__main__" else __file__).resolve() |
20 | | -CURRENT_DIR = CURRENT_FILE.parent |
| 15 | +POLLER_CLEANUP_DELAY_S = 100.0 |
| 16 | + |
21 | 17 |
|
| 18 | +@pytest.fixture |
| 19 | +def long_running_poller_mock( |
| 20 | + monkeypatch: pytest.MonkeyPatch, |
| 21 | +) -> Callable[[asyncio.Event, Request, Any], Awaitable]: |
22 | 22 |
|
23 | | -mock_app = FastAPI(title="Disconnect example") |
| 23 | + async def _mock_disconnect_poller( |
| 24 | + close_event: asyncio.Event, request: Request, result: Any |
| 25 | + ): |
| 26 | + _mock_disconnect_poller.called = True |
| 27 | + while not await request.is_disconnected(): |
| 28 | + await asyncio.sleep(0.01) |
| 29 | + if close_event.is_set(): |
| 30 | + # Simulate a long cleanup procedure |
| 31 | + await asyncio.sleep(POLLER_CLEANUP_DELAY_S) |
| 32 | + break |
| 33 | + return result |
24 | 34 |
|
25 | | -MESSAGE_ON_HANDLER_CANCELLATION = "Request was cancelled!!" |
| 35 | + monkeypatch.setattr( |
| 36 | + "servicelib.fastapi.requests_decorators._disconnect_poller", |
| 37 | + _mock_disconnect_poller, |
| 38 | + ) |
| 39 | + return _mock_disconnect_poller |
26 | 40 |
|
27 | 41 |
|
28 | | -@mock_app.get("/example") |
29 | | -@cancel_on_disconnect |
30 | | -async def example( |
31 | | - request: Request, |
32 | | - wait: float = Query(..., description="Time to wait, in seconds"), |
| 42 | +async def test_decorator_waits_for_poller_cleanup( |
| 43 | + long_running_poller_mock: Callable[[asyncio.Event, Request, Any], Awaitable], |
33 | 44 | ): |
34 | | - try: |
35 | | - print(f"Sleeping for {wait:.2f}") |
36 | | - await asyncio.sleep(wait) |
37 | | - print("Sleep not cancelled") |
38 | | - return f"I waited for {wait:.2f}s and now this is the result" |
39 | | - except asyncio.CancelledError: |
40 | | - print(MESSAGE_ON_HANDLER_CANCELLATION) |
41 | | - raise |
42 | | - |
43 | | - |
44 | | -class ServerInfo(NamedTuple): |
45 | | - url: str |
46 | | - proc: subprocess.Popen |
47 | | - |
48 | | - |
49 | | -@contextmanager |
50 | | -def server_lifetime(port: int) -> Iterator[ServerInfo]: |
51 | | - with subprocess.Popen( |
52 | | - [ |
53 | | - "uvicorn", |
54 | | - f"{CURRENT_FILE.stem}:mock_app", |
55 | | - "--port", |
56 | | - f"{port}", |
57 | | - ], |
58 | | - cwd=f"{CURRENT_DIR}", |
59 | | - stdout=subprocess.PIPE, |
60 | | - stderr=subprocess.PIPE, |
61 | | - ) as proc: |
62 | | - |
63 | | - url = f"http://127.0.0.1:{port}" |
64 | | - print("\nStarted", proc.args) |
65 | | - |
66 | | - # some time to start |
67 | | - time.sleep(2) |
68 | | - |
69 | | - # checks started successfully |
70 | | - assert proc.stdout |
71 | | - assert not proc.poll(), proc.stdout.read().decode("utf-8") |
72 | | - print("server is up and waiting for requests...") |
73 | | - yield ServerInfo(url, proc) |
74 | | - print("server is closing...") |
75 | | - proc.terminate() |
76 | | - print("server terminated") |
77 | | - |
78 | | - |
79 | | -def test_cancel_on_disconnect(get_unused_port: Callable[[], int]): |
80 | | - |
81 | | - with server_lifetime(port=get_unused_port()) as server: |
82 | | - url, proc = server |
83 | | - print("--> testing server") |
84 | | - response = requests.get(f"{server.url}/example?wait=0", timeout=2) |
85 | | - print(response.url, "->", response.text) |
86 | | - response.raise_for_status() |
87 | | - print("<-- server responds") |
88 | | - |
89 | | - print("--> testing server correctly cancels") |
90 | | - with pytest.raises(requests.exceptions.ReadTimeout): |
91 | | - response = requests.get(f"{server.url}/example?wait=2", timeout=0.5) |
92 | | - print("<-- testing server correctly cancels done") |
93 | | - |
94 | | - print("--> testing server again") |
95 | | - # NOTE: the timeout here appears to be sensitive. if it is set <5 the test hangs from time to time |
96 | | - response = requests.get(f"{server.url}/example?wait=1", timeout=5) |
97 | | - print(response.url, "->", response.text) |
98 | | - response.raise_for_status() |
99 | | - print("<-- testing server again done") |
100 | | - |
101 | | - # kill service |
102 | | - server.proc.terminate() |
103 | | - assert server.proc.stdout |
104 | | - server_log = server.proc.stdout.read().decode("utf-8") |
105 | | - print( |
106 | | - f"{server.url=} stdout", |
107 | | - "-" * 10, |
108 | | - "\n", |
109 | | - server_log, |
110 | | - "-" * 30, |
111 | | - ) |
112 | | - # server.url=http://127.0.0.1:44077 stdout ---------- |
113 | | - # Sleeping for 0.00 |
114 | | - # Sleep not cancelled |
115 | | - # INFO: 127.0.0.1:35114 - "GET /example?wait=0 HTTP/1.1" 200 OK |
116 | | - # Sleeping for 2.00 |
117 | | - # Exiting on cancellation |
118 | | - # Sleeping for 1.00 |
119 | | - # Sleep not cancelled |
120 | | - # INFO: 127.0.0.1:35134 - "GET /example?wait=1 HTTP/1.1" 200 OK |
121 | | - |
122 | | - assert MESSAGE_ON_HANDLER_CANCELLATION in server_log |
| 45 | + """ |
| 46 | + Tests that the decorator's wrapper waits for the poller task to finish |
| 47 | + its cleanup, even if the handler finishes first, without needing a full server. |
| 48 | + """ |
| 49 | + long_running_poller_mock.called = False |
| 50 | + handler_was_called = False |
| 51 | + |
| 52 | + @cancel_on_disconnect |
| 53 | + async def my_handler(request: Request): |
| 54 | + nonlocal handler_was_called |
| 55 | + handler_was_called = True |
| 56 | + await asyncio.sleep(0.1) # Simulate quick work |
| 57 | + return "Success" |
| 58 | + |
| 59 | + # Mock a fastapi.Request object |
| 60 | + mock_request = AsyncMock(spec=Request) |
| 61 | + mock_request.is_disconnected.return_value = False |
| 62 | + |
| 63 | + # --- |
| 64 | + tasks_before = asyncio.all_tasks() |
| 65 | + |
| 66 | + # Call the decorated handler |
| 67 | + _ = await my_handler(mock_request) |
| 68 | + |
| 69 | + tasks_after = asyncio.all_tasks() |
| 70 | + # --- |
| 71 | + |
| 72 | + assert handler_was_called |
| 73 | + assert long_running_poller_mock.called == True |
| 74 | + |
| 75 | + # Check that no background tasks were left orphaned |
| 76 | + assert tasks_before == tasks_after |
0 commit comments