77import sys
88import threading
99import time
10+ from typing import Iterator , Tuple
1011from unittest .mock import Mock
1112
1213import grpc
14+ import psutil
1315import pytest
16+ from isolate .connections .grpc .definitions import FunctionCall
17+ from isolate .connections .grpc .definitions .agent_pb2_grpc import AgentStub
1418from isolate .server .definitions .server_pb2 import BoundFunction , EnvironmentDefinition
1519from isolate .server .definitions .server_pb2_grpc import IsolateStub
1620from isolate .server .interface import to_serialized_object
@@ -44,13 +48,23 @@ def servicer():
4448
4549
4650@pytest .fixture
47- def isolate_server_subprocess (monkeypatch ):
51+ def single_use ():
52+ return True
53+
54+
55+ @pytest .fixture
56+ def idle_timeout_seconds ():
57+ return 0
58+
59+
60+ @pytest .fixture
61+ def isolate_server_subprocess (
62+ single_use : bool , idle_timeout_seconds : int
63+ ) -> Iterator [Tuple [subprocess .Popen , int ]]:
4864 """Set up a gRPC server with the IsolateServicer for testing."""
4965 # Find a free port
5066 import socket
5167
52- monkeypatch .setenv ("ISOLATE_SHUTDOWN_GRACE_PERIOD" , "2" )
53-
5468 # Bind only to the loopback interface to avoid exposing the socket on all interfaces
5569 with socket .socket () as s :
5670 s .bind (("127.0.0.1" , 0 ))
@@ -61,10 +75,14 @@ def isolate_server_subprocess(monkeypatch):
6175 sys .executable ,
6276 "-m" ,
6377 "isolate.server.server" ,
64- "--single-use" ,
78+ * ([ "--single-use" ] if single_use else []) ,
6579 "--port" ,
6680 str (port ),
67- ]
81+ ],
82+ env = {
83+ "ISOLATE_SHUTDOWN_GRACE_PERIOD" : "2" ,
84+ "ISOLATE_AGENT_IDLE_TIMEOUT_SECONDS" : str (idle_timeout_seconds ),
85+ },
6886 )
6987
7088 time .sleep (5 ) # Wait for server to start
@@ -76,7 +94,50 @@ def isolate_server_subprocess(monkeypatch):
7694 process .wait (timeout = 10 )
7795
7896
79- def consume_responses (responses ):
97+ @pytest .fixture
98+ def isolate_agent_subprocess (
99+ idle_timeout_seconds : int ,
100+ ) -> Iterator [Tuple [subprocess .Popen , int ]]:
101+ """Set up a gRPC server with the IsolateServicer for testing."""
102+ # Find a free port
103+ import socket
104+
105+ # Bind only to the loopback interface to avoid exposing the socket on all interfaces
106+ with socket .socket () as s :
107+ s .bind (("127.0.0.1" , 0 ))
108+ port = s .getsockname ()[1 ]
109+
110+ # Use /dev/null for log output since pytest may capture stdout
111+ # (making fileno() fail with "Bad file descriptor")
112+ log_file = open (os .devnull , "w" )
113+
114+ process = subprocess .Popen (
115+ [
116+ sys .executable ,
117+ "-m" ,
118+ "isolate.connections.grpc.agent" ,
119+ f"localhost:{ port } " ,
120+ "--log-fd" ,
121+ str (log_file .fileno ()),
122+ ],
123+ env = {
124+ "ISOLATE_AGENT_IDLE_TIMEOUT_SECONDS" : str (idle_timeout_seconds ),
125+ },
126+ pass_fds = (log_file .fileno (),),
127+ )
128+
129+ time .sleep (1 ) # Wait for server to start
130+ try :
131+ yield process , port
132+ finally :
133+ # Cleanup
134+ if process .poll () is None :
135+ process .terminate ()
136+ process .wait (timeout = 10 )
137+ log_file .close ()
138+
139+
140+ def consume_responses (responses : Iterator , wait : bool = False ) -> None :
80141 def _consume ():
81142 try :
82143 for response in responses :
@@ -87,6 +148,8 @@ def _consume():
87148
88149 response_thread = threading .Thread (target = _consume , daemon = True )
89150 response_thread .start ()
151+ if wait :
152+ response_thread .join ()
90153
91154
92155def test_shutdown_with_terminate (servicer ):
@@ -180,5 +243,103 @@ def handle_term(signum, frame):
180243 ), "Function should have received SIGTERM and created the file"
181244
182245
183- if __name__ == "__main__" :
184- pytest .main ([__file__ , "-v" ])
246+ @pytest .mark .parametrize (
247+ "idle_timeout_seconds" ,
248+ [0 , 2 ],
249+ )
250+ def test_idle_timeout_no_request (isolate_agent_subprocess , idle_timeout_seconds ):
251+ process , port = isolate_agent_subprocess
252+
253+ p = psutil .Process (process .pid )
254+ for _ in range (10 ):
255+ if p .is_running ():
256+ break
257+ time .sleep (1 )
258+ else :
259+ assert False , "Agent should be running"
260+
261+ # Wait for the idle timeout to trigger
262+ try :
263+ p .wait (timeout = 5 )
264+ terminated = True
265+ except psutil .TimeoutExpired :
266+ terminated = False
267+
268+ if idle_timeout_seconds == 0 :
269+ assert not terminated , "Agent should not have terminated"
270+ else :
271+ assert terminated , "Agent should have terminated after idle timeout"
272+
273+
274+ @pytest .mark .parametrize (
275+ "idle_timeout_seconds" ,
276+ [0 , 2 ],
277+ )
278+ def test_idle_timeout (isolate_agent_subprocess , idle_timeout_seconds ):
279+ process , port = isolate_agent_subprocess
280+
281+ p = psutil .Process (process .pid )
282+ for _ in range (10 ):
283+ if p .is_running ():
284+ break
285+ time .sleep (1 )
286+ else :
287+ assert False , "Agent should be running"
288+
289+ channel = grpc .insecure_channel (f"localhost:{ port } " )
290+ stub = AgentStub (channel )
291+
292+ def fn ():
293+ import time
294+
295+ time .sleep (3 ) # longer than the idle timeout
296+ print ("Function finished" )
297+
298+ responses = stub .Run (FunctionCall (function = to_serialized_object (fn , method = "dill" )))
299+ consume_responses (responses , wait = True )
300+
301+ # Wait for the idle timeout to trigger
302+ try :
303+ p .wait (timeout = 5 )
304+ terminated = True
305+ except psutil .TimeoutExpired :
306+ terminated = False
307+
308+ if idle_timeout_seconds == 0 :
309+ assert not terminated , "Agent should not have terminated"
310+ else :
311+ assert terminated , "Agent should have terminated after idle timeout"
312+
313+
314+ @pytest .mark .parametrize (
315+ ["single_use" , "idle_timeout_seconds" ],
316+ [(False , 2 )], # to prevent the server from shutting down automatically
317+ )
318+ def test_idle_timeout_server_handle (isolate_server_subprocess ):
319+ process , port = isolate_server_subprocess
320+ channel = grpc .insecure_channel (f"localhost:{ port } " )
321+ stub = IsolateStub (channel )
322+
323+ def fn ():
324+ import time
325+
326+ time .sleep (5 ) # longer than the idle timeout
327+ print ("Function finished" )
328+
329+ responses = stub .Run (create_run_request (fn ))
330+ consume_responses (responses , wait = True )
331+
332+ # Send the first request to start the agent
333+ p = psutil .Process (process .pid )
334+ assert len (p .children ()) == 1 , "Server should have one agent process"
335+
336+ # Wait for the idle timeout to trigger
337+ time .sleep (3 )
338+ assert (
339+ len (p .children ()) == 0
340+ ), "Agent process should have terminated after idle timeout"
341+
342+ # Server should be able to handle a new request after the idle timeout
343+ responses = stub .Run (create_run_request (fn ))
344+ consume_responses (responses , wait = True )
345+ assert len (p .children ()) == 1 , "Server should have one agent process"
0 commit comments