deadpool-executor 2026.3.2__tar.gz → 2026.4.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (18) hide show
  1. {deadpool_executor-2026.3.2 → deadpool_executor-2026.4.1}/PKG-INFO +1 -1
  2. {deadpool_executor-2026.3.2 → deadpool_executor-2026.4.1}/deadpool.py +50 -26
  3. {deadpool_executor-2026.3.2 → deadpool_executor-2026.4.1}/noxfile.py +2 -0
  4. {deadpool_executor-2026.3.2 → deadpool_executor-2026.4.1}/tests/test_deadpool.py +54 -11
  5. {deadpool_executor-2026.3.2 → deadpool_executor-2026.4.1}/.coveragerc +0 -0
  6. {deadpool_executor-2026.3.2 → deadpool_executor-2026.4.1}/.pre-commit-config.yaml +0 -0
  7. {deadpool_executor-2026.3.2 → deadpool_executor-2026.4.1}/LICENSE-AGPL +0 -0
  8. {deadpool_executor-2026.3.2 → deadpool_executor-2026.4.1}/LICENSE-Apache +0 -0
  9. {deadpool_executor-2026.3.2 → deadpool_executor-2026.4.1}/README.rst +0 -0
  10. {deadpool_executor-2026.3.2 → deadpool_executor-2026.4.1}/covstart.pth +0 -0
  11. {deadpool_executor-2026.3.2 → deadpool_executor-2026.4.1}/examples/callbacks.py +0 -0
  12. {deadpool_executor-2026.3.2 → deadpool_executor-2026.4.1}/examples/entrypoint.py +0 -0
  13. {deadpool_executor-2026.3.2 → deadpool_executor-2026.4.1}/examples/leftover.py +0 -0
  14. {deadpool_executor-2026.3.2 → deadpool_executor-2026.4.1}/examples/priorities.py +0 -0
  15. {deadpool_executor-2026.3.2 → deadpool_executor-2026.4.1}/img1.jpg +0 -0
  16. {deadpool_executor-2026.3.2 → deadpool_executor-2026.4.1}/pyproject.toml +0 -0
  17. {deadpool_executor-2026.3.2 → deadpool_executor-2026.4.1}/tests/conftest.py +0 -0
  18. {deadpool_executor-2026.3.2 → deadpool_executor-2026.4.1}/tests/test_oom.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deadpool-executor
3
- Version: 2026.3.2
3
+ Version: 2026.4.1
4
4
  Summary: Deadpool
5
5
  Author-email: Caleb Hattingh <caleb.hattingh@gmail.com>
6
6
  Description-Content-Type: text/x-rst
@@ -44,7 +44,7 @@ from functools import partial
44
44
  import psutil
45
45
  from setproctitle import setproctitle
46
46
 
47
- __version__ = "2026.3.2"
47
+ __version__ = "2026.4.1"
48
48
  __all__ = [
49
49
  "Deadpool",
50
50
  "Future",
@@ -320,6 +320,9 @@ class Deadpool(Executor):
320
320
  self.running_jobs = Queue(maxsize=self.pool_size)
321
321
  self.running_futs = weakref.WeakSet()
322
322
  self.existing_workers = weakref.WeakSet()
323
+ # Lock protecting busy_workers, existing_workers, and
324
+ # running_futs for thread-safety without the GIL.
325
+ self._workers_lock = threading.Lock()
323
326
  self.closed = False
324
327
  self.shutdown_wait = shutdown_wait
325
328
  self.shutdown_cancel_futures = shutdown_cancel_futures
@@ -352,9 +355,10 @@ class Deadpool(Executor):
352
355
 
353
356
  # These are not counters; they are determined at the time of the
354
357
  # call based on the state of the worker processes.
355
- stats["worker_processes_still_alive"] = len(self.existing_workers)
358
+ with self._workers_lock:
359
+ stats["worker_processes_still_alive"] = len(self.existing_workers)
360
+ stats["worker_processes_busy"] = len(self.busy_workers)
356
361
  stats["worker_processes_idle"] = self.workers.qsize()
357
- stats["worker_processes_busy"] = len(self.busy_workers)
358
362
 
359
363
  return stats
360
364
 
@@ -388,7 +392,8 @@ class Deadpool(Executor):
388
392
  )
389
393
  self.workers.put(worker)
390
394
  self._statistics.worker_processes_created.increment()
391
- self.existing_workers.add(worker)
395
+ with self._workers_lock:
396
+ self.existing_workers.add(worker)
392
397
 
393
398
  def clear_workers(self):
394
399
  """Clear all workers from the pool.
@@ -434,7 +439,8 @@ class Deadpool(Executor):
434
439
  t.start()
435
440
 
436
441
  def get_process(self) -> WorkerProcess:
437
- bw = len(self.busy_workers)
442
+ with self._workers_lock:
443
+ bw = len(self.busy_workers)
438
444
  mw = self.pool_size
439
445
  qs = self.workers.qsize()
440
446
 
@@ -443,22 +449,20 @@ class Deadpool(Executor):
443
449
  self.add_worker_to_pool()
444
450
 
445
451
  wp = self.workers.get()
446
- self.busy_workers.add(wp)
447
- if (
448
- len(self.busy_workers)
449
- > self._statistics.max_workers_busy_concurrently.value
450
- ):
451
- self._statistics.max_workers_busy_concurrently.value = len(
452
- self.busy_workers
453
- )
452
+ with self._workers_lock:
453
+ self.busy_workers.add(wp)
454
+ busy_count = len(self.busy_workers)
455
+ with self._statistics.max_workers_busy_concurrently.lock:
456
+ if busy_count > self._statistics.max_workers_busy_concurrently.value:
457
+ self._statistics.max_workers_busy_concurrently.value = busy_count
454
458
 
455
459
  return wp
456
460
 
457
461
  def done_with_process(self, wp: WorkerProcess):
458
462
  # This worker is done with its job and is no longer busy.
459
- self.busy_workers.remove(wp)
460
-
461
- count_workers_busy = len(self.busy_workers)
463
+ with self._workers_lock:
464
+ self.busy_workers.remove(wp)
465
+ count_workers_busy = len(self.busy_workers)
462
466
  count_workers_idle = self.workers.qsize()
463
467
  backlog_size = self.submitted_jobs.qsize()
464
468
 
@@ -544,7 +548,8 @@ class Deadpool(Executor):
544
548
  return
545
549
 
546
550
  fut.pid = worker.pid
547
- self.running_futs.add(fut)
551
+ with self._workers_lock:
552
+ self.running_futs.add(fut)
548
553
 
549
554
  while True:
550
555
  if worker.results_are_available():
@@ -552,19 +557,35 @@ class Deadpool(Executor):
552
557
  results = worker.get_results()
553
558
  except EOFError:
554
559
  self._statistics.tasks_failed.increment()
555
- fut.set_exception(
556
- ProcessError("Worker process died unexpectedly")
557
- )
560
+ if not fut.done():
561
+ try:
562
+ fut.set_exception(
563
+ ProcessError("Worker process died unexpectedly")
564
+ )
565
+ except InvalidStateError:
566
+ pass
558
567
  except BaseException as e:
559
568
  self._statistics.tasks_failed.increment()
560
569
  logger.debug(f"Unexpected exception from worker: {e}")
561
- fut.set_exception(e)
570
+ if not fut.done():
571
+ try:
572
+ fut.set_exception(e)
573
+ except InvalidStateError:
574
+ pass
562
575
  else:
563
576
  if isinstance(results, BaseException):
564
577
  self._statistics.tasks_failed.increment()
565
- fut.set_exception(results)
578
+ if not fut.done():
579
+ try:
580
+ fut.set_exception(results)
581
+ except InvalidStateError:
582
+ pass
566
583
  else:
567
- fut.set_result(results)
584
+ if not fut.done():
585
+ try:
586
+ fut.set_result(results)
587
+ except InvalidStateError:
588
+ pass
568
589
 
569
590
  if isinstance(results, TimeoutError):
570
591
  self._statistics.tasks_failed.increment()
@@ -680,7 +701,8 @@ class Deadpool(Executor):
680
701
  # want to wait, that she probably wants us to also stop
681
702
  # running processes.
682
703
  if (not wait) and cancel_futures:
683
- running_futs = list(self.running_futs)
704
+ with self._workers_lock:
705
+ running_futs = list(self.running_futs)
684
706
  for fut in running_futs:
685
707
  fut.cancel_and_kill_if_running()
686
708
 
@@ -700,8 +722,10 @@ class Deadpool(Executor):
700
722
 
701
723
  # There may be a few processes left in the
702
724
  # `busy_workers` queue. Shut them down too.
703
- while self.busy_workers:
704
- worker = self.busy_workers.pop()
725
+ with self._workers_lock:
726
+ remaining = list(self.busy_workers)
727
+ self.busy_workers.clear()
728
+ for worker in remaining:
705
729
  worker.shutdown()
706
730
 
707
731
  def __enter__(self):
@@ -11,6 +11,7 @@ import nox
11
11
  "3.12",
12
12
  "3.13",
13
13
  "3.14",
14
+ "3.14t",
14
15
  ]
15
16
  )
16
17
  def test(session):
@@ -27,6 +28,7 @@ def test(session):
27
28
  "3.12",
28
29
  "3.13",
29
30
  "3.14",
31
+ "3.14t",
30
32
  ]
31
33
  )
32
34
  def testcov(session):
@@ -7,7 +7,8 @@ import signal
7
7
  import sys
8
8
  import time
9
9
  import multiprocessing as mp
10
- from concurrent.futures import CancelledError, as_completed
10
+ import threading
11
+ from concurrent.futures import CancelledError, InvalidStateError, as_completed
11
12
  from contextlib import contextmanager
12
13
  from functools import partial
13
14
 
@@ -634,6 +635,47 @@ def test_cancel_and_kill():
634
635
  fut.result()
635
636
 
636
637
 
638
+ def delayed_error(delay=0.5):
639
+ time.sleep(delay)
640
+ raise ValueError("delayed error from worker")
641
+
642
+
643
+ def test_set_exception_on_cancelled_future():
644
+ """Cancelling a future whose worker raises an exception must not
645
+ cause an InvalidStateError in the run_task thread.
646
+
647
+ Reproduces the bug: deadpool futures stay PENDING (never set to
648
+ RUNNING), so fut.cancel() succeeds even while a worker is
649
+ processing. When the worker finishes with an exception, run_task
650
+ calls fut.set_exception() on the already-cancelled future, raising
651
+ InvalidStateError.
652
+ """
653
+ thread_exceptions = []
654
+ original_hook = threading.excepthook
655
+
656
+ def capture_hook(args):
657
+ thread_exceptions.append(args)
658
+
659
+ threading.excepthook = capture_hook
660
+ try:
661
+ exe = deadpool.Deadpool(max_workers=1)
662
+ fut = exe.submit(delayed_error, 0.5)
663
+ time.sleep(0.2) # let worker start
664
+ fut.cancel() # cancel while worker is still running
665
+ time.sleep(1.0) # let worker finish and run_task process results
666
+ exe.shutdown(wait=True)
667
+
668
+ assert fut.cancelled()
669
+ invalid_state_errors = [
670
+ e for e in thread_exceptions if isinstance(e.exc_value, InvalidStateError)
671
+ ]
672
+ assert (
673
+ not invalid_state_errors
674
+ ), f"InvalidStateError raised in background thread: {invalid_state_errors}"
675
+ finally:
676
+ threading.excepthook = original_hook
677
+
678
+
637
679
  def test_trim_memory():
638
680
  """Just testing it doesn't fail."""
639
681
  deadpool.trim_memory()
@@ -654,13 +696,15 @@ def leaker(n):
654
696
  def test_max_memory(logging_initializer):
655
697
  # Verify that the memory threshold feature in deadpool
656
698
  # works as expected. This test will run 20 functions, 10
657
- # of which will consume 1MB of memory. The other 10 will
699
+ # of which will consume 150MB of memory. The other 10 will
658
700
  # consume 0.1MB of memory. The memory threshold is set to
659
- # 1.5MB, so the first 10 functions should cause their workers
660
- # to be replaced by new workers, while the other 10 functions
701
+ # 100MB, so the large functions should cause their workers
702
+ # to be replaced by new workers, while the small functions
661
703
  # should be able to run without requiring their workers to be
662
- # replaced. So we'll count the total number of subprocess PID
663
- # values seen by a task, and verify the result.
704
+ # replaced. We verify that more unique PIDs were seen than
705
+ # the pool size, proving that worker replacement occurred.
706
+ # We use >= 10 rather than == 11 because PID recycling can
707
+ # cause a replaced worker's PID to be reused by its successor.
664
708
 
665
709
  leak_test_accumulator.clear()
666
710
  with deadpool.Deadpool(
@@ -675,11 +719,10 @@ def test_max_memory(logging_initializer):
675
719
 
676
720
  pids = set(f.result() for f in deadpool.as_completed(futs))
677
721
 
678
- # We should see 11 unique PIDs, because the first 10 functions
679
- # should have caused their workers to be replaced, while their
680
- # replacements should have been able to run the remaining 10
681
- # functions without being replaced.
682
- assert len(pids) == 11
722
+ # We expect ~11 unique PIDs (1 initial + 10 replacements), but
723
+ # PID recycling may reduce this slightly. At minimum we should
724
+ # see substantially more than max_workers (1).
725
+ assert len(pids) >= 10
683
726
 
684
727
 
685
728
  def test_can_pickle_nested_function():