@@ -6182,8 +6182,9 @@ def test_resource_tracker_sigkill(self):
61826182 def _is_resource_tracker_reused (conn , pid ):
61836183 from multiprocessing .resource_tracker import _resource_tracker
61846184 _resource_tracker .ensure_running ()
6185- # The pid should be None in the child process, expect for the fork
6186- # context. It should not be a new value.
6185+ # The pid should be None in the child (the at-fork handler clears
6186+ # it for fork; spawn/forkserver children never had it set). It
6187+ # should not be a new value.
61876188 reused = _resource_tracker ._pid in (None , pid )
61886189 reused &= _resource_tracker ._check_alive ()
61896190 conn .send (reused )
@@ -6268,6 +6269,183 @@ def test_resource_tracker_blocked_signals(self):
62686269 # restore sigmask to what it was before executing test
62696270 signal .pthread_sigmask (signal .SIG_SETMASK , orig_sigmask )
62706271
6272+ @only_run_in_forkserver_testsuite ("avoids redundant testing." )
6273+ def test_resource_tracker_fork_deadlock (self ):
6274+ # gh-146313: ResourceTracker.__del__ used to deadlock if a forked
6275+ # child still held the pipe's write end open when the parent
6276+ # exited, because the parent would block in waitpid() waiting for
6277+ # the tracker to exit, but the tracker would never see EOF.
6278+ cmd = '''if 1:
6279+ import os, signal
6280+ from multiprocessing.resource_tracker import ensure_running
6281+ ensure_running()
6282+ if os.fork() == 0:
6283+ signal.pause()
6284+ os._exit(0)
6285+ # parent falls through and exits, triggering __del__
6286+ '''
6287+ proc = subprocess .Popen ([sys .executable , '-c' , cmd ],
6288+ start_new_session = True )
6289+ try :
6290+ try :
6291+ proc .wait (timeout = support .SHORT_TIMEOUT )
6292+ except subprocess .TimeoutExpired :
6293+ self .fail (
6294+ "Parent process deadlocked in ResourceTracker.__del__"
6295+ )
6296+ self .assertEqual (proc .returncode , 0 )
6297+ finally :
6298+ try :
6299+ os .killpg (proc .pid , signal .SIGKILL )
6300+ except ProcessLookupError :
6301+ pass
6302+ proc .wait ()
6303+
6304+ @only_run_in_forkserver_testsuite ("avoids redundant testing." )
6305+ def test_resource_tracker_mp_fork_reuse_and_prompt_reap (self ):
6306+ # gh-146313 / gh-80849: A child started via multiprocessing.Process
6307+ # with the 'fork' start method should reuse the parent's resource
6308+ # tracker (the at-fork handler preserves the inherited pipe fd),
6309+ # *and* the parent should be able to reap the tracker promptly
6310+ # after joining the child, without hitting the waitpid timeout.
6311+ cmd = textwrap .dedent ('''
6312+ import multiprocessing as mp
6313+ from multiprocessing.resource_tracker import _resource_tracker
6314+
6315+ def child(conn):
6316+ # Prove we can talk to the parent's tracker by registering
6317+ # and unregistering a dummy resource over the inherited fd.
6318+ # If the fd were closed, ensure_running would launch a new
6319+ # tracker and _pid would be non-None.
6320+ _resource_tracker.register("x", "dummy")
6321+ _resource_tracker.unregister("x", "dummy")
6322+ conn.send((_resource_tracker._fd is not None,
6323+ _resource_tracker._pid is None,
6324+ _resource_tracker._check_alive()))
6325+
6326+ if __name__ == "__main__":
6327+ mp.set_start_method("fork")
6328+ _resource_tracker.ensure_running()
6329+ r, w = mp.Pipe(duplex=False)
6330+ p = mp.Process(target=child, args=(w,))
6331+ p.start()
6332+ child_has_fd, child_pid_none, child_alive = r.recv()
6333+ p.join()
6334+ w.close(); r.close()
6335+
6336+ # Now simulate __del__: the child has exited and released
6337+ # its fd copy, so the tracker should see EOF and exit
6338+ # promptly -- no timeout.
6339+ _resource_tracker._stop(wait_timeout=5.0)
6340+ print(child_has_fd, child_pid_none, child_alive,
6341+ _resource_tracker._waitpid_timed_out,
6342+ _resource_tracker._exitcode)
6343+ ''' )
6344+ rc , out , err = script_helper .assert_python_ok ('-c' , cmd )
6345+ parts = out .decode ().split ()
6346+ self .assertEqual (parts , ['True' , 'True' , 'True' , 'False' , '0' ],
6347+ f"unexpected: { parts !r} stderr={ err !r} " )
6348+
6349+ @only_run_in_forkserver_testsuite ("avoids redundant testing." )
6350+ def test_resource_tracker_raw_fork_prompt_reap (self ):
6351+ # gh-146313: After a raw os.fork() the at-fork handler closes the
6352+ # child's inherited fd, so the parent can reap the tracker
6353+ # immediately -- even while the child is still alive -- rather
6354+ # than waiting out the 1s timeout.
6355+ cmd = textwrap .dedent ('''
6356+ import os, signal
6357+ from multiprocessing.resource_tracker import _resource_tracker
6358+
6359+ _resource_tracker.ensure_running()
6360+ r, w = os.pipe()
6361+ pid = os.fork()
6362+ if pid == 0:
6363+ os.close(r)
6364+ # Report whether our fd was closed by the at-fork handler.
6365+ os.write(w, b"1" if _resource_tracker._fd is None else b"0")
6366+ os.close(w)
6367+ signal.pause() # stay alive so parent's reap is meaningful
6368+ os._exit(0)
6369+ os.close(w)
6370+ child_fd_closed = os.read(r, 1) == b"1"
6371+ os.close(r)
6372+
6373+ # Child is still alive and paused. Because it closed its fd
6374+ # copy, our close below is the last one and the tracker exits.
6375+ _resource_tracker._stop(wait_timeout=5.0)
6376+
6377+ os.kill(pid, signal.SIGKILL)
6378+ os.waitpid(pid, 0)
6379+ print(child_fd_closed,
6380+ _resource_tracker._waitpid_timed_out,
6381+ _resource_tracker._exitcode)
6382+ ''' )
6383+ rc , out , err = script_helper .assert_python_ok ('-c' , cmd )
6384+ parts = out .decode ().split ()
6385+ self .assertEqual (parts , ['True' , 'False' , '0' ],
6386+ f"unexpected: { parts !r} stderr={ err !r} " )
6387+
6388+ @only_run_in_forkserver_testsuite ("avoids redundant testing." )
6389+ def test_resource_tracker_lock_reinit_after_fork (self ):
6390+ # gh-146313: If a parent thread held the tracker's lock at fork
6391+ # time, the child would inherit the held lock and deadlock on
6392+ # its next ensure_running(). The at-fork handler reinits it.
6393+ cmd = textwrap .dedent ('''
6394+ import os, threading
6395+ from multiprocessing.resource_tracker import _resource_tracker
6396+
6397+ held = threading.Event()
6398+ release = threading.Event()
6399+ def hold():
6400+ with _resource_tracker._lock:
6401+ held.set()
6402+ release.wait()
6403+ t = threading.Thread(target=hold)
6404+ t.start()
6405+ held.wait()
6406+
6407+ pid = os.fork()
6408+ if pid == 0:
6409+ ok = _resource_tracker._lock.acquire(timeout=5.0)
6410+ os._exit(0 if ok else 1)
6411+
6412+ release.set()
6413+ t.join()
6414+ _, status = os.waitpid(pid, 0)
6415+ print(os.waitstatus_to_exitcode(status))
6416+ ''' )
6417+ rc , out , err = script_helper .assert_python_ok (
6418+ '-W' , 'ignore::DeprecationWarning' , '-c' , cmd )
6419+ self .assertEqual (out .strip (), b'0' ,
6420+ f"child failed to acquire lock: stderr={ err !r} " )
6421+
6422+ @only_run_in_forkserver_testsuite ("avoids redundant testing." )
6423+ def test_resource_tracker_safety_net_timeout (self ):
6424+ # gh-146313: When an mp.Process(fork) child holds the preserved
6425+ # fd and the parent calls _stop() without joining (simulating
6426+ # abnormal shutdown), the safety-net timeout should fire rather
6427+ # than deadlocking.
6428+ cmd = textwrap .dedent ('''
6429+ import multiprocessing as mp
6430+ import signal
6431+ from multiprocessing.resource_tracker import _resource_tracker
6432+
6433+ if __name__ == "__main__":
6434+ mp.set_start_method("fork")
6435+ _resource_tracker.ensure_running()
6436+ p = mp.Process(target=signal.pause)
6437+ p.start()
6438+ # Stop WITHOUT joining -- child still holds preserved fd
6439+ _resource_tracker._stop(wait_timeout=0.5)
6440+ print(_resource_tracker._waitpid_timed_out)
6441+ p.terminate()
6442+ p.join()
6443+ ''' )
6444+ rc , out , err = script_helper .assert_python_ok ('-c' , cmd )
6445+ self .assertEqual (out .strip (), b'True' ,
6446+ f"safety-net timeout did not fire: stderr={ err !r} " )
6447+
6448+
62716449class TestSimpleQueue (unittest .TestCase ):
62726450
62736451 @classmethod
0 commit comments