55from __future__ import annotations
66
77import multiprocessing as mp
8-
8+ import queue
99import threading
1010import time
1111from collections .abc import Callable
@@ -456,8 +456,11 @@ def _process_server_entry(
456456 server_kwargs : dict ,
457457 shutdown_event : MPEvent ,
458458 ready_queue ,
459+ control_queue ,
460+ control_response_queue ,
459461) -> None :
460462 """Run an :class:`InferenceServer` loop inside a child process."""
463+ server = None
461464 try :
462465 model = policy_factory ()
463466 server = InferenceServer (
@@ -466,11 +469,36 @@ def _process_server_entry(
466469 shutdown_event = shutdown_event ,
467470 ** server_kwargs ,
468471 )
472+ server .start ()
469473 ready_queue .put ((True , None ))
470- server ._run ()
474+ while not shutdown_event .is_set ():
475+ try :
476+ request_id , command , kwargs = control_queue .get (timeout = 0.05 )
477+ except queue .Empty :
478+ continue
479+ try :
480+ if command == "stats" :
481+ payload = server .stats (** kwargs )
482+ elif command == "health" :
483+ payload = {
484+ "alive" : server .is_alive ,
485+ "policy_version" : server .policy_version ,
486+ }
487+ elif command == "shutdown" :
488+ shutdown_event .set ()
489+ payload = {"accepted" : True }
490+ else :
491+ raise RuntimeError (f"Unknown process-server command: { command } " )
492+ except BaseException as exc :
493+ control_response_queue .put ((request_id , False , repr (exc )))
494+ else :
495+ control_response_queue .put ((request_id , True , payload ))
471496 except BaseException as exc :
472497 ready_queue .put ((False , repr (exc )))
473498 raise
499+ finally :
500+ if server is not None :
501+ server .shutdown (timeout = 1.0 )
474502
475503
476504class ProcessInferenceServer :
@@ -596,6 +624,9 @@ def __init__(
596624 self ._ctx = mp_context
597625 self ._shutdown_event = self ._ctx .Event ()
598626 self ._ready_queue = self ._ctx .Queue ()
627+ self ._control_queue = self ._ctx .Queue ()
628+ self ._control_response_queue = self ._ctx .Queue ()
629+ self ._next_control_request_id = 0
599630 self ._process : mp .Process | None = None
600631 self ._server_kwargs = {
601632 "max_batch_size" : max_batch_size ,
@@ -626,6 +657,8 @@ def start(self) -> ProcessInferenceServer:
626657 "server_kwargs" : self ._server_kwargs ,
627658 "shutdown_event" : self ._shutdown_event ,
628659 "ready_queue" : self ._ready_queue ,
660+ "control_queue" : self ._control_queue ,
661+ "control_response_queue" : self ._control_response_queue ,
629662 },
630663 daemon = True ,
631664 name = "ProcessInferenceServer" ,
@@ -637,8 +670,44 @@ def start(self) -> ProcessInferenceServer:
637670 raise RuntimeError (f"ProcessInferenceServer failed to start: { payload } " )
638671 return self
639672
673+ def _request_control (
674+ self , command : str , kwargs : dict | None = None , timeout : float = 5.0
675+ ):
676+ if self ._process is None :
677+ raise RuntimeError ("ProcessInferenceServer is not running." )
678+ if not self ._process .is_alive ():
679+ raise RuntimeError (
680+ "ProcessInferenceServer process is not alive "
681+ f"(exitcode={ self ._process .exitcode } )."
682+ )
683+ request_id = self ._next_control_request_id
684+ self ._next_control_request_id += 1
685+ self ._control_queue .put ((request_id , command , kwargs or {}))
686+ deadline = time .monotonic () + timeout
687+ while True :
688+ remaining = deadline - time .monotonic ()
689+ if remaining <= 0 :
690+ raise TimeoutError (
691+ f"Timed out waiting for ProcessInferenceServer { command !r} ."
692+ )
693+ response_id , ok , payload = self ._control_response_queue .get (
694+ timeout = remaining
695+ )
696+ if response_id != request_id :
697+ continue
698+ if not ok :
699+ raise RuntimeError (
700+ f"ProcessInferenceServer { command !r} failed: { payload } "
701+ )
702+ return payload
703+
640704 def shutdown (self , timeout : float | None = 5.0 ) -> None :
641705 """Signal the child process to stop and wait for it to exit."""
706+ if self .is_alive :
707+ try :
708+ self ._request_control ("shutdown" , timeout = timeout or 5.0 )
709+ except Exception :
710+ pass
642711 self ._shutdown_event .set ()
643712 process = self ._process
644713 if process is None :
@@ -655,12 +724,25 @@ def is_alive(self) -> bool:
655724 return self ._process is not None and self ._process .is_alive ()
656725
657726 def stats (self , * , reset : bool = False ) -> dict [str , float | int ]:
658- """Return process-server stats.
727+ """Return process-server stats from the child process .
659728
660- Live stats are not shared across processes yet, so this currently
661- returns an empty dictionary.
729+ Args:
730+ reset (bool, optional): if ``True``, reset counters in the child
731+ process after taking the snapshot.
662732 """
663- return {}
733+ return self ._request_control ("stats" , {"reset" : reset })
734+
735+ def health (self ) -> dict [str , int | bool | None ]:
736+ """Return a lightweight child-process health snapshot."""
737+ process = self ._process
738+ result = {
739+ "process_alive" : process .is_alive () if process is not None else False ,
740+ "pid" : process .pid if process is not None else None ,
741+ "exitcode" : process .exitcode if process is not None else None ,
742+ }
743+ if process is not None and process .is_alive ():
744+ result .update (self ._request_control ("health" ))
745+ return result
664746
665747 def __enter__ (self ) -> ProcessInferenceServer :
666748 return self .start ()
0 commit comments