@@ -164,7 +164,9 @@ def func4():
164164The core functionality in this module was ported from :mod:`xdev`.
165165"""
166166import atexit
167+ import multiprocessing
167168import os
169+ import pathlib
168170import sys
169171# This is for compatibility
170172from .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
0 commit comments