Skip to content

Commit 3d0f981

Browse files
authored
Register explicit profiler atexit hook only in main process (fix multiprocessing / Py3.14 regression) (#5)
* Fix explicit profiler ownership for multiprocessing * Avoid helper process atexit registration * Skip atexit output in forked children * Refine ownership checks for explicit profiler * Add debug hooks and reduce CI matrix * Skip orphaned forkserver output * Restore full CI matrix
1 parent 20d8b29 commit 3d0f981

3 files changed

Lines changed: 227 additions & 20 deletions

File tree

line_profiler/explicit_profiler.py

Lines changed: 144 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,9 @@ def func4():
164164
The core functionality in this module was ported from :mod:`xdev`.
165165
"""
166166
import atexit
167+
import multiprocessing
167168
import os
169+
import pathlib
168170
import sys
169171
# This is for compatibility
170172
from .cli_utils import boolean, get_python_executable as _python_command
@@ -173,8 +175,8 @@ def func4():
173175

174176
# The first process that enables profiling records its PID here. Child processes
175177
# created via multiprocessing (spawn/forkserver) inherit this environment value,
176-
# allowing them to avoid registering duplicate atexit hooks (which can print
177-
# output after the parent exits and/or clobber output files).
178+
# which helps prevent helper processes from claiming ownership and clobbering
179+
# output. Standalone subprocess runs should always be able to reset this value.
178180
_OWNER_PID_ENVVAR = 'LINE_PROFILER_OWNER_PID'
179181

180182

@@ -270,9 +272,8 @@ def __init__(self, config=None):
270272
self._config = config_source.path
271273

272274
self._profile = None
273-
self.enabled = None
274275
self._owner_pid = None
275-
276+
self.enabled = None
276277
# Configs:
277278
# - How to toggle the profiler
278279
self.setup_config = config_source.conf_dict['setup']
@@ -317,27 +318,28 @@ def enable(self, output_prefix=None):
317318
"""
318319
Explicitly enables global profiler and controls its settings.
319320
"""
321+
self._debug('enable:enter')
320322
# When using multiprocessing start methods like 'spawn'/'forkserver',
321-
# helper processes may import this module. We only register the atexit
322-
# reporting hook (and enable profiling) in the first process that
323-
# called enable(), to prevent duplicate/out-of-order output.
324-
owner = os.environ.get(_OWNER_PID_ENVVAR)
325-
if owner is None:
326-
owner_pid = os.getpid()
327-
os.environ[_OWNER_PID_ENVVAR] = str(owner_pid)
328-
else:
329-
try:
330-
owner_pid = int(owner)
331-
except Exception:
332-
owner_pid = os.getpid()
333-
os.environ[_OWNER_PID_ENVVAR] = str(owner_pid)
334-
self._owner_pid = owner_pid
323+
# helper processes may import this module. Only register the atexit
324+
# reporting hook (and enable profiling) in real script invocations to
325+
# prevent duplicate/out-of-order output.
326+
if self._is_helper_process_context():
327+
self._debug('enable:helper-context')
328+
self.enabled = False
329+
return
335330

336-
# Only enable + register atexit in the owner process.
337-
if os.getpid() != owner_pid:
331+
if self._should_skip_due_to_owner():
332+
self._debug('enable:skip-due-to-owner')
338333
self.enabled = False
339334
return
340335

336+
# Standalone script executions should always claim ownership, even if a
337+
# PID marker was inherited from another process environment.
338+
owner_pid = os.getpid()
339+
os.environ[_OWNER_PID_ENVVAR] = str(owner_pid)
340+
self._owner_pid = owner_pid
341+
self._debug('enable:owner-claimed', owner_pid=owner_pid)
342+
341343
if self._profile is None:
342344
# Try to only ever create one real LineProfiler object
343345
atexit.register(self.show)
@@ -350,6 +352,120 @@ def enable(self, output_prefix=None):
350352
if output_prefix is not None:
351353
self.output_prefix = output_prefix
352354

355+
def _is_helper_process_context(self):
356+
"""
357+
Determine if this process looks like a multiprocessing helper.
358+
359+
Helper contexts should never register atexit hooks or claim ownership,
360+
while real script invocations should always be allowed to do so.
361+
"""
362+
argv0 = sys.argv[0] if sys.argv else ''
363+
if self._has_forkserver_env():
364+
self._debug('helper:forkserver-env', argv0=argv0)
365+
return True
366+
try:
367+
import multiprocessing.spawn as mp_spawn
368+
if getattr(mp_spawn, '_inheriting', False):
369+
self._debug('helper:spawn-inheriting', argv0=argv0)
370+
return True
371+
except Exception:
372+
pass
373+
try:
374+
if multiprocessing.current_process().name != 'MainProcess':
375+
self._debug(
376+
'helper:non-main-process',
377+
process_name=multiprocessing.current_process().name,
378+
argv0=argv0,
379+
)
380+
return True
381+
except Exception:
382+
pass
383+
384+
main_mod = sys.modules.get('__main__')
385+
main_file = getattr(main_mod, '__file__', None)
386+
for candidate in (argv0, main_file):
387+
if candidate:
388+
try:
389+
if pathlib.Path(candidate).exists():
390+
self._debug('helper:script-detected', candidate=candidate)
391+
return False
392+
except Exception:
393+
continue
394+
395+
self._debug('helper:no-script-detected', argv0=argv0, main_file=main_file)
396+
return True
397+
398+
def _should_skip_due_to_owner(self):
399+
"""
400+
In multiprocessing children, respect an inherited owner marker.
401+
402+
Standalone subprocesses (parent_process is None) should reset ownership,
403+
but fork/spawn children should not clobber a parent owner's outputs.
404+
"""
405+
try:
406+
if multiprocessing.parent_process() is None:
407+
self._debug('owner:no-parent', owner=os.environ.get(_OWNER_PID_ENVVAR))
408+
return False
409+
except Exception:
410+
return False
411+
412+
owner = os.environ.get(_OWNER_PID_ENVVAR)
413+
if owner is None:
414+
return False
415+
416+
try:
417+
owner_pid = int(owner)
418+
if os.getppid() == 1 and owner_pid != os.getpid():
419+
self._debug('owner:skip-orphan', owner=owner, ppid=os.getppid())
420+
return True
421+
if os.getppid() == owner_pid and owner_pid != os.getpid():
422+
try:
423+
start_method = multiprocessing.get_start_method(allow_none=True)
424+
except Exception:
425+
start_method = None
426+
if start_method == 'forkserver':
427+
self._debug(
428+
'owner:skip-forkserver-child',
429+
owner=owner,
430+
ppid=os.getppid(),
431+
start_method=start_method,
432+
)
433+
return True
434+
skip = owner_pid != os.getpid()
435+
self._debug('owner:check', owner=owner, skip=skip)
436+
return skip
437+
except Exception:
438+
return False
439+
440+
def _has_forkserver_env(self):
441+
for key in os.environ:
442+
if key.startswith('FORKSERVER_'):
443+
return True
444+
if key.startswith('MULTIPROCESSING_FORKSERVER'):
445+
return True
446+
return False
447+
448+
def _debug(self, message, **extra):
449+
if not os.environ.get('LINE_PROFILER_DEBUG'):
450+
return
451+
try:
452+
parent = multiprocessing.parent_process()
453+
parent_pid = parent.pid if parent is not None else None
454+
except Exception:
455+
parent_pid = None
456+
info = {
457+
'pid': os.getpid(),
458+
'ppid': os.getppid(),
459+
'process': getattr(multiprocessing.current_process(), 'name', None),
460+
'parent_pid': parent_pid,
461+
'owner_env': os.environ.get(_OWNER_PID_ENVVAR),
462+
'owner_pid': self._owner_pid,
463+
'enabled': self.enabled,
464+
}
465+
info.update(extra)
466+
payload = ' '.join(f'{k}={v!r}' for k, v in info.items())
467+
print(f'[line_profiler debug] {message} {payload}')
468+
353469
def disable(self):
354470
"""
355471
Explicitly initialize and disable this global profiler.
@@ -386,6 +502,14 @@ def show(self):
386502
If the implicit setup triggered, then this will be called by
387503
:py:mod:`atexit`.
388504
"""
505+
self._debug('show:enter')
506+
owner_env = os.environ.get(_OWNER_PID_ENVVAR)
507+
if os.getppid() == 1 and owner_env == str(os.getpid()):
508+
self._debug('show:skip-orphan-owner', owner_env=owner_env)
509+
return
510+
if self._owner_pid is not None and os.getpid() != self._owner_pid:
511+
self._debug('show:skip-non-owner', current_pid=os.getpid())
512+
return
389513
import io
390514
import pathlib
391515

tests/test_complex_case.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ def test_varied_complex_invocations():
9595
temp_dpath = stack.enter_context(tempfile.TemporaryDirectory())
9696
stack.enter_context(ub.ChDir(temp_dpath))
9797
env = {}
98+
env['LINE_PROFILER_DEBUG'] = '1'
9899

99100
outpath = case['outpath']
100101
if outpath:

tests/test_explicit_profile.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,88 @@ def test_explicit_profile_with_environ_on():
141141
assert (temp_dpath / 'profile_output.lprof').exists()
142142

143143

144+
def test_explicit_profile_ignores_inherited_owner_marker():
145+
"""
146+
Standalone runs should not be blocked by an inherited owner marker.
147+
"""
148+
with tempfile.TemporaryDirectory() as tmp:
149+
temp_dpath = ub.Path(tmp)
150+
env = os.environ.copy()
151+
env['LINE_PROFILE'] = '1'
152+
env['LINE_PROFILER_OWNER_PID'] = str(os.getpid() + 100000)
153+
env['PYTHONPATH'] = os.getcwd()
154+
155+
with ub.ChDir(temp_dpath):
156+
157+
script_fpath = ub.Path('script.py')
158+
script_fpath.write_text(_demo_explicit_profile_script())
159+
160+
args = [sys.executable, os.fspath(script_fpath)]
161+
proc = ub.cmd(args, env=env)
162+
print(proc.stdout)
163+
print(proc.stderr)
164+
proc.check_returncode()
165+
166+
assert (temp_dpath / 'profile_output.txt').exists()
167+
assert (temp_dpath / 'profile_output.lprof').exists()
168+
169+
170+
def test_explicit_profile_process_pool_forkserver():
171+
"""
172+
Ensure explicit profiler works with forkserver ProcessPoolExecutor.
173+
"""
174+
import multiprocessing as mp
175+
if 'forkserver' not in mp.get_all_start_methods():
176+
pytest.skip('forkserver start method not available')
177+
with tempfile.TemporaryDirectory() as tmp:
178+
temp_dpath = ub.Path(tmp)
179+
env = os.environ.copy()
180+
env['LINE_PROFILE'] = '1'
181+
env['LINE_PROFILER_DEBUG'] = '1'
182+
env['PYTHONPATH'] = os.getcwd()
183+
184+
with ub.ChDir(temp_dpath):
185+
186+
script_fpath = ub.Path('script.py')
187+
script_fpath.write_text(ub.codeblock(
188+
'''
189+
import multiprocessing as mp
190+
from concurrent.futures import ProcessPoolExecutor
191+
from line_profiler import profile
192+
193+
def worker(x):
194+
return x * x
195+
196+
@profile
197+
def run():
198+
total = 0
199+
for i in range(1000):
200+
total += i % 7
201+
with ProcessPoolExecutor(max_workers=2) as ex:
202+
list(ex.map(worker, range(4)))
203+
return total
204+
205+
def main():
206+
if 'forkserver' in mp.get_all_start_methods():
207+
mp.set_start_method('forkserver', force=True)
208+
run()
209+
210+
if __name__ == '__main__':
211+
main()
212+
''').strip())
213+
214+
args = [sys.executable, os.fspath(script_fpath)]
215+
proc = ub.cmd(args, env=env)
216+
print(proc.stdout)
217+
print(proc.stderr)
218+
proc.check_returncode()
219+
220+
output_path = temp_dpath / 'profile_output.txt'
221+
assert output_path.exists()
222+
assert output_path.stat().st_size > 100
223+
assert proc.stdout.count('Wrote profile results to profile_output.txt') == 1
224+
225+
144226
def test_explicit_profile_with_environ_off():
145227
"""
146228
When LINE_PROFILE is falsy, profiling should not run.

0 commit comments

Comments
 (0)