Script 'mail_helper' called by obssrc
Hello community,

here is the log from the commit of package python-billiard for openSUSE:Factory 
checked in at 2026-10-26 10:00:25
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/python-billiard (Old)
 and      /work/SRC/openSUSE:Factory/.python-billiard.new.2252680 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Package is "python-billiard"

Mon Oct 26 10:00:25 2026 rev:31 rq:1392395 version:4.3.1

Changes:
--------
--- /work/SRC/openSUSE:Factory/python-billiard/python-billiard.changes  
2026-09-28 10:44:35.693282609 +0200
+++ 
/work/SRC/openSUSE:Factory/.python-billiard.new.2252680/python-billiard.changes 
    2026-10-26 10:01:17.337950894 +0100
@@ -1,0 +2,12 @@
+Sun Oct 25 11:59:46 UTC 2026 - Dirk Müller <[email protected]>
+
+- update to 4.3.1:
+  * Reject bool for Pool numeric args (processes, timeouts,
+    limits) - #460
+  * Reject fewer than one pool process - #462
+  * Reject a non-positive maxtasksperchild in Pool - #465
+  * Refuse to close a Process that is still running -(#467)
+  * Raise ValueError when using a closed Queue (#466)
+  * Do not report a shutdown SystemExit as the job's result
+
+-------------------------------------------------------------------

Old:
----
  billiard-4.3.0.tar.gz

New:
----
  billiard-4.3.1.tar.gz

++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Other differences:
------------------
++++++ python-billiard.spec ++++++
--- /var/tmp/diff_new_pack.fziNdJ/_old  2026-10-26 10:01:18.110983326 +0100
+++ /var/tmp/diff_new_pack.fziNdJ/_new  2026-10-26 10:01:18.115983536 +0100
@@ -18,7 +18,7 @@
 
 %{?sle15_python_module_pythons}
 Name:           python-billiard
-Version:        4.3.0
+Version:        4.3.1
 Release:        0
 Summary:        Python multiprocessing fork
 License:        BSD-3-Clause

++++++ billiard-4.3.0.tar.gz -> billiard-4.3.1.tar.gz ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/billiard-4.3.0/CHANGES.txt 
new/billiard-4.3.1/CHANGES.txt
--- old/billiard-4.3.0/CHANGES.txt      2026-09-17 10:30:48.366935500 +0200
+++ new/billiard-4.3.1/CHANGES.txt      2026-10-05 08:31:02.569429200 +0200
@@ -1,3 +1,13 @@
+4.3.1 - 2026-10-05
+------------------
+- Reject bool for Pool numeric args (processes, timeouts, limits) - #460
+- Reject fewer than one pool process - #462
+- Reject a non-positive maxtasksperchild in Pool - #465
+- Refuse to close a Process that is still running -(#467)
+- Raise ValueError when using a closed Queue (#466)
+- Do not report a shutdown SystemExit as the job's result (#464)
+
+
 4.3.0 - 2026-09-26
 ------------------
 - Propagate MemoryError from pool callbacks
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/billiard-4.3.0/PKG-INFO new/billiard-4.3.1/PKG-INFO
--- old/billiard-4.3.0/PKG-INFO 2026-09-17 10:35:43.591227800 +0200
+++ new/billiard-4.3.1/PKG-INFO 2026-10-05 08:37:27.369151400 +0200
@@ -1,6 +1,6 @@
 Metadata-Version: 2.4
 Name: billiard
-Version: 4.3.0
+Version: 4.3.1
 Summary: Python multiprocessing fork with improvements and bugfixes
 Home-page: https://github.com/celery/billiard
 Author: R Oudkerk / Python Software Foundation
@@ -47,7 +47,7 @@
 
 |build-status-lin| |build-status-win| |license| |wheel| |pyversion| |pyimp|
 
-:Version: 4.3.0
+:Version: 4.3.1
 :Web: https://billiard.readthedocs.io
 :Download: https://pypi.org/project/billiard/
 :Source: https://github.com/celery/billiard/
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/billiard-4.3.0/README.rst 
new/billiard-4.3.1/README.rst
--- old/billiard-4.3.0/README.rst       2026-09-17 10:30:48.367270500 +0200
+++ new/billiard-4.3.1/README.rst       2026-10-05 08:31:02.569776300 +0200
@@ -4,7 +4,7 @@
 
 |build-status-lin| |build-status-win| |license| |wheel| |pyversion| |pyimp|
 
-:Version: 4.3.0
+:Version: 4.3.1
 :Web: https://billiard.readthedocs.io
 :Download: https://pypi.org/project/billiard/
 :Source: https://github.com/celery/billiard/
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/billiard-4.3.0/billiard/__init__.py 
new/billiard-4.3.1/billiard/__init__.py
--- old/billiard-4.3.0/billiard/__init__.py     2026-09-17 10:30:48.367686700 
+0200
+++ new/billiard-4.3.1/billiard/__init__.py     2026-10-05 08:31:02.570095500 
+0200
@@ -23,7 +23,7 @@
 
 from . import context
 
-__version__ = '4.3.0'
+__version__ = '4.3.1'
 __author__ = 'R Oudkerk / Python Software Foundation'
 __author_email__ = '[email protected]'
 __maintainer__ = 'Asif Saif Uddin'
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/billiard-4.3.0/billiard/common.py 
new/billiard-4.3.1/billiard/common.py
--- old/billiard-4.3.0/billiard/common.py       2026-09-17 08:52:08.068524000 
+0200
+++ new/billiard-4.3.1/billiard/common.py       2026-10-05 08:12:40.525474500 
+0200
@@ -74,6 +74,32 @@
 _should_have_exited = [False]
 
 
+def terminate_handler_fired():
+    """Has this process's own terminate-signal handler already fired?
+
+    A `SystemExit` seen after that point was raised by
+    :func:`_shutdown_cleanup`, not by whatever the process happened to be
+    running, and means the process is on its way out.
+
+    Strictly about *this* process having already run the handler -- a
+    process that has been sent a terminate signal but has not yet handled
+    it answers False, which is why this is not named ``terminating()``.
+    """
+    return _should_have_exited[0]
+
+
+def clear_terminate_handler_fired():
+    """Forget that a terminate handler ran -- for use after :func:`fork`.
+
+    The flag is a module global and so is inherited across ``fork()``. A
+    fresh child has not run a terminate handler, whatever it inherited from
+    a parent that had, and must say so before it runs any user code: a
+    stale True makes :func:`_shutdown_cleanup` take its "something is very
+    wrong" :func:`os._exit` branch on the child's first terminate signal.
+    """
+    _should_have_exited[0] = False
+
+
 def human_status(status):
     if (status or 0) < 0:
         try:
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/billiard-4.3.0/billiard/pool.py 
new/billiard-4.3.1/billiard/pool.py
--- old/billiard-4.3.0/billiard/pool.py 2026-09-17 08:52:08.069891700 +0200
+++ new/billiard-4.3.1/billiard/pool.py 2026-10-05 08:12:40.525818000 +0200
@@ -27,7 +27,8 @@
 from . import cpu_count, get_context
 from . import util
 from .common import (
-    TERM_SIGNAL, human_status, pickle_loads, reset_signals, restart_state,
+    TERM_SIGNAL, clear_terminate_handler_fired, human_status, pickle_loads,
+    reset_signals, restart_state, terminate_handler_fired,
 )
 from .compat import get_errno, mem_rss, send_offset
 from .einfo import ExceptionInfo
@@ -362,11 +363,50 @@
                     try:
                         result = (True, prepare_result(fun(*args, **kwargs)))
                     except BaseException as exc:
+                        if terminate_handler_fired():
+                            # Our own signal handler has already run in this
+                            # process -- see common._shutdown_cleanup -- so we
+                            # are unwinding towards exit and this job did not
+                            # finish. There is no result to report: say
+                            # nothing and let the process go, and the parent
+                            # accounts for the job as lost with the worker.
+                            # Reporting it would make an interrupted job look
+                            # completed-with-failure, and a caller that
+                            # acknowledges work only once it is done -- Celery
+                            # with task_acks_late -- would then acknowledge a
+                            # task nothing ever ran to the end.
+                            #
+                            # Deliberately not narrowed to SystemExit. If the
+                            # job's own cleanup raises while that SystemExit
+                            # unwinds -- a finally, or an __exit__ that fails,
+                            # say a rollback on a connection the teardown has
+                            # already closed -- the exception arriving here is
+                            # that new error, and the job is every bit as
+                            # unfinished (reported by @kratos0718 on #464).
+                            #
+                            # Known limitation, in the safer direction: a job
+                            # that *swallows* the shutdown SystemExit leaves
+                            # this flag set with the worker still alive, so a
+                            # later job's own exception is reported as a lost
+                            # worker rather than as itself. A spurious loss is
+                            # redelivered rather than silently acked, and such
+                            # a job has already defeated shutdown. Do not put
+                            # the isinstance check back without first handling
+                            # the cleanup-raises case above.
+                            raise
                         result = (False, ExceptionInfo())
                         if isinstance(exc, (SystemExit, KeyboardInterrupt)):
-                            # The worker was told to exit (e.g. SIGTERM from a
-                            # hard time limit): report the result, then leave
-                            # instead of re-entering the shared inqueue.
+                            # Raised by the job itself: a result, and one the
+                            # caller should see rather than a WorkerLostError
+                            # (#427). Report it, then leave instead of
+                            # re-entering the shared inqueue.
+                            #
+                            # Reached only when the flag is clear, so these
+                            # really are the job's own: _shutdown_cleanup
+                            # raises SystemExit and sets the flag first, and
+                            # after_fork sets SIGINT to SIG_IGN, so a worker's
+                            # KeyboardInterrupt can only have come from the
+                            # job.
                             exit_exc = exc
                     try:
                         put((READY, (job, i, result, inqW_fd)))
@@ -420,6 +460,14 @@
         return False
 
     def after_fork(self):
+        # First, before self.initializer runs any user code: this child has
+        # not handled a terminate signal, whatever it inherited across the
+        # fork. Doing this here rather than as a side effect of
+        # reset_signals() below also closes the window where a signal
+        # arriving during a slow initializer (Celery's process_initializer)
+        # would be handled with the inherited flag still set.
+        clear_terminate_handler_fired()
+
         if hasattr(self.inq, '_writer'):
             self.inq._writer.close()
         if hasattr(self.outq, '_reader'):
@@ -998,6 +1046,28 @@
                  max_memory_per_child=None,
                  enable_timeouts=False,
                  **kwargs):
+        # bool subclasses int; processes=True would silently spawn 1 worker,
+        # and timeout/soft_timeout/lost_worker_timeout=True would become 1s.
+        for name, value in (
+            ("processes", processes),
+            ("maxtasksperchild", maxtasksperchild),
+            ("timeout", timeout),
+            ("soft_timeout", soft_timeout),
+            ("lost_worker_timeout", lost_worker_timeout),
+            ("max_memory_per_child", max_memory_per_child),
+            ("max_restarts", max_restarts),
+            ("max_restart_freq", max_restart_freq),
+        ):
+            if isinstance(value, bool):
+                raise TypeError(
+                    f"{name} must be an int or float, not bool (got {value!r})"
+                )
+        if processes is not None and processes < 1:
+            raise ValueError("Number of processes must be at least 1")
+        if maxtasksperchild is not None and (
+                not isinstance(maxtasksperchild, int) or maxtasksperchild < 1):
+            raise ValueError("maxtasksperchild must be a positive int or None")
+
         self._ctx = context or get_context()
         self.synack = synack
         self._setup_queues()
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/billiard-4.3.0/billiard/process.py 
new/billiard-4.3.1/billiard/process.py
--- old/billiard-4.3.0/billiard/process.py      2026-09-17 08:52:08.070341000 
+0200
+++ new/billiard-4.3.1/billiard/process.py      2026-10-05 08:12:40.526094200 
+0200
@@ -88,6 +88,7 @@
         self._config = _current_process._config.copy()
         self._parent_pid = os.getpid()
         self._popen = None
+        self._closed = False
         self._target = target
         self._args = tuple(args)
         self._kwargs = dict(kwargs)
@@ -102,6 +103,10 @@
         
         self._controlled_termination = False
 
+    def _check_closed(self):
+        if self._closed:
+            raise ValueError("process object is closed")
+
     def run(self):
         '''
         Method to be run in sub-process; can be overridden in sub-class
@@ -113,6 +118,7 @@
         '''
         Start child process
         '''
+        self._check_closed()
         assert self._popen is None, 'cannot start a process twice'
         assert self._parent_pid == os.getpid(), \
             'can only start a process object created by current process'
@@ -122,13 +128,32 @@
         _children.add(self)
 
     def close(self):
+        '''
+        Close the Process object and release the resources it holds.
+
+        It is an error to call this method while the child is still running.
+        Other methods and properties then raise ValueError.
+        '''
         if self._popen is not None:
+            # poll() also reports None when the child was already reaped by
+            # someone else (ECHILD, e.g. a SIGCHLD handler calling
+            # waitpid(-1)), so such a process can never be closed; this is
+            # the same in multiprocessing.
+            if self._popen.poll() is None:
+                raise ValueError(
+                    "Cannot close a process while it is still running. "
+                    "You should first call join() or terminate().")
             self._popen.close()
+            self._popen = None
+            del self._sentinel
+            _children.discard(self)
+        self._closed = True
 
     def terminate(self):
         '''
         Terminate process; sends SIGTERM signal or uses TerminateProcess()
         '''
+        self._check_closed()
         self._popen.terminate()
         
     def terminate_controlled(self):
@@ -139,17 +164,20 @@
         '''
         Wait until child process terminates
         '''
+        self._check_closed()
         assert self._parent_pid == os.getpid(), 'can only join a child process'
         assert self._popen is not None, 'can only join a started process'
         res = self._popen.wait(timeout)
         if res is not None:
             _children.discard(self)
-            self.close()
+            # only release the sentinel: callers still read exitcode after join
+            self._popen.close()
 
     def is_alive(self):
         '''
         Return whether process is alive
         '''
+        self._check_closed()
         if self is _current_process:
             return True
         assert self._parent_pid == os.getpid(), 'can only test a child process'
@@ -203,6 +231,7 @@
         '''
         Return exit code of process or `None` if it has yet to stop
         '''
+        self._check_closed()
         if self._popen is None:
             return self._popen
         return self._popen.poll()
@@ -212,6 +241,7 @@
         '''
         Return identifier (PID) of process or `None` if it has yet to start
         '''
+        self._check_closed()
         if self is _current_process:
             return os.getpid()
         else:
@@ -225,6 +255,7 @@
         Return a file descriptor (Unix) or handle (Windows) suitable for
         waiting for process termination.
         '''
+        self._check_closed()
         try:
             return self._sentinel
         except AttributeError:
@@ -258,6 +289,8 @@
     def __repr__(self):
         if self is _current_process:
             status = 'started'
+        elif self._closed:
+            status = 'closed'
         elif self._parent_pid != os.getpid():
             status = 'unknown'
         elif self._popen is None:
@@ -375,6 +408,7 @@
         self._name = 'MainProcess'
         self._parent_pid = None
         self._popen = None
+        self._closed = False
         self._config = {'authkey': AuthenticationString(os.urandom(32)),
                         'semprefix': '/mp'}
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/billiard-4.3.0/billiard/queues.py 
new/billiard-4.3.1/billiard/queues.py
--- old/billiard-4.3.0/billiard/queues.py       2026-09-17 08:52:08.070471000 
+0200
+++ new/billiard-4.3.1/billiard/queues.py       2026-10-05 08:12:40.526342400 
+0200
@@ -83,7 +83,8 @@
         self._poll = self._reader.poll
 
     def put(self, obj, block=True, timeout=None):
-        assert not self._closed
+        if self._closed:
+            raise ValueError(f"Queue {self!r} is closed")
         if not self._sem.acquire(block, timeout):
             raise Full
 
@@ -94,6 +95,8 @@
             self._notempty.notify()
 
     def get(self, block=True, timeout=None):
+        if self._closed:
+            raise ValueError(f"Queue {self!r} is closed")
         if block and timeout is None:
             with self._rlock:
                 res = self._recv_bytes()
@@ -147,7 +150,8 @@
 
     def join_thread(self):
         debug('Queue.join_thread()')
-        assert self._closed
+        if not self._closed:
+            raise ValueError(f"Queue {self!r} not closed")
         if self._jointhread:
             self._jointhread()
 
@@ -304,7 +308,8 @@
         self._cond, self._unfinished_tasks = state[-2:]
 
     def put(self, obj, block=True, timeout=None):
-        assert not self._closed
+        if self._closed:
+            raise ValueError(f"Queue {self!r} is closed")
         if not self._sem.acquire(block, timeout):
             raise Full
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/billiard-4.3.0/billiard.egg-info/PKG-INFO 
new/billiard-4.3.1/billiard.egg-info/PKG-INFO
--- old/billiard-4.3.0/billiard.egg-info/PKG-INFO       2026-09-17 
10:35:43.561669800 +0200
+++ new/billiard-4.3.1/billiard.egg-info/PKG-INFO       2026-10-05 
08:37:27.336799600 +0200
@@ -1,6 +1,6 @@
 Metadata-Version: 2.4
 Name: billiard
-Version: 4.3.0
+Version: 4.3.1
 Summary: Python multiprocessing fork with improvements and bugfixes
 Home-page: https://github.com/celery/billiard
 Author: R Oudkerk / Python Software Foundation
@@ -47,7 +47,7 @@
 
 |build-status-lin| |build-status-win| |license| |wheel| |pyversion| |pyimp|
 
-:Version: 4.3.0
+:Version: 4.3.1
 :Web: https://billiard.readthedocs.io
 :Download: https://pypi.org/project/billiard/
 :Source: https://github.com/celery/billiard/
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/billiard-4.3.0/billiard.egg-info/SOURCES.txt 
new/billiard-4.3.1/billiard.egg-info/SOURCES.txt
--- old/billiard-4.3.0/billiard.egg-info/SOURCES.txt    2026-09-17 
10:35:43.569574000 +0200
+++ new/billiard-4.3.1/billiard.egg-info/SOURCES.txt    2026-10-05 
08:37:27.344692700 +0200
@@ -64,6 +64,7 @@
 t/integration/tests/__init__.py
 t/integration/tests/test_multiprocessing.py
 t/unit/__init__.py
+t/unit/conftest.py
 t/unit/test_common.py
 t/unit/test_compat.py
 t/unit/test_context.py
@@ -71,6 +72,8 @@
 t/unit/test_einfo.py
 t/unit/test_package.py
 t/unit/test_pool.py
+t/unit/test_process.py
+t/unit/test_queues.py
 t/unit/test_spawn.py
 t/unit/test_values.py
 t/unit/test_win32.py
\ No newline at end of file
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/billiard-4.3.0/t/unit/conftest.py 
new/billiard-4.3.1/t/unit/conftest.py
--- old/billiard-4.3.0/t/unit/conftest.py       1970-01-01 01:00:00.000000000 
+0100
+++ new/billiard-4.3.1/t/unit/conftest.py       2026-10-05 08:12:40.526496000 
+0200
@@ -0,0 +1,21 @@
+import pytest
+
+from billiard import common
+
+
[email protected](autouse=True)
+def reset_terminate_handler_fired():
+    """Keep ``common._should_have_exited`` from leaking between tests.
+
+    The flag is a module global, and any test that calls ``_shutdown_cleanup``
+    in the test process -- ``test_common.test_shutdown_handler`` does, with
+    ``sys.exit`` patched out -- leaves it True for the rest of the session.
+    Nothing resets it in-process, so a later test exercising the workloop
+    would silently take the shutdown branch and pass or fail for a reason
+    that has nothing to do with what it is testing.
+    """
+    previous = common._should_have_exited[0]
+    try:
+        yield
+    finally:
+        common._should_have_exited[0] = previous
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/billiard-4.3.0/t/unit/test_common.py 
new/billiard-4.3.1/t/unit/test_common.py
--- old/billiard-4.3.0/t/unit/test_common.py    2026-09-17 08:52:08.072662000 
+0200
+++ new/billiard-4.3.1/t/unit/test_common.py    2026-10-05 08:12:40.526700500 
+0200
@@ -10,8 +10,10 @@
 
 from billiard.common import (
     _shutdown_cleanup,
+    clear_terminate_handler_fired,
     reset_signals,
     restart_state,
+    terminate_handler_fired,
 )
 
 
@@ -79,6 +81,44 @@
                     yield GET, SET
 
 
[email protected]_win32()
+class test_terminate_handler_fired:
+    """The whole contract of the flag the workloop branches on.
+
+    ``_should_have_exited`` is restored around every test by the autouse
+    fixture in ``t/unit/conftest.py``; without it the first assertion here
+    would depend on which tests ran before.
+    """
+
+    def test_false_before_any_handler_runs(self):
+        assert terminate_handler_fired() is False
+
+    def test_true_after_shutdown_cleanup(self):
+        with patch('sys.exit'):
+            _shutdown_cleanup(signal.SIGTERM, Mock())
+        assert terminate_handler_fired() is True
+
+    def test_false_again_after_clear(self):
+        with patch('sys.exit'):
+            _shutdown_cleanup(signal.SIGTERM, Mock())
+        clear_terminate_handler_fired()
+        assert terminate_handler_fired() is False
+
+    def test_reset_signals_does_not_clear_it(self):
+        """Clearing is a separate, explicitly called concern.
+
+        A child clears the flag via clear_terminate_handler_fired() at the
+        top of Worker.after_fork(), not as a hidden side effect of
+        installing signal handlers. Downstream callers of reset_signals()
+        should not have shutdown state changed underneath them.
+        """
+        with patch('sys.exit'):
+            _shutdown_cleanup(signal.SIGTERM, Mock())
+        with patch('signal.signal'), patch('signal.getsignal'):
+            reset_signals()
+        assert terminate_handler_fired() is True
+
+
 class test_restart_state:
 
     def test_raises(self):
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/billiard-4.3.0/t/unit/test_pool.py 
new/billiard-4.3.1/t/unit/test_pool.py
--- old/billiard-4.3.0/t/unit/test_pool.py      2026-09-17 08:52:08.073204800 
+0200
+++ new/billiard-4.3.1/t/unit/test_pool.py      2026-10-05 08:12:40.527043000 
+0200
@@ -6,7 +6,10 @@
 
 import billiard.pool
 from billiard import get_context
-from billiard.exceptions import TimeLimitExceeded
+from billiard.einfo import ExceptionWithTraceback
+from billiard.exceptions import TimeLimitExceeded, WorkerLostError
+
+from t import skip
 
 
 def func(x):
@@ -30,6 +33,39 @@
 def raise_system_exit():
     raise SystemExit(1)
 
+
+def announce_pid_then_sleep(marker):
+    """Publish the pid of the process actually running this job, then block.
+
+    Written to a temporary name and renamed into place: os.replace is atomic,
+    so the parent cannot observe the marker existing but still empty.
+
+    The sleep must outlast the parent's result.get() timeout by a wide margin.
+    If the two were comparable, a kill that was slow or ineffective would let
+    the job finish at about the moment the get gave up, and the test would
+    report whichever won rather than the behaviour it is checking.
+    """
+    with open(marker + '.tmp', 'w') as fh:
+        fh.write(str(os.getpid()))
+    os.replace(marker + '.tmp', marker)
+    time.sleep(300)
+
+def announce_pid_then_fail_in_cleanup(marker):
+    """As above, but the job's own cleanup raises on the way out.
+
+    A failing ``finally`` (or ``__exit__``) replaces the in-flight shutdown
+    SystemExit with its own exception, so what arrives at the workloop is a
+    RuntimeError. The job is no less unfinished for it.
+    """
+    try:
+        with open(marker + '.tmp', 'w') as fh:
+            fh.write(str(os.getpid()))
+        os.replace(marker + '.tmp', marker)
+        time.sleep(300)
+    finally:
+        raise RuntimeError('cleanup failed while shutting down')
+
+
 class test_pool:
     def test_memory_error_from_callback_propagates(self):
         def callback(value):
@@ -94,6 +130,75 @@
             pool.terminate()
             pool.join()
 
+    @skip.if_win32()
+    @pytest.mark.parametrize('job', [
+        announce_pid_then_sleep,
+        announce_pid_then_fail_in_cleanup,
+    ], ids=['plain', 'cleanup_raises'])
+    def test_worker_terminated_mid_job_is_lost_not_completed(self, tmp_path,
+                                                             job):
+        """A worker told to exit did not finish its job.
+
+        The SystemExit that terminates a worker is raised by billiard's own
+        signal handler, inside whatever the worker happened to be running. If
+        the worker reports that as the job's result, the job looks completed
+        (with a failure) rather than interrupted -- and a caller that
+        acknowledges work only once it is done, such as Celery with
+        task_acks_late, then acknowledges a job nothing ever ran to the end.
+
+        Run for two shapes of job. In the 'cleanup_raises' case the job's own
+        finally raises while that SystemExit unwinds, so the exception
+        reaching the workloop is a RuntimeError rather than the SystemExit --
+        which is why the guard cannot be narrowed to SystemExit.
+
+        Unix only, and deliberately so: on Windows os.kill maps to
+        TerminateProcess, which never runs the Python-level handler. The
+        worker would still die and the parent would still report the job
+        lost, so this test would pass there without terminate_handler_fired()
+        ever being consulted -- green, but not coverage.
+        """
+        pool = billiard.pool.Pool(1)
+        try:
+            marker = str(tmp_path / 'started')
+            result = pool.apply_async(job, (marker,))
+
+            # Take the pid from the child itself rather than from
+            # pool._pool[0]: that indexes the pool's current roster, which is
+            # only the process running this job as long as the pool has one
+            # worker and the Supervisor has had no cause to replace it.
+            deadline = time.monotonic() + 10
+            while time.monotonic() < deadline and not os.path.exists(marker):
+                time.sleep(0.1)
+            assert os.path.exists(marker), 'worker never started the job'
+            with open(marker) as fh:
+                worker_pid = int(fh.read())
+
+            os.kill(worker_pid, billiard.pool.TERM_SIGNAL)
+
+            # ApplyResult.get() re-raises ExceptionInfo.exception. The parent
+            # synthesises this failure locally in Pool.mark_as_worker_lost, so
+            # nothing is pickled and what surfaces is the
+            # ExceptionWithTraceback wrapper, whose .exc holds the real
+            # WorkerLostError -- `pytest.raises(WorkerLostError)` would NOT
+            # match here. A result that had travelled back through the queue
+            # would have been rebuilt by einfo.rebuild_exc into the bare
+            # exception, hence the tolerant unwrap below.
+            #
+            # Naming both types keeps a TimeoutError -- the parent never
+            # noticing the dead worker at all -- failing at the raises clause
+            # with an accurate message, instead of reaching the assert.
+            #
+            # Generous timeout: the parent notices the dead worker from its
+            # supervisor loop, so this bounds a failure rather than a wait.
+            with pytest.raises(
+                    (WorkerLostError, ExceptionWithTraceback)) as excinfo:
+                result.get(timeout=30)
+            exc = getattr(excinfo.value, 'exc', excinfo.value)
+            assert isinstance(exc, WorkerLostError)
+        finally:
+            pool.terminate()
+            pool.join()
+
     def test_hard_timeout_does_not_stall_pool(self):
         pool = billiard.pool.Pool(1, timeout=1)
         try:
@@ -184,3 +289,34 @@
         # retrieved.
         for i, result in enumerate(results):
             assert result.get() == i * 2
+
+
+    def test_rejects_fewer_than_one_process(self):
+        for processes in (0, -1):
+            with pytest.raises(ValueError, match="at least 1"):
+                billiard.pool.Pool(processes=processes)
+
+    def test_rejects_non_positive_maxtasksperchild(self):
+        # Worker only asserted this, so it failed with a bare AssertionError,
+        # and under python -O the pool kept replacing workers that exited
+        # before running a task.
+        for maxtasksperchild in (0, -1, 1.5):
+            with pytest.raises(ValueError, match="maxtasksperchild"):
+                billiard.pool.Pool(processes=1,
+                                   maxtasksperchild=maxtasksperchild)
+
+    def test_rejects_bool_for_numeric_pool_args(self):
+        """bool subclasses int; processes=True must not silently spawn 1 
worker."""
+        for kwargs in (
+            {"processes": True},
+            {"processes": False},
+            {"maxtasksperchild": True},
+            {"timeout": True},
+            {"soft_timeout": True},
+            {"lost_worker_timeout": True},
+            {"max_memory_per_child": True},
+            {"max_restarts": True},
+            {"max_restart_freq": True},
+        ):
+            with pytest.raises(TypeError, match="bool"):
+                billiard.pool.Pool(**kwargs)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/billiard-4.3.0/t/unit/test_process.py 
new/billiard-4.3.1/t/unit/test_process.py
--- old/billiard-4.3.0/t/unit/test_process.py   1970-01-01 01:00:00.000000000 
+0100
+++ new/billiard-4.3.1/t/unit/test_process.py   2026-10-05 08:12:40.527197000 
+0200
@@ -0,0 +1,44 @@
+import time
+
+import pytest
+
+from billiard import Process
+
+
+class test_Process:
+
+    def test_close_while_running_raises(self):
+        # close() used to close the sentinel of a live child, after which
+        # join() failed with a TypeError and the child was never reaped.
+        p = Process(target=time.sleep, args=(0.5,))
+        p.start()
+        try:
+            with pytest.raises(ValueError, match='still running'):
+                p.close()
+        finally:
+            p.join(5)
+        assert p.exitcode == 0
+        p.close()
+
+    def test_join_keeps_the_process_usable(self):
+        # join() releases the sentinel itself, but the pool still reads
+        # exitcode and pid afterwards, so it must not close the object.
+        p = Process(target=time.sleep, args=(0,))
+        p.start()
+        p.join(5)
+        assert p.exitcode == 0
+        assert p.pid is not None
+        assert not p.is_alive()
+
+    def test_closed_process(self):
+        p = Process(target=time.sleep, args=(0,))
+        p.start()
+        p.join(5)
+        p.close()
+        assert 'closed' in repr(p)
+        for use in (p.start, p.join, p.is_alive, p.terminate,
+                    lambda: p.exitcode, lambda: p.pid,
+                    lambda: p.sentinel):
+            with pytest.raises(ValueError, match='process object is closed'):
+                use()
+        p.close()  # closing twice is fine
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/billiard-4.3.0/t/unit/test_queues.py 
new/billiard-4.3.1/t/unit/test_queues.py
--- old/billiard-4.3.0/t/unit/test_queues.py    1970-01-01 01:00:00.000000000 
+0100
+++ new/billiard-4.3.1/t/unit/test_queues.py    2026-10-05 08:12:40.527333300 
+0200
@@ -0,0 +1,36 @@
+import pytest
+
+from billiard import JoinableQueue, Queue
+
+
+class test_closed_queue:
+
+    @pytest.mark.parametrize('queue_type', [Queue, JoinableQueue])
+    def test_put_raises_value_error(self, queue_type):
+        # put() only asserted this, so under python -O the item was
+        # accepted and then lost by the feeder thread.
+        q = queue_type()
+        q.close()
+        with pytest.raises(ValueError, match='is closed'):
+            q.put(1)
+        with pytest.raises(ValueError, match='is closed'):
+            q.put_nowait(1)
+
+    @pytest.mark.parametrize('queue_type', [Queue, JoinableQueue])
+    def test_get_raises_value_error(self, queue_type):
+        q = queue_type()
+        q.close()
+        with pytest.raises(ValueError, match='is closed'):
+            q.get()
+        with pytest.raises(ValueError, match='is closed'):
+            q.get(timeout=0.1)
+        with pytest.raises(ValueError, match='is closed'):
+            q.get_nowait()
+
+    @pytest.mark.parametrize('queue_type', [Queue, JoinableQueue])
+    def test_join_thread_requires_closed_queue(self, queue_type):
+        q = queue_type()
+        with pytest.raises(ValueError, match='not closed'):
+            q.join_thread()
+        q.close()
+        q.join_thread()

Reply via email to