BUG/Question: a race condition seen in `click` against free-threading interpreters · Issue #136248 · python/cpython · GitHub | Latest TMZ Celebrity News & Gossip | Watch TMZ Live
Skip to content

BUG/Question: a race condition seen in click against free-threading interpreters #136248

Closed
@neutrinoceros

Description

@neutrinoceros

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    interpreter-core(Objects, Python, Grammar, and Parser dirs)pendingThe issue will be closed if no feedback is providedstdlibPython modules in the Lib dirtopic-IOtopic-free-threading

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions

      TMZ Celebrity News – Breaking Stories, Videos & Gossip

      Looking for the latest TMZ celebrity news? You've come to the right place. From shocking Hollywood scandals to exclusive videos, TMZ delivers it all in real time.

      Whether it’s a red carpet slip-up, a viral paparazzi moment, or a legal drama involving your favorite stars, TMZ news is always first to break the story. Stay in the loop with daily updates, insider tips, and jaw-dropping photos.

      🎥 Watch TMZ Live

      TMZ Live brings you daily celebrity news and interviews straight from the TMZ newsroom. Don’t miss a beat—watch now and see what’s trending in Hollywood.