44import time
55import socket
66from pathlib import Path
7- from typing import List
7+ from typing import List , Tuple
88
99import grpc
1010import 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
151172def 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
158177class 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 } \n Retrying startup..." )
243282
244- server . stop ( )
283+ pytest . fail ( startup_failure )
245284
246285
247286@pytest .fixture
0 commit comments