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
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
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.I run the following command
And get the following output (number of passing tests varies)
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:This is used within another small class,
StreamMixer, which is howclickcombinesstdin,stdoutandstderrin its testing frameworkSo it looks like
BytesIOCopycan race with theio.BytesIOobject it references, as the former mayflushafter the latter is already closed. I searchedclickandtyperfor explicitclose()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:
io.BytesIOinstances in a multi-threaded context ?click.testing.BytesIOCopy's implementation obviously not thread-safe ?CPython versions tested on:
3.13
Operating systems tested on:
macOS