Skip to content

Commit 4393a6f

Browse files
fix(ci): increased startup robustness (period and retry), improve fail diag., free port aolocation
1 parent 11dce54 commit 4393a6f

3 files changed

Lines changed: 89 additions & 50 deletions

File tree

CMakePresets.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
"toolset": "v143",
3030
"architecture": "x64",
3131
"cacheVariables": {
32-
"VCPKG_OVERLAY_TRIPLETS": "${sourceDir}/cmake/triplets",
32+
"VCPKG_OVERLAY_TRIPLETS": "${sourceDir}/triplets",
3333
"VCPKG_TARGET_TRIPLET": "x64-windows-v143",
3434
"VCPKG_HOST_TRIPLET": "x64-windows-v143"
3535
}

tests/conftest.py

Lines changed: 88 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import time
55
import socket
66
from pathlib import Path
7-
from typing import List
7+
from typing import List, Tuple
88

99
import grpc
1010
import pytest
@@ -130,6 +130,27 @@ def has_required_bindings(path: Path) -> bool:
130130
return python_proto_dir
131131

132132

133+
def _allocate_free_port() -> int:
134+
"""Allocate a free ephemeral localhost TCP port."""
135+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
136+
s.bind(("127.0.0.1", 0))
137+
return int(s.getsockname()[1])
138+
139+
140+
def _collect_process_output(process: subprocess.Popen) -> Tuple[str, str]:
141+
"""Terminate process if needed and return captured stdout/stderr."""
142+
if process.poll() is None:
143+
process.terminate()
144+
try:
145+
stdout, stderr = process.communicate(timeout=2)
146+
except subprocess.TimeoutExpired:
147+
process.kill()
148+
stdout, stderr = process.communicate(timeout=2)
149+
else:
150+
stdout, stderr = process.communicate()
151+
return stdout or "", stderr or ""
152+
153+
133154
# -----------------------------------------------------------------------------
134155
# Fixtures
135156
# -----------------------------------------------------------------------------
@@ -150,9 +171,7 @@ def server_exe():
150171
@pytest.fixture
151172
def free_port():
152173
"""Get a free ephemeral port."""
153-
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
154-
s.bind(("127.0.0.1", 0))
155-
return s.getsockname()[1]
174+
return _allocate_free_port()
156175

157176

158177
class ServerProcess:
@@ -193,55 +212,75 @@ def fluxgraph_server(server_exe: Path, free_port: int, proto_bindings):
193212
import fluxgraph_pb2_grpc as pb_grpc
194213
import fluxgraph_pb2 as pb
195214

196-
cmd = [str(server_exe), "--port", str(free_port)]
197-
198-
# Start process
199-
proc = subprocess.Popen(
200-
cmd,
201-
stdout=subprocess.PIPE,
202-
stderr=subprocess.PIPE,
203-
text=True,
204-
cwd=str(
205-
_repo_root()
206-
), # Run from repo root so relative config paths work if needed
207-
)
208-
209-
server = ServerProcess(proc, free_port)
210-
211-
# Wait for readiness (using gRPC health check)
212-
address = f"127.0.0.1:{free_port}"
213-
channel = grpc.insecure_channel(address)
214-
stub = pb_grpc.FluxGraphStub(channel)
215-
216-
start_time = time.time()
217-
ready = False
218-
last_err = None
219-
220-
while time.time() - start_time < 5.0:
221-
if proc.poll() is not None:
222-
pytest.fail(
223-
f"Server process died immediately with code {proc.returncode}. Stderr: {proc.stderr.read()}"
224-
)
225-
226-
try:
227-
req = pb.HealthCheckRequest(service="fluxgraph")
228-
resp = stub.Check(req)
229-
if resp.status == pb.HealthCheckResponse.SERVING:
230-
ready = True
215+
max_start_attempts = 3
216+
startup_timeout_sec = 10.0
217+
startup_failure = "unknown startup failure"
218+
219+
for attempt in range(1, max_start_attempts + 1):
220+
port = free_port if attempt == 1 else _allocate_free_port()
221+
cmd = [str(server_exe), "--port", str(port)]
222+
223+
# Start process
224+
proc = subprocess.Popen(
225+
cmd,
226+
stdout=subprocess.PIPE,
227+
stderr=subprocess.PIPE,
228+
text=True,
229+
cwd=str(
230+
_repo_root()
231+
), # Run from repo root so relative config paths work if needed
232+
)
233+
server = ServerProcess(proc, port)
234+
235+
# Wait for readiness (using gRPC health check)
236+
channel = grpc.insecure_channel(server.address)
237+
stub = pb_grpc.FluxGraphStub(channel)
238+
239+
start_time = time.time()
240+
ready = False
241+
last_err = None
242+
243+
while time.time() - start_time < startup_timeout_sec:
244+
if proc.poll() is not None:
245+
stdout, stderr = _collect_process_output(proc)
246+
startup_failure = (
247+
f"Server exited during startup on attempt {attempt}/{max_start_attempts} "
248+
f"(port={port}, code={proc.returncode}).\n"
249+
f"stdout:\n{stdout}\n"
250+
f"stderr:\n{stderr}"
251+
)
231252
break
232-
except grpc.RpcError as e:
233-
last_err = e
234-
time.sleep(0.1)
235253

236-
if not ready:
237-
proc.kill()
238-
pytest.fail(
239-
f"Server at {address} failed to become ready in 5s. Last error: {last_err}"
240-
)
254+
try:
255+
req = pb.HealthCheckRequest(service="fluxgraph")
256+
resp = stub.Check(req, timeout=0.5)
257+
if resp.status == pb.HealthCheckResponse.SERVING:
258+
ready = True
259+
break
260+
except grpc.RpcError as e:
261+
last_err = e
262+
time.sleep(0.1)
263+
264+
channel.close()
265+
266+
if ready:
267+
yield server
268+
server.stop()
269+
return
270+
271+
if proc.poll() is None:
272+
stdout, stderr = _collect_process_output(proc)
273+
startup_failure = (
274+
f"Server at {server.address} failed readiness on attempt {attempt}/{max_start_attempts} "
275+
f"within {startup_timeout_sec:.1f}s. Last RPC error: {last_err}\n"
276+
f"stdout:\n{stdout}\n"
277+
f"stderr:\n{stderr}"
278+
)
241279

242-
yield server
280+
if attempt < max_start_attempts:
281+
print(f"WARNING: {startup_failure}\nRetrying startup...")
243282

244-
server.stop()
283+
pytest.fail(startup_failure)
245284

246285

247286
@pytest.fixture

0 commit comments

Comments
 (0)