Description
Bug report
Bug description:
I hit a flaky error in an actual application neutrinoceros/inifix#421, which only seems to happen with free-threaded interpreters (at least 3.13.5t and 3.14.0b3t, seen on ubuntu and macOS). I got the reproducer down to a few lines of typer
(which extends on click). I'm aware that it's not appropriate to report bugs in third-party libraries to CPython, and I apologize for bringing it here in its current state, but before I attempt to reduce it further, I'd like to start the discussion here if it's not too much of a bother, because I do not know enough yet to state for sure that the actual bug is downstream, and not in the interpreter itself.
# t.py
from concurrent.futures import ThreadPoolExecutor
import pytest
from typer import Exit, Typer
from typer.testing import CliRunner
app = Typer()
@app.command()
def cmd(_: list[str]) -> None:
with ThreadPoolExecutor() as executor:
executor.submit(lambda: None).result()
raise Exit(code=1)
runner = CliRunner()
@pytest.mark.parametrize("_", range(10_000))
def test_cmd(_):
runner.invoke(app, ["cmd"])
I run the following command
❯ pytest t.py -x
And get the following output (number of passing tests varies)
warning: Using incompatible environment (`.venv`) due to `--no-sync` (The project environment's Python version does not satisfy the request: `Python >=3.11`)
========================================== test session starts ===========================================
platform darwin -- Python 3.14.0b3, pytest-8.4.1, pluggy-1.6.0
rootdir: /Users/clm/dev/inifix
configfile: pyproject.toml
plugins: hypothesis-6.135.11
collected 10000 items
t.py ............................................................................................. [ 0%]
.................................................................................................. [ 1%]
.................................................................................................. [ 2%]
.................................................................................................. [ 3%]
.................................................................................................. [ 4%]
.................................................................................................. [ 5%]
.................................................................................................. [ 6%]
.................................................................................................. [ 7%]
.................................................................................................. [ 8%]
.................................................................................................. [ 9%]
.................................................................................................. [ 10%]
.................................................................................................. [ 11%]
.................................................................................................. [ 12%]
.................................................................................................. [ 13%]
.................................................................................................. [ 14%]
.................................................................................................. [ 15%]
.................................................................................................. [ 16%]
.................................................................................................. [ 17%]
.................................................................................................. [ 18%]
.................................................................................................. [ 19%]
.................................................................................................. [ 20%]
.................................................................................................. [ 21%]
.................................................................................................. [ 22%]
.................................................................................................. [ 23%]
.................................................................................................. [ 24%]
.................................................................................................. [ 25%]
.................................................................................................. [ 26%]
.................................................................................................. [ 27%]
.................................................................................................. [ 28%]
.................................................................................................. [ 29%]
.................................................................................................. [ 30%]
.................................................................................................. [ 31%]
.................................................................................................. [ 32%]
.................................................................................................. [ 33%]
.................................................................................................. [ 34%]
.................................................................................................. [ 35%]
.................................................................................................. [ 36%]
.................................................................................................. [ 37%]
.................................................................................................. [ 38%]
.................................................................................................. [ 39%]
.................................................................................................. [ 40%]
.................................................................................................. [ 41%]
.................................................................................................. [ 42%]
.................................................................................................. [ 43%]
.................................................................................................. [ 44%]
.................................................................................................. [ 45%]
.................................................................................................. [ 46%]
.................................................................................................. [ 46%]
.................................................................................................. [ 47%]
.................................................................................................. [ 48%]
.................................................................................................. [ 49%]
.................................................................................................. [ 50%]
.................................................................................................. [ 51%]
.................................................................................................. [ 52%]
.................................................................................................. [ 53%]
.................................................................................................. [ 54%]
.................................................................................................. [ 55%]
.................................................................................................. [ 56%]
.................................................................................................. [ 57%]
.................................................................................................. [ 58%]
.................................................................................................. [ 59%]
.................................................................................................. [ 60%]
.................................................................................................. [ 61%]
.................................................................................................. [ 62%]
.................................................................................................. [ 63%]
.................................................................................................. [ 64%]
.................................................................................................. [ 65%]
.................................................................................................. [ 66%]
.................................................................................................. [ 67%]
............................................F
================================================ FAILURES ================================================
_____________________________________________ test_cmd[6801] _____________________________________________
item = <Option help>
def sort_key(item: Parameter) -> tuple[bool, float]:
try:
> idx: float = invocation_order.index(item)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
E ValueError: list.index(x): x not in list
_click/src/click/core.py:133: ValueError
During handling of the above exception, another exception occurred:
self = <click.testing.BytesIOCopy object at 0x498c711ff00>
def flush(self) -> None:
super().flush()
> self.copy_to.flush()
E ValueError: I/O operation on closed file.
_click/src/click/testing.py:82: ValueError
The above exception was the direct cause of the following exception:
cls = <class '_pytest.runner.CallInfo'>
func = <function call_and_report.<locals>.<lambda> at 0x498c9a760c0>, when = 'call'
reraise = (<class '_pytest.outcomes.Exit'>, <class 'KeyboardInterrupt'>)
@classmethod
def from_call(
cls,
func: Callable[[], TResult],
when: Literal["collect", "setup", "call", "teardown"],
reraise: type[BaseException] | tuple[type[BaseException], ...] | None = None,
) -> CallInfo[TResult]:
"""Call func, wrapping the result in a CallInfo.
:param func:
The function to call. Called without arguments.
:type func: Callable[[], _pytest.runner.TResult]
:param when:
The phase in which the function is called.
:param reraise:
Exception or exceptions that shall propagate if raised by the
function, instead of being wrapped in the CallInfo.
"""
excinfo = None
instant = timing.Instant()
try:
> result: TResult | None = func()
^^^^^^
.venv/lib/python3.14t/site-packages/_pytest/runner.py:344:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
.venv/lib/python3.14t/site-packages/_pytest/runner.py:246: in <lambda>
lambda: runtest_hook(item=item, **kwds), when=when, reraise=reraise
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
.venv/lib/python3.14t/site-packages/pluggy/_hooks.py:512: in __call__
return self._hookexec(self.name, self._hookimpls.copy(), kwargs, firstresult)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
.venv/lib/python3.14t/site-packages/pluggy/_manager.py:120: in _hookexec
return self._inner_hookexec(hook_name, methods, kwargs, firstresult)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
.venv/lib/python3.14t/site-packages/_pytest/logging.py:850: in pytest_runtest_call
yield
.venv/lib/python3.14t/site-packages/_pytest/capture.py:900: in pytest_runtest_call
return (yield)
^^^^^
.venv/lib/python3.14t/site-packages/pluggy/_callers.py:53: in run_old_style_hookwrapper
return result.get_result()
^^^^^^^^^^^^^^^^^^^
.venv/lib/python3.14t/site-packages/pluggy/_callers.py:38: in run_old_style_hookwrapper
res = yield
^^^^^
.venv/lib/python3.14t/site-packages/_pytest/skipping.py:263: in pytest_runtest_call
return (yield)
^^^^^
.venv/lib/python3.14t/site-packages/_pytest/unraisableexception.py:158: in pytest_runtest_call
collect_unraisable(item.config)
.venv/lib/python3.14t/site-packages/_pytest/unraisableexception.py:79: in collect_unraisable
raise errors[0]
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
config = <_pytest.config.Config object at 0x498bc589f10>
def collect_unraisable(config: Config) -> None:
pop_unraisable = config.stash[unraisable_exceptions].pop
errors: list[pytest.PytestUnraisableExceptionWarning | RuntimeError] = []
meta = None
hook_error = None
try:
while True:
try:
meta = pop_unraisable()
except IndexError:
break
if isinstance(meta, BaseException):
hook_error = RuntimeError("Failed to process unraisable exception")
hook_error.__cause__ = meta
errors.append(hook_error)
continue
msg = meta.msg
try:
> warnings.warn(pytest.PytestUnraisableExceptionWarning(msg))
E pytest.PytestUnraisableExceptionWarning: Exception ignored while finalizing file <_NamedTextIOWrapper name='<stderr>' mode='w' encoding='utf-8'>: None
.venv/lib/python3.14t/site-packages/_pytest/unraisableexception.py:67: PytestUnraisableExceptionWarning
======================================== short test summary info =========================================
FAILED t.py::test_cmd[6801] - pytest.PytestUnraisableExceptionWarning: Exception ignored while finalizing file <_NamedTextIOWrapper...
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! stopping after 1 failures !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
===================================== 1 failed, 6801 passed in 6.13s =====================================
Assuming the details do not matter too much (this is a big if), the exception appears to be raised within click.testing.BytesIOCopy
, which is small enough that I can reproduce it entirely here:
import io
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from _typeshed import ReadableBuffer
class BytesIOCopy(io.BytesIO):
def __init__(self, copy_to: io.BytesIO) -> None:
super().__init__()
self.copy_to = copy_to
def flush(self) -> None:
super().flush()
self.copy_to.flush()
def write(self, b: ReadableBuffer) -> int:
self.copy_to.write(b)
return super().write(b)
This is used within another small class, StreamMixer
, which is how click
combines stdin
, stdout
and stderr
in its testing framework
import io
class StreamMixer:
def __init__(self) -> None:
self.output: io.BytesIO = io.BytesIO()
self.stdout: io.BytesIO = BytesIOCopy(copy_to=self.output)
self.stderr: io.BytesIO = BytesIOCopy(copy_to=self.output)
So it looks like BytesIOCopy
can race with the io.BytesIO
object it references, as the former may flush
after the latter is already closed. I searched click
and typer
for explicit close()
calls and didn't find anything relevant, so I'm assuming that the interpreter (or garbage collector ?) may be doing it under the hood.
I guess at this point my questions are:
- what's the lifetime of
io.BytesIO
instances in a multi-threaded context ? - is
click.testing.BytesIOCopy
's implementation obviously not thread-safe ?
CPython versions tested on:
3.13
Operating systems tested on:
macOS