@@ -269,6 +269,20 @@ def _process_worker(call_queue, result_queue, initializer, initargs, max_tasks=N
269269 return
270270
271271
272+ def _spawn_worker (mp_context , call_queue , result_queue , initializer ,
273+ initargs , max_tasks_per_child , processes ):
274+ """Start one worker process and record it in *processes* by pid."""
275+ p = mp_context .Process (
276+ target = _process_worker ,
277+ args = (call_queue ,
278+ result_queue ,
279+ initializer ,
280+ initargs ,
281+ max_tasks_per_child ))
282+ p .start ()
283+ processes [p .pid ] = p
284+
285+
272286class _ExecutorManagerThread (threading .Thread ):
273287 """Manages the communication between this process and the worker processes.
274288
@@ -321,6 +335,15 @@ def weakref_cb(_,
321335 # exiting safely
322336 self .max_tasks_per_child = executor ._max_tasks_per_child
323337
338+ # gh-119592: Needed to size worker replacement, and immutable, so
339+ # keep a copy rather than reading it back through the executor
340+ # weakref. The rest of the spawn configuration is deliberately NOT
341+ # copied here: holding user-provided objects (initializer,
342+ # initargs, mp_context) in this always-reachable running thread
343+ # could keep the executor itself reachable through them, breaking
344+ # garbage-collection-triggered shutdown.
345+ self .max_workers = executor ._max_workers
346+
324347 # A dict mapping work ids to _WorkItems e.g.
325348 # {5: <_WorkItem...>, 6: <_WorkItem...>, ...}
326349 self .pending_work_items = executor ._pending_work_items
@@ -357,12 +380,14 @@ def run(self):
357380 # while waiting on new results.
358381 del result_item
359382
360- if executor := self .executor_reference ():
361- if process_exited :
362- with self .shutdown_lock :
363- executor ._replace_dead_worker ()
364- else :
365- executor ._idle_worker_semaphore .release ()
383+ if process_exited :
384+ with self .shutdown_lock :
385+ broken = self ._replace_dead_worker ()
386+ if broken is not None :
387+ self .terminate_broken (* broken )
388+ return
389+ elif executor := self .executor_reference ():
390+ executor ._idle_worker_semaphore .release ()
366391 del executor
367392
368393 if self .is_shutting_down ():
@@ -379,6 +404,71 @@ def run(self):
379404 self .join_executor_internals ()
380405 return
381406
407+ def _replace_dead_worker (self ):
408+ """Spawn a replacement for a worker that exited at its
409+ max_tasks_per_child limit. Called under self.shutdown_lock.
410+
411+ Returns None while the pool can still make progress, otherwise a
412+ (cause, message) tuple describing why the remaining work items can
413+ never run, so that run() can fail their futures.
414+ """
415+ assert self .shutdown_lock .locked ()
416+ cause = None
417+ message = None
418+ executor = self .executor_reference ()
419+ if executor is None :
420+ # gh-152967: The executor was garbage collected; nothing can
421+ # spawn a replacement worker for it anymore.
422+ message = ("The ProcessPoolExecutor was garbage collected with "
423+ "work pending after its last worker process exited "
424+ "upon reaching max_tasks_per_child; the pending work "
425+ "can never be run." )
426+ elif executor ._force_shutting_down :
427+ # terminate_workers()/kill_workers() is tearing the pool down;
428+ # a replacement worker would escape the kill and run work
429+ # items that were enqueued before it.
430+ message = ("A worker process exited while the pool was being "
431+ "forcefully shut down; work that was still enqueued "
432+ "will not be run." )
433+ elif self .pending_work_items or not self .is_shutting_down ():
434+ # gh-115634: Do not consult the executor's
435+ # _idle_worker_semaphore here: it counts task completions, not
436+ # idle workers, so it can hold a stale token released by the
437+ # now-dead worker. Trusting such a token would leave the pool
438+ # a worker short, deadlocking once all workers reach their
439+ # task limit. Spawning from this (manager) thread is safe
440+ # despite gh-90622 because max_tasks_per_child is rejected for
441+ # the "fork" start method.
442+ if len (self .processes ) < self .max_workers :
443+ # gh-119592: Spawn using state owned by this thread and
444+ # configuration read through the live weakref (which
445+ # shutdown() never clears), not the executor state that
446+ # shutdown(wait=False) clears concurrently.
447+ try :
448+ _spawn_worker (executor ._mp_context , self .call_queue ,
449+ self .result_queue , executor ._initializer ,
450+ executor ._initargs ,
451+ self .max_tasks_per_child , self .processes )
452+ except Exception as exc :
453+ # While other workers remain the pool has merely lost
454+ # capacity and they keep draining the queue; with none
455+ # left the failure is reported below.
456+ cause = format_exception (exc )
457+ message = ("A replacement worker process could not be "
458+ "started, leaving the pool without workers "
459+ "to run the remaining work." )
460+ del executor
461+
462+ if not self .processes and (self .pending_work_items
463+ or cause is not None ):
464+ # No worker processes remain and no replacement can be
465+ # spawned: any remaining work items can never run. A spawn
466+ # failure breaks the pool even with nothing pending; leaving
467+ # a zero-worker pool alive would hang a later submit() on a
468+ # stale _idle_worker_semaphore token instead of raising.
469+ return (cause , message )
470+ return None
471+
382472 def add_call_item_to_queue (self ):
383473 # Fills call_queue with _WorkItems from pending_work_items.
384474 # This function never blocks.
@@ -455,10 +545,11 @@ def is_shutting_down(self):
455545 return (_global_shutdown or executor is None
456546 or executor ._shutdown_thread )
457547
458- def _terminate_broken (self , cause ):
548+ def _terminate_broken (self , cause , bpe_message = None ):
459549 # Terminate the executor because it is in a broken state. The cause
460550 # argument can be used to display more information on the error that
461- # lead the executor into becoming broken.
551+ # lead the executor into becoming broken. bpe_message overrides the
552+ # default message on the BrokenProcessPool set on pending futures.
462553
463554 # Mark the process pool broken so that submits fail right now.
464555 executor = self .executor_reference ()
@@ -476,11 +567,12 @@ def _terminate_broken(self, cause):
476567 if cause is not None :
477568 cause_tb = f"\n '''\n { '' .join (cause )} '''"
478569
570+ if bpe_message is None :
571+ bpe_message = ("A process in the process pool was terminated "
572+ "abruptly while the future was running or pending." )
479573 # Mark pending tasks as failed.
480574 for work_id , work_item in self .pending_work_items .items ():
481- bpe = BrokenProcessPool ("A process in the process pool was "
482- "terminated abruptly while the future was "
483- "running or pending." )
575+ bpe = BrokenProcessPool (bpe_message )
484576 if cause_tb is not None :
485577 bpe .__cause__ = _RemoteTraceback (cause_tb )
486578 try :
@@ -505,9 +597,9 @@ def _terminate_broken(self, cause):
505597 # clean up resources
506598 self ._join_executor_internals (broken = True )
507599
508- def terminate_broken (self , cause ):
600+ def terminate_broken (self , cause , bpe_message = None ):
509601 with self .shutdown_lock :
510- self ._terminate_broken (cause )
602+ self ._terminate_broken (cause , bpe_message )
511603
512604 def flag_executor_shutting_down (self ):
513605 # Flag the executor as shutting down and cancel remaining tasks if
@@ -720,6 +812,7 @@ def __init__(self, max_workers=None, mp_context=None,
720812 self ._queue_count = 0
721813 self ._pending_work_items = {}
722814 self ._cancel_pending_futures = False
815+ self ._force_shutting_down = False
723816
724817 # _ThreadWakeup is a communication channel used to interrupt the wait
725818 # of the main loop of executor_manager_thread from another thread (e.g.
@@ -759,34 +852,15 @@ def _start_executor_manager_thread(self):
759852 _threads_wakeups [self ._executor_manager_thread ] = \
760853 self ._executor_manager_thread_wakeup
761854
762- def _replace_dead_worker (self ):
855+ def _adjust_process_count (self ):
763856 # gh-132969: avoid error when state is reset and executor is still running,
764857 # which will happen when shutdown(wait=False) is called.
765858 if self ._processes is None :
766859 return
767860
768- # A replacement is pointless when shutting down with nothing left
769- # to run. Both attributes are read under _shutdown_lock, which
770- # shutdown() holds while setting _shutdown_thread.
771- assert self ._shutdown_lock .locked ()
772- if self ._shutdown_thread and not self ._pending_work_items :
773- return
774-
775- # gh-115634: A worker exited after reaching max_tasks_per_child and
776- # has been removed from self._processes. Do not consult
777- # _idle_worker_semaphore here: it counts task completions, not idle
778- # workers, so it can hold a stale token released by the now-dead
779- # worker. Trusting such a token would leave the pool a worker short,
780- # deadlocking once all workers reach their task limit. Spawning is
781- # safe from this (manager) thread despite gh-90622 because
782- # max_tasks_per_child is rejected for the "fork" start method.
783- if len (self ._processes ) < self ._max_workers :
784- self ._spawn_process ()
785-
786- def _adjust_process_count (self ):
787- # gh-132969: avoid error when state is reset and executor is still running,
788- # which will happen when shutdown(wait=False) is called.
789- if self ._processes is None :
861+ # gh-152967: A forceful shutdown is in progress; a worker spawned
862+ # here could escape its process snapshot and keep running work.
863+ if self ._force_shutting_down :
790864 return
791865
792866 # if there's an idle process, we don't need to spawn a new one.
@@ -812,15 +886,10 @@ def _launch_processes(self):
812886 self ._spawn_process ()
813887
814888 def _spawn_process (self ):
815- p = self ._mp_context .Process (
816- target = _process_worker ,
817- args = (self ._call_queue ,
818- self ._result_queue ,
819- self ._initializer ,
820- self ._initargs ,
821- self ._max_tasks_per_child ))
822- p .start ()
823- self ._processes [p .pid ] = p
889+ _spawn_worker (self ._mp_context , self ._call_queue ,
890+ self ._result_queue , self ._initializer ,
891+ self ._initargs , self ._max_tasks_per_child ,
892+ self ._processes )
824893
825894 def submit (self , fn , / , * args , ** kwargs ):
826895 with self ._shutdown_lock :
@@ -917,6 +986,14 @@ def _force_shutdown(self, operation):
917986 if operation not in _SHUTDOWN_CALLBACK_OPERATION :
918987 raise ValueError (f"Unsupported operation: { operation !r} " )
919988
989+ # gh-152967: Stop the manager thread from spawning replacement
990+ # workers before we copy the processes to signal: a worker spawned
991+ # after the copy would survive the loop below and run enqueued
992+ # work items. Taking the lock orders this against the manager's
993+ # worker replacement, which runs under the same lock.
994+ with self ._shutdown_lock :
995+ self ._force_shutting_down = True
996+
920997 processes = {}
921998 if self ._processes :
922999 processes = self ._processes .copy ()
0 commit comments