Skip to content

Commit 57f0068

Browse files
committed
test: stabilize pytest server and enforce test port
1 parent 214d197 commit 57f0068

5 files changed

Lines changed: 148 additions & 64 deletions

File tree

tests/conftest.py

Lines changed: 134 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -9,24 +9,87 @@
99
import sys
1010
import time
1111
import subprocess
12+
import ast
1213
from pathlib import Path
1314
import httpx
1415
import pytest
1516

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.
1718
# See UP-20 / Integration Testing — Port Conflict Resolution in agentchatbus-upstream-improvements.md
1819
TEST_PORT = 39769
1920
BASE_URL = f"http://127.0.0.1:{TEST_PORT}"
2021
# Use a separate test database
2122
TEST_DB_PATH = os.path.join(os.path.dirname(__file__), "data", "bus_test.db")
2223
_SERVER_PROCESS = None
2324

25+
if TEST_PORT == 39765:
26+
raise RuntimeError("TEST_PORT must never be the production port 39765.")
27+
2428
# Hard guardrails: tests must never accidentally use a production DB.
2529
# 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)
2732
os.environ.setdefault("AGENTCHATBUS_TEST_BASE_URL", BASE_URL)
2833
os.environ.setdefault("AGENTCHATBUS_DB", TEST_DB_PATH)
2934

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+
3093
# Script-style checks that are intended to run manually against a dedicated server
3194
# should not be collected by pytest's normal test discovery.
3295
collect_ignore = ["test_image_paste.py", "test_token_exposure.py"]
@@ -79,6 +142,8 @@ def enforce_test_database() -> None:
79142
def server():
80143
"""Start the AgentChatBus server for the test session with test-specific config."""
81144
global _SERVER_PROCESS
145+
started_here = False
146+
server_ready = False
82147

83148
# Set environment variables for test server
84149
test_env = os.environ.copy()
@@ -111,66 +176,75 @@ def server():
111176
and react_check.status_code == 404
112177
and "Message" in str(react_detail)
113178
):
114-
yield
115-
return
179+
server_ready = True
116180
except Exception:
117181
pass
118182

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):
162201
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}")
169216

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}")

tests/test_database_safety_contract.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,3 +86,13 @@ def test_conftest_enforces_database_guardrails() -> None:
8686
conftest = _read_text(Path("tests/conftest.py"))
8787
assert "def enforce_test_database" in conftest
8888
assert "AGENTCHATBUS_DB" in conftest
89+
90+
91+
def test_conftest_enforces_non_production_test_port() -> None:
92+
conftest = _read_text(Path("tests/conftest.py"))
93+
assert "TEST_PORT" in conftest
94+
assert "if TEST_PORT == 39765" in conftest
95+
96+
m = re.search(r"TEST_PORT\s*=\s*(\d+)", conftest)
97+
assert m, "tests/conftest.py must define TEST_PORT"
98+
assert int(m.group(1)) != 39765, "TEST_PORT must never be production port 39765"

tests/test_e2e.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@
1010
import pytest
1111
import uuid
1212

13-
# Use the same test port as conftest.py (39766) to connect to test server
14-
BASE_URL = os.getenv("AGENTCHATBUS_TEST_BASE_URL", "http://127.0.0.1:39766")
13+
# Use the same test port as conftest.py (39769) to connect to test server.
14+
BASE_URL = os.getenv("AGENTCHATBUS_TEST_BASE_URL", "http://127.0.0.1:39769")
1515

1616

1717
def _build_client() -> httpx.Client:

tests/test_export_markdown.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
# NOTE: This test suite must run against a dedicated test server instance.
1111
# Do NOT default to AGENTCHATBUS_BASE_URL (which may point at a production/dev server).
12-
BASE_URL = os.getenv("AGENTCHATBUS_TEST_BASE_URL", "http://127.0.0.1:39766")
12+
BASE_URL = os.getenv("AGENTCHATBUS_TEST_BASE_URL", "http://127.0.0.1:39769")
1313

1414

1515
def _build_client() -> httpx.Client:

tests/test_metrics.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
from src.db import crud
3232
from src.db.database import init_schema
3333

34-
BASE_URL = os.getenv("AGENTCHATBUS_TEST_BASE_URL", "http://127.0.0.1:39766")
34+
BASE_URL = os.getenv("AGENTCHATBUS_TEST_BASE_URL", "http://127.0.0.1:39769")
3535

3636
# ─────────────────────────────────────────────
3737
# Helpers

0 commit comments

Comments
 (0)