Skip to content

Commit 6647f4a

Browse files
committed
fixup! Merge pull request #117 from StanFromIreland/tachyon-subprocesses-atomic-speed
1 parent 173422f commit 6647f4a

File tree

4 files changed

+43
-38
lines changed

4 files changed

+43
-38
lines changed

Doc/library/profiling.sampling.rst

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -483,7 +483,7 @@ or other process spawning mechanisms.
483483
python -m profiling.sampling run --subprocesses --flamegraph worker_pool.py
484484

485485
This produces separate flame graphs for the main process and each worker
486-
process: ``flamegraph.<main_pid>.html``, ``flamegraph.<worker1_pid>.html``,
486+
process: ``flamegraph_<main_pid>.html``, ``flamegraph_<worker1_pid>.html``,
487487
and so on.
488488

489489
Each subprocess receives its own output file. The filename is derived from
@@ -492,21 +492,20 @@ appended:
492492

493493
- If you specify ``-o profile.html``, subprocesses produce ``profile_12345.html``,
494494
``profile_12346.html``, and so on
495-
- With default output, subprocesses produce files like ``flamegraph.12345.html``
495+
- With default output, subprocesses produce files like ``flamegraph_12345.html``
496496
or directories like ``heatmap_12345``
497497
- For pstats format (which defaults to stdout), subprocesses produce files like
498-
``profile.12345.pstats``
498+
``profile_12345.pstats``
499499

500500
The subprocess profilers inherit most sampling options from the parent (interval,
501501
duration, thread selection, native frames, GC frames, async-aware mode, and
502502
output format). All Python descendant processes are profiled recursively,
503503
including grandchildren and further descendants.
504504

505505
Subprocess detection works by periodically scanning for new descendants of
506-
the target process and checking whether each new process is a Python process.
507-
On Linux, this uses a fast check of the executable name followed by a full
508-
probe of the process memory if needed. Non-Python subprocesses (such as
509-
shell commands or external tools) are ignored.
506+
the target process and checking whether each new process is a Python process
507+
by probing the process memory for Python runtime structures. Non-Python
508+
subprocesses (such as shell commands or external tools) are ignored.
510509

511510
There is a limit of 100 concurrent subprocess profilers to prevent resource
512511
exhaustion in programs that spawn many processes. If this limit is reached,

Lib/profiling/sampling/_child_monitor.py

Lines changed: 4 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,8 @@
55
separate profiler instances for each discovered child.
66
"""
77

8-
import os
9-
import sys
108
import subprocess
9+
import sys
1110
import threading
1211
import time
1312

@@ -42,30 +41,14 @@ def get_child_pids(pid, recursive=True):
4241

4342
def is_python_process(pid):
4443
"""
45-
Quickly check if a process is a Python process.
46-
47-
This performs a two-stage check:
48-
1. Fast path: Check /proc/{pid}/exe symlink for 'python' (Linux only)
49-
2. Full probe: Attempt to locate Python runtime structures in memory
44+
Check if a process is a Python process.
5045
5146
Args:
5247
pid: Process ID to check
5348
5449
Returns:
5550
bool: True if the process appears to be a Python process, False otherwise
5651
"""
57-
# Fast path: Check executable name on Linux (much faster than full probe)
58-
if sys.platform == "linux":
59-
try:
60-
exe_path = os.readlink(f"/proc/{pid}/exe")
61-
# Check if executable name contains 'python'
62-
exe_name = os.path.basename(exe_path).lower()
63-
if "python" not in exe_name:
64-
return False
65-
except (OSError, PermissionError):
66-
# Can't read exe link, fall through to full probe
67-
pass
68-
6952
return _remote_debugging.is_python_process(pid)
7053

7154

@@ -282,7 +265,8 @@ def _build_child_cli_args(self, child_pid):
282265
args = list(self.cli_args)
283266

284267
if self.output_pattern:
285-
output_file = self.output_pattern.format(pid=child_pid)
268+
# Use replace() instead of format() to handle user filenames with braces
269+
output_file = self.output_pattern.replace("{pid}", str(child_pid))
286270
found_output = False
287271
for i, arg in enumerate(args):
288272
if arg in ("-o", "--output") and i + 1 < len(args):

Lib/profiling/sampling/cli.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,11 @@ def _build_child_profiler_args(args):
144144

145145

146146
def _build_output_pattern(args):
147+
"""Build output filename pattern for child profilers.
148+
149+
The pattern uses {pid} as a placeholder which will be replaced with the
150+
actual child PID using str.replace(), so user filenames with braces are safe.
151+
"""
147152
if args.outfile:
148153
# User specified output - add PID to filename
149154
base, ext = os.path.splitext(args.outfile)
@@ -152,14 +157,14 @@ def _build_output_pattern(args):
152157
else:
153158
return f"{args.outfile}_{{pid}}"
154159
else:
155-
# Use default pattern based on format
160+
# Use default pattern based on format (consistent _ separator)
156161
extension = FORMAT_EXTENSIONS.get(args.format, "txt")
157162
if args.format == "heatmap":
158163
return "heatmap_{pid}"
159164
if args.format == "pstats":
160165
# pstats defaults to stdout, but for subprocesses we need files
161-
return "profile.{pid}.pstats"
162-
return f"{args.format}.{{pid}}.{extension}"
166+
return "profile_{pid}.pstats"
167+
return f"{args.format}_{{pid}}.{extension}"
163168

164169

165170
def _parse_mode(mode_string):

Lib/test/test_profiling/test_sampling_profiler/test_children.py

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,18 @@
2424
_SKIP_PERMISSION_MSG = "Insufficient permissions for remote profiling"
2525

2626

27+
def _check_remote_debugging_permissions():
28+
"""Check if we have permissions for remote debugging.
29+
30+
Returns True if we have permissions, False if we don't.
31+
On macOS without proper entitlements, this will return False.
32+
"""
33+
# If is_python_process returns False for the current process,
34+
# we don't have permissions (since we ARE a Python process)
35+
import _remote_debugging
36+
return _remote_debugging.is_python_process(os.getpid())
37+
38+
2739
def _readline_with_timeout(file_obj, timeout):
2840
# Thread-based readline with timeout - works across all platforms
2941
# including Windows where select() doesn't work with pipes.
@@ -558,16 +570,23 @@ def test_is_python_process_current_process(self):
558570
"""Test that current process is detected as Python."""
559571
from profiling.sampling._child_monitor import is_python_process
560572

573+
if not _check_remote_debugging_permissions():
574+
self.skipTest(_SKIP_PERMISSION_MSG)
575+
561576
# Current process should be Python
577+
result = is_python_process(os.getpid())
562578
self.assertTrue(
563-
is_python_process(os.getpid()),
579+
result,
564580
f"Current process (PID {os.getpid()}) should be detected as Python",
565581
)
566582

567583
def test_is_python_process_python_subprocess(self):
568584
"""Test that a Python subprocess is detected as Python."""
569585
from profiling.sampling._child_monitor import is_python_process
570586

587+
if not _check_remote_debugging_permissions():
588+
self.skipTest(_SKIP_PERMISSION_MSG)
589+
571590
# Start a Python subprocess
572591
proc = subprocess.Popen(
573592
[sys.executable, "-c", "import time; time.sleep(10)"],
@@ -584,12 +603,9 @@ def test_is_python_process_python_subprocess(self):
584603
while time.time() < deadline:
585604
if proc.poll() is not None:
586605
self.fail(f"Process {proc.pid} exited unexpectedly")
587-
try:
588-
if is_python_process(proc.pid):
589-
detected = True
590-
break
591-
except PermissionError:
592-
self.skipTest(_SKIP_PERMISSION_MSG)
606+
if is_python_process(proc.pid):
607+
detected = True
608+
break
593609
time.sleep(0.05)
594610

595611
self.assertTrue(
@@ -632,8 +648,9 @@ def test_is_python_process_nonexistent_pid(self):
632648
from profiling.sampling._child_monitor import is_python_process
633649

634650
# Use a very high PID that's unlikely to exist
651+
result = is_python_process(999999999)
635652
self.assertFalse(
636-
is_python_process(999999999),
653+
result,
637654
"Nonexistent PID 999999999 should return False",
638655
)
639656

0 commit comments

Comments
 (0)