|
9 | 9 | import sys |
10 | 10 | import time |
11 | 11 | import subprocess |
| 12 | +import ast |
12 | 13 | from pathlib import Path |
13 | 14 | import httpx |
14 | 15 | import pytest |
15 | 16 |
|
16 | | -# Use a dedicated test port separate from the production port (39766) to avoid conflicts. |
| 17 | +# Use a dedicated test port separate from the production port (39765) to avoid conflicts. |
17 | 18 | # See UP-20 / Integration Testing — Port Conflict Resolution in agentchatbus-upstream-improvements.md |
18 | 19 | TEST_PORT = 39769 |
19 | 20 | BASE_URL = f"http://127.0.0.1:{TEST_PORT}" |
20 | 21 | # Use a separate test database |
21 | 22 | TEST_DB_PATH = os.path.join(os.path.dirname(__file__), "data", "bus_test.db") |
22 | 23 | _SERVER_PROCESS = None |
23 | 24 |
|
| 25 | +if TEST_PORT == 39765: |
| 26 | + raise RuntimeError("TEST_PORT must never be the production port 39765.") |
| 27 | + |
24 | 28 | # Hard guardrails: tests must never accidentally use a production DB. |
25 | 29 | # Set defaults early so modules that read config at import time see test values. |
26 | | -os.environ.setdefault("AGENTCHATBUS_PORT", str(TEST_PORT)) |
| 30 | +# Set test port explicitly for this pytest process so imports never see production port. |
| 31 | +os.environ["AGENTCHATBUS_PORT"] = str(TEST_PORT) |
27 | 32 | os.environ.setdefault("AGENTCHATBUS_TEST_BASE_URL", BASE_URL) |
28 | 33 | os.environ.setdefault("AGENTCHATBUS_DB", TEST_DB_PATH) |
29 | 34 |
|
| 35 | + |
| 36 | +def _enforce_no_popen_pipe_in_conftest() -> None: |
| 37 | + """Hard guardrail: never use subprocess.PIPE for the test server. |
| 38 | +
|
| 39 | + Why: |
| 40 | + - If the server is started with stdout/stderr=PIPE and the test runner does |
| 41 | + not continuously drain those pipes, the child process can block once the |
| 42 | + OS pipe buffer fills. |
| 43 | + - That manifests as intermittent hangs (pytest appears stuck) or |
| 44 | + httpx.ReadTimeouts when tests send HTTP requests to the server. |
| 45 | +
|
| 46 | + This check intentionally fails fast if someone reintroduces PIPE in this |
| 47 | + fixture. |
| 48 | + """ |
| 49 | + |
| 50 | + try: |
| 51 | + source = Path(__file__).read_text(encoding="utf-8") |
| 52 | + except Exception: |
| 53 | + return |
| 54 | + |
| 55 | + try: |
| 56 | + tree = ast.parse(source) |
| 57 | + except SyntaxError: |
| 58 | + return |
| 59 | + |
| 60 | + def _is_subprocess_pipe(node: ast.AST) -> bool: |
| 61 | + # Matches: subprocess.PIPE |
| 62 | + return ( |
| 63 | + isinstance(node, ast.Attribute) |
| 64 | + and isinstance(node.value, ast.Name) |
| 65 | + and node.value.id == "subprocess" |
| 66 | + and node.attr == "PIPE" |
| 67 | + ) |
| 68 | + |
| 69 | + for n in ast.walk(tree): |
| 70 | + if not isinstance(n, ast.Call): |
| 71 | + continue |
| 72 | + |
| 73 | + # Match subprocess.Popen(...) |
| 74 | + is_popen = ( |
| 75 | + isinstance(n.func, ast.Attribute) |
| 76 | + and isinstance(n.func.value, ast.Name) |
| 77 | + and n.func.value.id == "subprocess" |
| 78 | + and n.func.attr == "Popen" |
| 79 | + ) |
| 80 | + if not is_popen: |
| 81 | + continue |
| 82 | + |
| 83 | + for kw in n.keywords: |
| 84 | + if kw.arg in {"stdout", "stderr"} and kw.value is not None and _is_subprocess_pipe(kw.value): |
| 85 | + raise RuntimeError( |
| 86 | + "tests/conftest.py must not start the test server with subprocess.PIPE " |
| 87 | + "(stdout/stderr). This can deadlock and cause intermittent pytest hangs." |
| 88 | + ) |
| 89 | + |
| 90 | + |
| 91 | +_enforce_no_popen_pipe_in_conftest() |
| 92 | + |
30 | 93 | # Script-style checks that are intended to run manually against a dedicated server |
31 | 94 | # should not be collected by pytest's normal test discovery. |
32 | 95 | collect_ignore = ["test_image_paste.py", "test_token_exposure.py"] |
@@ -79,6 +142,8 @@ def enforce_test_database() -> None: |
79 | 142 | def server(): |
80 | 143 | """Start the AgentChatBus server for the test session with test-specific config.""" |
81 | 144 | global _SERVER_PROCESS |
| 145 | + started_here = False |
| 146 | + server_ready = False |
82 | 147 |
|
83 | 148 | # Set environment variables for test server |
84 | 149 | test_env = os.environ.copy() |
@@ -111,66 +176,75 @@ def server(): |
111 | 176 | and react_check.status_code == 404 |
112 | 177 | and "Message" in str(react_detail) |
113 | 178 | ): |
114 | | - yield |
115 | | - return |
| 179 | + server_ready = True |
116 | 180 | except Exception: |
117 | 181 | pass |
118 | 182 |
|
119 | | - # Start the server with test configuration |
120 | | - print(f"\nStarting AgentChatBus test server at {BASE_URL}...") |
121 | | - print(f"Using test database: {TEST_DB_PATH}") |
122 | | - _SERVER_PROCESS = subprocess.Popen( |
123 | | - [sys.executable, "-m", "src.main"], |
124 | | - stdout=subprocess.PIPE, |
125 | | - stderr=subprocess.PIPE, |
126 | | - text=True, |
127 | | - env=test_env |
128 | | - ) |
129 | | - |
130 | | - # Wait for server to be ready |
131 | | - max_retries = 30 |
132 | | - for i in range(max_retries): |
133 | | - try: |
134 | | - time.sleep(0.5) |
135 | | - with httpx.Client(base_url=BASE_URL, timeout=5) as client: |
136 | | - resp = client.get("/api/threads") |
137 | | - if resp.status_code < 500: |
138 | | - print(f"Test server started successfully (attempt {i+1})") |
139 | | - yield |
140 | | - return |
141 | | - except Exception: |
142 | | - if i == max_retries - 1: |
143 | | - raise Exception(f"Server failed to start after {max_retries} attempts") |
144 | | - continue |
145 | | - |
146 | | - yield |
147 | | - |
148 | | - # Cleanup: stop the server gracefully |
149 | | - if _SERVER_PROCESS: |
150 | | - print("Stopping test server...") |
151 | | - try: |
152 | | - # First try graceful shutdown with Ctrl+C |
153 | | - if os.name == 'nt': # Windows |
154 | | - _SERVER_PROCESS.send_signal(signal.CTRL_C_EVENT) |
155 | | - else: |
156 | | - _SERVER_PROCESS.send_signal(signal.SIGTERM) |
157 | | - |
158 | | - # Wait up to 3 seconds for graceful shutdown |
159 | | - _SERVER_PROCESS.wait(timeout=3) |
160 | | - except (subprocess.TimeoutExpired, AttributeError, OSError): |
161 | | - # If graceful shutdown fails, force kill |
| 183 | + if not server_ready: |
| 184 | + # Start the server with test configuration |
| 185 | + print(f"\nStarting AgentChatBus test server at {BASE_URL}...") |
| 186 | + print(f"Using test database: {TEST_DB_PATH}") |
| 187 | + |
| 188 | + # IMPORTANT: do NOT set stdout/stderr to subprocess.PIPE here. |
| 189 | + # Pytest does not drain those pipes continuously, and once the pipe |
| 190 | + # buffer fills the server can block (deadlock), leading to flaky |
| 191 | + # httpx.ReadTimeout errors and the appearance that pytest has "hung". |
| 192 | + _SERVER_PROCESS = subprocess.Popen( |
| 193 | + [sys.executable, "-m", "src.main"], |
| 194 | + env=test_env |
| 195 | + ) |
| 196 | + started_here = True |
| 197 | + |
| 198 | + # Wait for server to be ready |
| 199 | + max_retries = 30 |
| 200 | + for i in range(max_retries): |
162 | 201 | try: |
163 | | - _SERVER_PROCESS.terminate() |
164 | | - _SERVER_PROCESS.wait(timeout=2) |
165 | | - except subprocess.TimeoutExpired: |
166 | | - _SERVER_PROCESS.kill() |
167 | | - _SERVER_PROCESS.wait() |
168 | | - print("Test server stopped") |
| 202 | + time.sleep(0.5) |
| 203 | + with httpx.Client(base_url=BASE_URL, timeout=5) as client: |
| 204 | + resp = client.get("/api/threads") |
| 205 | + if resp.status_code < 500: |
| 206 | + print(f"Test server started successfully (attempt {i+1})") |
| 207 | + server_ready = True |
| 208 | + break |
| 209 | + except Exception: |
| 210 | + if i == max_retries - 1: |
| 211 | + raise Exception(f"Server failed to start after {max_retries} attempts") |
| 212 | + continue |
| 213 | + |
| 214 | + if not server_ready: |
| 215 | + raise RuntimeError(f"Test server is not ready at {BASE_URL}") |
169 | 216 |
|
170 | | - # Optionally clean up test database |
171 | | - if os.path.exists(TEST_DB_PATH): |
172 | | - try: |
173 | | - os.remove(TEST_DB_PATH) |
174 | | - print(f"Cleaned up test database: {TEST_DB_PATH}") |
175 | | - except Exception as e: |
176 | | - print(f"Warning: Could not remove test database: {e}") |
| 217 | + try: |
| 218 | + yield |
| 219 | + finally: |
| 220 | + # Cleanup only for process started by this fixture. |
| 221 | + if started_here and _SERVER_PROCESS: |
| 222 | + print("Stopping test server...") |
| 223 | + try: |
| 224 | + # On Windows, CTRL_C_EVENT can interrupt the pytest parent process. |
| 225 | + # Use terminate() for reliable child-only shutdown. |
| 226 | + if os.name == 'nt': |
| 227 | + _SERVER_PROCESS.terminate() |
| 228 | + else: |
| 229 | + _SERVER_PROCESS.send_signal(signal.SIGTERM) |
| 230 | + |
| 231 | + # Wait up to 3 seconds for graceful shutdown |
| 232 | + _SERVER_PROCESS.wait(timeout=3) |
| 233 | + except (subprocess.TimeoutExpired, AttributeError, OSError): |
| 234 | + # If graceful shutdown fails, force kill |
| 235 | + try: |
| 236 | + _SERVER_PROCESS.terminate() |
| 237 | + _SERVER_PROCESS.wait(timeout=2) |
| 238 | + except subprocess.TimeoutExpired: |
| 239 | + _SERVER_PROCESS.kill() |
| 240 | + _SERVER_PROCESS.wait() |
| 241 | + print("Test server stopped") |
| 242 | + _SERVER_PROCESS = None |
| 243 | + |
| 244 | + # Optionally clean up test database created by this fixture run. |
| 245 | + if started_here and os.path.exists(TEST_DB_PATH): |
| 246 | + try: |
| 247 | + os.remove(TEST_DB_PATH) |
| 248 | + print(f"Cleaned up test database: {TEST_DB_PATH}") |
| 249 | + except Exception as e: |
| 250 | + print(f"Warning: Could not remove test database: {e}") |
0 commit comments