| """ | |
| Child process monitoring for the sampling profiler. | |
| This module monitors a target process for child process creation and spawns | |
| separate profiler instances for each discovered child. | |
| """ | |
| import subprocess | |
| import sys | |
| import threading | |
| import time | |
| import _remote_debugging | |
| # Polling interval for child process discovery | |
| _CHILD_POLL_INTERVAL_SEC = 0.1 | |
| # Default timeout for waiting on child profilers | |
| _DEFAULT_WAIT_TIMEOUT_SEC = 30.0 | |
| # Maximum number of child profilers to spawn (prevents resource exhaustion) | |
| _MAX_CHILD_PROFILERS = 100 | |
| # Interval for cleaning up completed profilers (in polling cycles) | |
| _CLEANUP_INTERVAL_CYCLES = 10 | |
| def get_child_pids(pid, recursive=True): | |
| """ | |
| Get all child process IDs of the given process. | |
| Args: | |
| pid: Process ID of the parent process | |
| recursive: If True, return all descendants (children, grandchildren, etc.) | |
| Returns: | |
| List of child PIDs | |
| """ | |
| return _remote_debugging.get_child_pids(pid, recursive=recursive) | |
| def is_python_process(pid): | |
| """ | |
| Check if a process is a Python process. | |
| Args: | |
| pid: Process ID to check | |
| Returns: | |
| bool: True if the process appears to be a Python process, False otherwise | |
| """ | |
| return _remote_debugging.is_python_process(pid) | |
| class ChildProcessMonitor: | |
| """ | |
| Monitors a target process for child processes and spawns profilers for them. | |
| Use as a context manager: | |
| with ChildProcessMonitor(pid, cli_args, output_pattern) as monitor: | |
| # monitoring runs here | |
| monitor.wait_for_profilers() # optional: wait before cleanup | |
| # cleanup happens automatically | |
| """ | |
| def __init__(self, pid, cli_args, output_pattern): | |
| """ | |
| Initialize the child process monitor. | |
| Args: | |
| pid: Parent process ID to monitor | |
| cli_args: CLI arguments to pass to child profilers | |
| output_pattern: Pattern for output files (format string with {pid}) | |
| """ | |
| self.parent_pid = pid | |
| self.cli_args = cli_args | |
| self.output_pattern = output_pattern | |
| self._known_children = set() | |
| self._spawned_profilers = [] | |
| self._lock = threading.Lock() | |
| self._stop_event = threading.Event() | |
| self._monitor_thread = None | |
| self._poll_count = 0 | |
| def __enter__(self): | |
| self._monitor_thread = threading.Thread( | |
| target=self._monitor_loop, | |
| daemon=True, | |
| name=f"child-monitor-{self.parent_pid}", | |
| ) | |
| self._monitor_thread.start() | |
| return self | |
| def __exit__(self, exc_type, exc_val, exc_tb): | |
| self._stop_event.set() | |
| if self._monitor_thread is not None: | |
| self._monitor_thread.join(timeout=2.0) | |
| if self._monitor_thread.is_alive(): | |
| print( | |
| "Warning: Monitor thread did not stop cleanly", | |
| file=sys.stderr, | |
| ) | |
| # Wait for child profilers to complete naturally | |
| self.wait_for_profilers() | |
| # Terminate any remaining profilers | |
| with self._lock: | |
| profilers_to_cleanup = list(self._spawned_profilers) | |
| self._spawned_profilers.clear() | |
| for proc in profilers_to_cleanup: | |
| self._cleanup_process(proc) | |
| return False | |
| def _cleanup_process(self, proc, terminate_timeout=2.0, kill_timeout=1.0): | |
| if proc.poll() is not None: | |
| return # Already terminated | |
| proc.terminate() | |
| try: | |
| proc.wait(timeout=terminate_timeout) | |
| except subprocess.TimeoutExpired: | |
| proc.kill() | |
| try: | |
| proc.wait(timeout=kill_timeout) | |
| except subprocess.TimeoutExpired: | |
| # Last resort: wait indefinitely to avoid zombie | |
| # SIGKILL should always work, but we must reap the process | |
| try: | |
| proc.wait() | |
| except Exception: | |
| pass | |
| @property | |
| def spawned_profilers(self): | |
| with self._lock: | |
| return list(self._spawned_profilers) | |
| def wait_for_profilers(self, timeout=_DEFAULT_WAIT_TIMEOUT_SEC): | |
| """ | |
| Wait for all spawned child profilers to complete. | |
| Call this before exiting the context if you want profilers to finish | |
| their work naturally rather than being terminated. | |
| Args: | |
| timeout: Maximum time to wait in seconds | |
| """ | |
| profilers = self.spawned_profilers | |
| if not profilers: | |
| return | |
| print( | |
| f"Waiting for {len(profilers)} child profiler(s) to complete...", | |
| file=sys.stderr, | |
| ) | |
| deadline = time.monotonic() + timeout | |
| for proc in profilers: | |
| remaining = deadline - time.monotonic() | |
| if remaining <= 0: | |
| break | |
| try: | |
| proc.wait(timeout=max(0.1, remaining)) | |
| except subprocess.TimeoutExpired: | |
| pass | |
| def _monitor_loop(self): | |
| # Note: There is an inherent TOCTOU race between discovering a child | |
| # process and checking if it's Python. This is expected for process monitoring. | |
| while not self._stop_event.is_set(): | |
| try: | |
| self._poll_count += 1 | |
| # Periodically clean up completed profilers to avoid memory buildup | |
| if self._poll_count % _CLEANUP_INTERVAL_CYCLES == 0: | |
| self._cleanup_completed_profilers() | |
| children = set(get_child_pids(self.parent_pid, recursive=True)) | |
| with self._lock: | |
| new_children = children - self._known_children | |
| self._known_children.update(new_children) | |
| for child_pid in new_children: | |
| # Only spawn profiler if this is actually a Python process | |
| if is_python_process(child_pid): | |
| self._spawn_profiler_for_child(child_pid) | |
| except ProcessLookupError: | |
| # Parent process exited, stop monitoring | |
| break | |
| except Exception as e: | |
| # Log error but continue monitoring | |
| print( | |
| f"Warning: Error in child monitor loop: {e}", | |
| file=sys.stderr, | |
| ) | |
| self._stop_event.wait(timeout=_CHILD_POLL_INTERVAL_SEC) | |
| def _cleanup_completed_profilers(self): | |
| with self._lock: | |
| # Keep only profilers that are still running | |
| self._spawned_profilers = [ | |
| p for p in self._spawned_profilers if p.poll() is None | |
| ] | |
| def _spawn_profiler_for_child(self, child_pid): | |
| if self._stop_event.is_set(): | |
| return | |
| # Check if we've reached the maximum number of child profilers | |
| with self._lock: | |
| if len(self._spawned_profilers) >= _MAX_CHILD_PROFILERS: | |
| print( | |
| f"Warning: Max child profilers ({_MAX_CHILD_PROFILERS}) reached, " | |
| f"skipping PID {child_pid}", | |
| file=sys.stderr, | |
| ) | |
| return | |
| cmd = [ | |
| sys.executable, | |
| "-m", | |
| "profiling.sampling", | |
| "attach", | |
| str(child_pid), | |
| ] | |
| cmd.extend(self._build_child_cli_args(child_pid)) | |
| proc = None | |
| try: | |
| proc = subprocess.Popen( | |
| cmd, | |
| stdin=subprocess.DEVNULL, | |
| stdout=subprocess.DEVNULL, | |
| stderr=subprocess.DEVNULL, | |
| ) | |
| with self._lock: | |
| if self._stop_event.is_set(): | |
| self._cleanup_process( | |
| proc, terminate_timeout=1.0, kill_timeout=1.0 | |
| ) | |
| return | |
| self._spawned_profilers.append(proc) | |
| print( | |
| f"Started profiler for child process {child_pid}", | |
| file=sys.stderr, | |
| ) | |
| except Exception as e: | |
| if proc is not None: | |
| self._cleanup_process( | |
| proc, terminate_timeout=1.0, kill_timeout=1.0 | |
| ) | |
| print( | |
| f"Warning: Failed to start profiler for child {child_pid}: {e}", | |
| file=sys.stderr, | |
| ) | |
| def _build_child_cli_args(self, child_pid): | |
| args = list(self.cli_args) | |
| if self.output_pattern: | |
| # Use replace() instead of format() to handle user filenames with braces | |
| output_file = self.output_pattern.replace("{pid}", str(child_pid)) | |
| found_output = False | |
| for i, arg in enumerate(args): | |
| if arg in ("-o", "--output") and i + 1 < len(args): | |
| args[i + 1] = output_file | |
| found_output = True | |
| break | |
| if not found_output: | |
| args.extend(["-o", output_file]) | |
| return args |