Skip to content

Commit 4497ad4

Browse files
authored
gh-135953: Profile a module or script with sampling profiler (#136777)
1 parent 70218b4 commit 4497ad4

File tree

5 files changed

+864
-79
lines changed

5 files changed

+864
-79
lines changed

Lib/profile/_sync_coordinator.py

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
"""
2+
Internal synchronization coordinator for the sample profiler.
3+
4+
This module is used internally by the sample profiler to coordinate
5+
the startup of target processes. It should not be called directly by users.
6+
"""
7+
8+
import os
9+
import sys
10+
import socket
11+
import runpy
12+
import time
13+
from typing import List, NoReturn
14+
15+
16+
class CoordinatorError(Exception):
17+
"""Base exception for coordinator errors."""
18+
pass
19+
20+
21+
class ArgumentError(CoordinatorError):
22+
"""Raised when invalid arguments are provided."""
23+
pass
24+
25+
26+
class SyncError(CoordinatorError):
27+
"""Raised when synchronization with profiler fails."""
28+
pass
29+
30+
31+
class TargetError(CoordinatorError):
32+
"""Raised when target execution fails."""
33+
pass
34+
35+
36+
def _validate_arguments(args: List[str]) -> tuple[int, str, List[str]]:
37+
"""
38+
Validate and parse command line arguments.
39+
40+
Args:
41+
args: Command line arguments including script name
42+
43+
Returns:
44+
Tuple of (sync_port, working_directory, target_args)
45+
46+
Raises:
47+
ArgumentError: If arguments are invalid
48+
"""
49+
if len(args) < 4:
50+
raise ArgumentError(
51+
"Insufficient arguments. Expected: <sync_port> <cwd> <target> [args...]"
52+
)
53+
54+
try:
55+
sync_port = int(args[1])
56+
if not (1 <= sync_port <= 65535):
57+
raise ValueError("Port out of range")
58+
except ValueError as e:
59+
raise ArgumentError(f"Invalid sync port '{args[1]}': {e}") from e
60+
61+
cwd = args[2]
62+
if not os.path.isdir(cwd):
63+
raise ArgumentError(f"Working directory does not exist: {cwd}")
64+
65+
target_args = args[3:]
66+
if not target_args:
67+
raise ArgumentError("No target specified")
68+
69+
return sync_port, cwd, target_args
70+
71+
72+
# Constants for socket communication
73+
_MAX_RETRIES = 3
74+
_INITIAL_RETRY_DELAY = 0.1
75+
_SOCKET_TIMEOUT = 2.0
76+
_READY_MESSAGE = b"ready"
77+
78+
79+
def _signal_readiness(sync_port: int) -> None:
80+
"""
81+
Signal readiness to the profiler via TCP socket.
82+
83+
Args:
84+
sync_port: Port number where profiler is listening
85+
86+
Raises:
87+
SyncError: If unable to signal readiness
88+
"""
89+
last_error = None
90+
91+
for attempt in range(_MAX_RETRIES):
92+
try:
93+
# Use context manager for automatic cleanup
94+
with socket.create_connection(("127.0.0.1", sync_port), timeout=_SOCKET_TIMEOUT) as sock:
95+
sock.send(_READY_MESSAGE)
96+
return
97+
except (socket.error, OSError) as e:
98+
last_error = e
99+
if attempt < _MAX_RETRIES - 1:
100+
# Exponential backoff before retry
101+
time.sleep(_INITIAL_RETRY_DELAY * (2 ** attempt))
102+
103+
# If we get here, all retries failed
104+
raise SyncError(f"Failed to signal readiness after {_MAX_RETRIES} attempts: {last_error}") from last_error
105+
106+
107+
def _setup_environment(cwd: str) -> None:
108+
"""
109+
Set up the execution environment.
110+
111+
Args:
112+
cwd: Working directory to change to
113+
114+
Raises:
115+
TargetError: If unable to set up environment
116+
"""
117+
try:
118+
os.chdir(cwd)
119+
except OSError as e:
120+
raise TargetError(f"Failed to change to directory {cwd}: {e}") from e
121+
122+
# Add current directory to sys.path if not present (for module imports)
123+
if cwd not in sys.path:
124+
sys.path.insert(0, cwd)
125+
126+
127+
def _execute_module(module_name: str, module_args: List[str]) -> None:
128+
"""
129+
Execute a Python module.
130+
131+
Args:
132+
module_name: Name of the module to execute
133+
module_args: Arguments to pass to the module
134+
135+
Raises:
136+
TargetError: If module execution fails
137+
"""
138+
# Replace sys.argv to match how Python normally runs modules
139+
# When running 'python -m module args', sys.argv is ["__main__.py", "args"]
140+
sys.argv = [f"__main__.py"] + module_args
141+
142+
try:
143+
runpy.run_module(module_name, run_name="__main__", alter_sys=True)
144+
except ImportError as e:
145+
raise TargetError(f"Module '{module_name}' not found: {e}") from e
146+
except SystemExit:
147+
# SystemExit is normal for modules
148+
pass
149+
except Exception as e:
150+
raise TargetError(f"Error executing module '{module_name}': {e}") from e
151+
152+
153+
def _execute_script(script_path: str, script_args: List[str], cwd: str) -> None:
154+
"""
155+
Execute a Python script.
156+
157+
Args:
158+
script_path: Path to the script to execute
159+
script_args: Arguments to pass to the script
160+
cwd: Current working directory for path resolution
161+
162+
Raises:
163+
TargetError: If script execution fails
164+
"""
165+
# Make script path absolute if it isn't already
166+
if not os.path.isabs(script_path):
167+
script_path = os.path.join(cwd, script_path)
168+
169+
if not os.path.isfile(script_path):
170+
raise TargetError(f"Script not found: {script_path}")
171+
172+
# Replace sys.argv to match original script call
173+
sys.argv = [script_path] + script_args
174+
175+
try:
176+
with open(script_path, 'rb') as f:
177+
source_code = f.read()
178+
179+
# Compile and execute the script
180+
code = compile(source_code, script_path, 'exec')
181+
exec(code, {'__name__': '__main__', '__file__': script_path})
182+
except FileNotFoundError as e:
183+
raise TargetError(f"Script file not found: {script_path}") from e
184+
except PermissionError as e:
185+
raise TargetError(f"Permission denied reading script: {script_path}") from e
186+
except SyntaxError as e:
187+
raise TargetError(f"Syntax error in script {script_path}: {e}") from e
188+
except SystemExit:
189+
# SystemExit is normal for scripts
190+
pass
191+
except Exception as e:
192+
raise TargetError(f"Error executing script '{script_path}': {e}") from e
193+
194+
195+
def main() -> NoReturn:
196+
"""
197+
Main coordinator function.
198+
199+
This function coordinates the startup of a target Python process
200+
with the sample profiler by signaling when the process is ready
201+
to be profiled.
202+
"""
203+
try:
204+
# Parse and validate arguments
205+
sync_port, cwd, target_args = _validate_arguments(sys.argv)
206+
207+
# Set up execution environment
208+
_setup_environment(cwd)
209+
210+
# Signal readiness to profiler
211+
_signal_readiness(sync_port)
212+
213+
# Execute the target
214+
if target_args[0] == "-m":
215+
# Module execution
216+
if len(target_args) < 2:
217+
raise ArgumentError("Module name required after -m")
218+
219+
module_name = target_args[1]
220+
module_args = target_args[2:]
221+
_execute_module(module_name, module_args)
222+
else:
223+
# Script execution
224+
script_path = target_args[0]
225+
script_args = target_args[1:]
226+
_execute_script(script_path, script_args, cwd)
227+
228+
except CoordinatorError as e:
229+
print(f"Profiler coordinator error: {e}", file=sys.stderr)
230+
sys.exit(1)
231+
except KeyboardInterrupt:
232+
print("Interrupted", file=sys.stderr)
233+
sys.exit(1)
234+
except Exception as e:
235+
print(f"Unexpected error in profiler coordinator: {e}", file=sys.stderr)
236+
sys.exit(1)
237+
238+
# Normal exit
239+
sys.exit(0)
240+
241+
242+
if __name__ == "__main__":
243+
main()

0 commit comments

Comments
 (0)