@@ -6321,8 +6321,9 @@ def test_resource_tracker_sigkill(self):
63216321 def _is_resource_tracker_reused (conn , pid ):
63226322 from multiprocessing .resource_tracker import _resource_tracker
63236323 _resource_tracker .ensure_running ()
6324- # The pid should be None in the child process, expect for the fork
6325- # context. It should not be a new value.
6324+ # The pid should be None in the child (the at-fork handler clears
6325+ # it for fork; spawn/forkserver children never had it set). It
6326+ # should not be a new value.
63266327 reused = _resource_tracker ._pid in (None , pid )
63276328 reused &= _resource_tracker ._check_alive ()
63286329 conn .send (reused )
@@ -6408,6 +6409,183 @@ def test_resource_tracker_blocked_signals(self):
64086409 # restore sigmask to what it was before executing test
64096410 signal .pthread_sigmask (signal .SIG_SETMASK , orig_sigmask )
64106411
6412+ @only_run_in_forkserver_testsuite ("avoids redundant testing." )
6413+ def test_resource_tracker_fork_deadlock (self ):
6414+ # gh-146313: ResourceTracker.__del__ used to deadlock if a forked
6415+ # child still held the pipe's write end open when the parent
6416+ # exited, because the parent would block in waitpid() waiting for
6417+ # the tracker to exit, but the tracker would never see EOF.
6418+ cmd = '''if 1:
6419+ import os, signal
6420+ from multiprocessing.resource_tracker import ensure_running
6421+ ensure_running()
6422+ if os.fork() == 0:
6423+ signal.pause()
6424+ os._exit(0)
6425+ # parent falls through and exits, triggering __del__
6426+ '''
6427+ proc = subprocess .Popen ([sys .executable , '-c' , cmd ],
6428+ start_new_session = True )
6429+ try :
6430+ try :
6431+ proc .wait (timeout = support .SHORT_TIMEOUT )
6432+ except subprocess .TimeoutExpired :
6433+ self .fail (
6434+ "Parent process deadlocked in ResourceTracker.__del__"
6435+ )
6436+ self .assertEqual (proc .returncode , 0 )
6437+ finally :
6438+ try :
6439+ os .killpg (proc .pid , signal .SIGKILL )
6440+ except ProcessLookupError :
6441+ pass
6442+ proc .wait ()
6443+
6444+ @only_run_in_forkserver_testsuite ("avoids redundant testing." )
6445+ def test_resource_tracker_mp_fork_reuse_and_prompt_reap (self ):
6446+ # gh-146313 / gh-80849: A child started via multiprocessing.Process
6447+ # with the 'fork' start method should reuse the parent's resource
6448+ # tracker (the at-fork handler preserves the inherited pipe fd),
6449+ # *and* the parent should be able to reap the tracker promptly
6450+ # after joining the child, without hitting the waitpid timeout.
6451+ cmd = textwrap .dedent ('''
6452+ import multiprocessing as mp
6453+ from multiprocessing.resource_tracker import _resource_tracker
6454+
6455+ def child(conn):
6456+ # Prove we can talk to the parent's tracker by registering
6457+ # and unregistering a dummy resource over the inherited fd.
6458+ # If the fd were closed, ensure_running would launch a new
6459+ # tracker and _pid would be non-None.
6460+ _resource_tracker.register("x", "dummy")
6461+ _resource_tracker.unregister("x", "dummy")
6462+ conn.send((_resource_tracker._fd is not None,
6463+ _resource_tracker._pid is None,
6464+ _resource_tracker._check_alive()))
6465+
6466+ if __name__ == "__main__":
6467+ mp.set_start_method("fork")
6468+ _resource_tracker.ensure_running()
6469+ r, w = mp.Pipe(duplex=False)
6470+ p = mp.Process(target=child, args=(w,))
6471+ p.start()
6472+ child_has_fd, child_pid_none, child_alive = r.recv()
6473+ p.join()
6474+ w.close(); r.close()
6475+
6476+ # Now simulate __del__: the child has exited and released
6477+ # its fd copy, so the tracker should see EOF and exit
6478+ # promptly -- no timeout.
6479+ _resource_tracker._stop(wait_timeout=5.0)
6480+ print(child_has_fd, child_pid_none, child_alive,
6481+ _resource_tracker._waitpid_timed_out,
6482+ _resource_tracker._exitcode)
6483+ ''' )
6484+ rc , out , err = script_helper .assert_python_ok ('-c' , cmd )
6485+ parts = out .decode ().split ()
6486+ self .assertEqual (parts , ['True' , 'True' , 'True' , 'False' , '0' ],
6487+ f"unexpected: { parts !r} stderr={ err !r} " )
6488+
6489+ @only_run_in_forkserver_testsuite ("avoids redundant testing." )
6490+ def test_resource_tracker_raw_fork_prompt_reap (self ):
6491+ # gh-146313: After a raw os.fork() the at-fork handler closes the
6492+ # child's inherited fd, so the parent can reap the tracker
6493+ # immediately -- even while the child is still alive -- rather
6494+ # than waiting out the 1s timeout.
6495+ cmd = textwrap .dedent ('''
6496+ import os, signal
6497+ from multiprocessing.resource_tracker import _resource_tracker
6498+
6499+ _resource_tracker.ensure_running()
6500+ r, w = os.pipe()
6501+ pid = os.fork()
6502+ if pid == 0:
6503+ os.close(r)
6504+ # Report whether our fd was closed by the at-fork handler.
6505+ os.write(w, b"1" if _resource_tracker._fd is None else b"0")
6506+ os.close(w)
6507+ signal.pause() # stay alive so parent's reap is meaningful
6508+ os._exit(0)
6509+ os.close(w)
6510+ child_fd_closed = os.read(r, 1) == b"1"
6511+ os.close(r)
6512+
6513+ # Child is still alive and paused. Because it closed its fd
6514+ # copy, our close below is the last one and the tracker exits.
6515+ _resource_tracker._stop(wait_timeout=5.0)
6516+
6517+ os.kill(pid, signal.SIGKILL)
6518+ os.waitpid(pid, 0)
6519+ print(child_fd_closed,
6520+ _resource_tracker._waitpid_timed_out,
6521+ _resource_tracker._exitcode)
6522+ ''' )
6523+ rc , out , err = script_helper .assert_python_ok ('-c' , cmd )
6524+ parts = out .decode ().split ()
6525+ self .assertEqual (parts , ['True' , 'False' , '0' ],
6526+ f"unexpected: { parts !r} stderr={ err !r} " )
6527+
6528+ @only_run_in_forkserver_testsuite ("avoids redundant testing." )
6529+ def test_resource_tracker_lock_reinit_after_fork (self ):
6530+ # gh-146313: If a parent thread held the tracker's lock at fork
6531+ # time, the child would inherit the held lock and deadlock on
6532+ # its next ensure_running(). The at-fork handler reinits it.
6533+ cmd = textwrap .dedent ('''
6534+ import os, threading
6535+ from multiprocessing.resource_tracker import _resource_tracker
6536+
6537+ held = threading.Event()
6538+ release = threading.Event()
6539+ def hold():
6540+ with _resource_tracker._lock:
6541+ held.set()
6542+ release.wait()
6543+ t = threading.Thread(target=hold)
6544+ t.start()
6545+ held.wait()
6546+
6547+ pid = os.fork()
6548+ if pid == 0:
6549+ ok = _resource_tracker._lock.acquire(timeout=5.0)
6550+ os._exit(0 if ok else 1)
6551+
6552+ release.set()
6553+ t.join()
6554+ _, status = os.waitpid(pid, 0)
6555+ print(os.waitstatus_to_exitcode(status))
6556+ ''' )
6557+ rc , out , err = script_helper .assert_python_ok (
6558+ '-W' , 'ignore::DeprecationWarning' , '-c' , cmd )
6559+ self .assertEqual (out .strip (), b'0' ,
6560+ f"child failed to acquire lock: stderr={ err !r} " )
6561+
6562+ @only_run_in_forkserver_testsuite ("avoids redundant testing." )
6563+ def test_resource_tracker_safety_net_timeout (self ):
6564+ # gh-146313: When an mp.Process(fork) child holds the preserved
6565+ # fd and the parent calls _stop() without joining (simulating
6566+ # abnormal shutdown), the safety-net timeout should fire rather
6567+ # than deadlocking.
6568+ cmd = textwrap .dedent ('''
6569+ import multiprocessing as mp
6570+ import signal
6571+ from multiprocessing.resource_tracker import _resource_tracker
6572+
6573+ if __name__ == "__main__":
6574+ mp.set_start_method("fork")
6575+ _resource_tracker.ensure_running()
6576+ p = mp.Process(target=signal.pause)
6577+ p.start()
6578+ # Stop WITHOUT joining -- child still holds preserved fd
6579+ _resource_tracker._stop(wait_timeout=0.5)
6580+ print(_resource_tracker._waitpid_timed_out)
6581+ p.terminate()
6582+ p.join()
6583+ ''' )
6584+ rc , out , err = script_helper .assert_python_ok ('-c' , cmd )
6585+ self .assertEqual (out .strip (), b'True' ,
6586+ f"safety-net timeout did not fire: stderr={ err !r} " )
6587+
6588+
64116589class TestSimpleQueue (unittest .TestCase ):
64126590
64136591 @classmethod
0 commit comments