1414# You should have received a copy of the GNU Lesser General Public
1515# License along with this library.
1616
17+ import asyncio
1718import pathlib
18- import socket
1919import subprocess
20+ import time
2021import typing
2122
2223import octobot_commons .dsl_interpreter .operator as dsl_interpreter_operator
2324import octobot_commons .errors as commons_errors
25+ import octobot_commons .logging as commons_logging
2426import octobot_commons .process_util as process_util
2527
2628
@@ -52,6 +54,37 @@ def request_graceful_stop(
5254 )
5355 return process_util .request_graceful_stop_via_sigterm (self .pid , logger = logger )
5456
57+ async def wait_until_pid_stopped (
58+ self ,
59+ pid : int ,
60+ * ,
61+ logger : typing .Optional [typing .Any ] = None ,
62+ timeout_seconds : float ,
63+ poll_interval : float = 0.2 ,
64+ ) -> None :
65+ """Poll until ``pid`` is gone or ``timeout_seconds`` elapses (after e.g. SIGTERM)."""
66+ resolved_logger = logger or commons_logging .get_logger (self .__class__ .__name__ )
67+ if pid <= 0 :
68+ resolved_logger .info (
69+ "wait_until_pid_stopped: pid=%s treated as already stopped (non-positive)" ,
70+ pid ,
71+ )
72+ return
73+ resolved_logger .info (
74+ "wait_until_pid_stopped: waiting for pid=%s to exit (timeout=%ss)" ,
75+ pid ,
76+ timeout_seconds ,
77+ )
78+ deadline = time .monotonic () + timeout_seconds
79+ while time .monotonic () < deadline :
80+ if not process_util .pid_is_running (pid ):
81+ resolved_logger .info ("wait_until_pid_stopped: pid=%s exited" , pid )
82+ return
83+ await asyncio .sleep (poll_interval )
84+ raise commons_errors .DSLInterpreterError (
85+ f"Timed out after { timeout_seconds } s waiting for pid={ pid } to exit."
86+ )
87+
5588 def spawn_subprocess (
5689 self ,
5790 argv : list [str ],
@@ -78,42 +111,6 @@ def reject_user_path_segment(path_value: str) -> None:
78111 "Invalid path: parent directory segments are not allowed."
79112 )
80113
81- @staticmethod
82- def _tcp_port_is_free (bind_host : str , port : int ) -> bool :
83- """True if nothing is currently bound to (host, port) for TCP."""
84- with socket .socket (socket .AF_INET , socket .SOCK_STREAM ) as sock :
85- sock .setsockopt (socket .SOL_SOCKET , socket .SO_REUSEADDR , 1 )
86- try :
87- sock .bind ((bind_host , port ))
88- except OSError :
89- return False
90- return True
91-
92- @staticmethod
93- def find_first_free_listen_port_after_base (
94- bind_host_for_probe : str ,
95- listen_port_base : int ,
96- max_offset : int = 256 ,
97- blocklist : list [int ] = None ,
98- ) -> int :
99- """
100- First offset where ``listen_port_base + offset`` is TCP-free on ``bind_host_for_probe``
101- (optional: require ``paired_listen_port_base + offset`` free as well, same scan step).
102- Returns ``listen_port``.
103- """
104- for offset_from_base in range (max_offset ):
105- listen_port = listen_port_base + offset_from_base
106- if blocklist and listen_port in blocklist :
107- continue
108- if not ProcessBoundOperatorMixin ._tcp_port_is_free (
109- bind_host_for_probe , listen_port
110- ):
111- continue
112- return listen_port
113- raise commons_errors .DSLInterpreterError (
114- "No free listen port found in the scanned range."
115- )
116-
117114 @staticmethod
118115 def bind_address_for_env_and_probe_hosts (
119116 params : dict ,
0 commit comments