1111import os
1212import subprocess
1313import sys
14+ import threading
15+ import uuid
1416from pathlib import Path
15- from typing import Any
17+ from queue import Empty , Queue
18+ from typing import Any , Optional
1619
1720import orjson
1821
1922logger = logging .getLogger (__name__ )
2023
2124
2225class VenvProcessCommunicator :
23- """Handles communication with child processes in different virtual environments ."""
26+ """Handles communication with a long-running child process in a different virtual environment ."""
2427
2528 def __init__ (self , venv_path : str ) -> None :
2629 """
@@ -31,6 +34,12 @@ def __init__(self, venv_path: str) -> None:
3134 """
3235 self .venv_path = Path (venv_path )
3336 self .python_executable = self ._get_python_executable ()
37+ self .process : Optional [subprocess .Popen ] = None
38+ self .reader_thread : Optional [threading .Thread ] = None
39+ self .stderr_thread : Optional [threading .Thread ] = None
40+ self .response_queues : dict [str , Queue ] = {}
41+ self .lock = threading .Lock ()
42+ self .running = False
3443 logger .info ("cwd: %s" , os .getcwd ())
3544
3645 def _get_python_executable (self ):
@@ -57,47 +66,199 @@ def install_requirements(self, requirements_file: str) -> None:
5766 if rc != 0 :
5867 raise Exception (f"Failed to install requirements from { requirements_file } " )
5968
60- def send_task (self , script_path : str , task_data : Any ) -> Any :
69+ def start_worker (self , script_path : str ) -> None :
6170 """
62- Send a task to child process and get response .
71+ Start the long-running worker process.
6372
6473 Args:
65- script_path (str): Path to the child script
66- task_data (dict): Data to send to child process
67-
68- Returns:
69- dict: Response from child process
74+ script_path (str): Path to the worker script
7075 """
71- process = None
76+ if self .running :
77+ logger .warning ("Worker process already running" )
78+ return
79+
7280 try :
73- # Prepare input data as JSON
74- input_json = orjson .dumps (task_data ).decode ()
7581 # Start child process
76- process = subprocess .Popen (
82+ self . process = subprocess .Popen (
7783 [self .python_executable , script_path ],
7884 stdin = subprocess .PIPE ,
7985 stdout = subprocess .PIPE ,
8086 stderr = subprocess .PIPE ,
8187 text = True ,
82- cwd = os .getcwd (), # Maintain current working directory
88+ bufsize = 1 , # Line buffered
89+ cwd = os .getcwd (),
8390 )
8491
85- # Send data and get response
86- stdout , stderr = process .communicate (input = input_json , timeout = 30 )
92+ self .running = True
93+
94+ # Start reader thread to handle responses
95+ self .reader_thread = threading .Thread (target = self ._read_responses , daemon = True )
96+ self .reader_thread .start ()
97+
98+ # Start stderr reader thread to capture errors
99+ self .stderr_thread = threading .Thread (target = self ._read_stderr , daemon = True )
100+ self .stderr_thread .start ()
101+
102+ logger .info ("Worker process started with PID: %s" , self .process .pid )
103+
104+ except Exception as e :
105+ self .running = False
106+ raise RuntimeError (f"Failed to start worker process: { e } " )
107+
108+ def _read_stderr (self ) -> None :
109+ """Background thread to read and log stderr from worker process."""
110+ if not self .process or not self .process .stderr :
111+ return
112+
113+ while self .running and self .process and self .process .stderr :
114+ try :
115+ line = self .process .stderr .readline ()
116+ if not line :
117+ break
118+ # Log stderr output from worker
119+ logger .debug ("Worker stderr: %s" , line .strip ())
120+ except Exception as e :
121+ logger .error ("Error reading stderr: %s" , e )
122+ break
123+
124+ def _read_responses (self ) -> None :
125+ """Background thread to read responses from worker process."""
126+ while self .running and self .process and self .process .stdout :
127+ try :
128+ line = self .process .stdout .readline ()
129+ if not line :
130+ # Process has terminated
131+ logger .warning ("Worker process stdout closed" )
132+ break
133+
134+ line = line .strip ()
135+ if not line :
136+ # Empty line, skip
137+ continue
138+
139+ try :
140+ response = json .loads (line )
141+ request_id = response .get ("request_id" )
142+
143+ if request_id :
144+ with self .lock :
145+ if request_id in self .response_queues :
146+ self .response_queues [request_id ].put (response )
147+ logger .debug ("Response queued for request_id: %s" , request_id )
148+ else :
149+ logger .warning ("Received response for unknown request_id: %s" , request_id )
150+ else :
151+ logger .warning ("Received response without request_id: %s" , line [:100 ])
152+
153+ except json .JSONDecodeError as e :
154+ logger .error ("Failed to decode response: %s, line: %s" , e , line [:200 ])
155+
156+ except Exception as e :
157+ logger .exception ("Error reading response: %s" , e )
158+ break
159+
160+ self .running = False
161+ logger .info ("Response reader thread terminated" )
162+
163+ def send_task (self , script_path : str , task_data : Any , timeout : float = 30.0 ) -> Any :
164+ """
165+ Send a task to the long-running worker process and get response.
166+
167+ Args:
168+ script_path (str): Path to the child script (used for worker initialization)
169+ task_data (dict): Data to send to child process
170+ timeout (float): Timeout in seconds for waiting for response
171+
172+ Returns:
173+ dict: Response from child process
174+ """
175+ # Start worker if not running
176+ if not self .running :
177+ self .start_worker (script_path )
178+
179+ # Generate unique request ID
180+ request_id = str (uuid .uuid4 ())
181+ task_data ["request_id" ] = request_id
182+
183+ # Create response queue for this request
184+ response_queue : Queue = Queue ()
185+ with self .lock :
186+ self .response_queues [request_id ] = response_queue
87187
88- if process .returncode != 0 :
89- raise RuntimeError (f"Child process failed: { stderr } " )
188+ try :
189+ # Send task to worker
190+ input_json = orjson .dumps (task_data ).decode ()
191+ if self .process and self .process .stdin :
192+ self .process .stdin .write (input_json + "\n " )
193+ self .process .stdin .flush ()
194+ else :
195+ raise RuntimeError ("Worker process stdin not available" )
90196
91- # Parse response
197+ # Wait for response
92198 try :
93- response = json .loads (stdout .strip ())
199+ response = response_queue .get (timeout = timeout )
200+
201+ # Check for errors in response
202+ if response .get ("status" ) == "error" :
203+ raise RuntimeError (f"Worker process error: { response .get ('message' )} " )
204+
205+ # Remove request_id from response before returning
206+ response .pop ("request_id" , None )
94207 return response
95- except json .JSONDecodeError :
96- raise RuntimeError (f"Invalid JSON response from child: { stdout } " )
97208
98- except subprocess .TimeoutExpired :
99- if process :
100- process .kill ()
101- raise RuntimeError ("Child process timed out" )
209+ except Empty :
210+ raise RuntimeError (f"Worker process timed out after { timeout } seconds" )
211+
212+ finally :
213+ # Clean up response queue
214+ with self .lock :
215+ self .response_queues .pop (request_id , None )
216+
217+ def stop_worker (self ) -> None :
218+ """Stop the long-running worker process."""
219+ if not self .running :
220+ return
221+
222+ self .running = False
223+
224+ try :
225+ if self .process :
226+ # Send shutdown signal
227+ if self .process .stdin :
228+ try :
229+ shutdown_task = {"task_type" : "shutdown" , "request_id" : "shutdown" }
230+ self .process .stdin .write (json .dumps (shutdown_task ) + "\n " )
231+ self .process .stdin .flush ()
232+ except Exception as e :
233+ logger .warning ("Failed to send shutdown signal: %s" , e )
234+
235+ # Wait for process to terminate gracefully
236+ try :
237+ self .process .wait (timeout = 5.0 )
238+ except subprocess .TimeoutExpired :
239+ logger .warning ("Worker process did not terminate gracefully, killing it" )
240+ self .process .kill ()
241+ self .process .wait ()
242+
243+ logger .info ("Worker process stopped" )
244+
102245 except Exception as e :
103- raise RuntimeError (f"Communication error: { e } " )
246+ logger .error ("Error stopping worker process: %s" , e )
247+
248+ finally :
249+ self .process = None
250+ if self .reader_thread and self .reader_thread .is_alive ():
251+ self .reader_thread .join (timeout = 2.0 )
252+ self .reader_thread = None
253+ if self .stderr_thread and self .stderr_thread .is_alive ():
254+ self .stderr_thread .join (timeout = 2.0 )
255+ self .stderr_thread = None
256+
257+ def is_alive (self ) -> bool :
258+ """Check if the worker process is alive and running."""
259+ return self .running and self .process is not None and self .process .poll () is None
260+
261+ def __del__ (self ):
262+ """Cleanup when object is destroyed."""
263+ if hasattr (self , 'running' ):
264+ self .stop_worker ()
0 commit comments