Skip to content

A child holding probe pipes can outlast the query timeout #196

Closed

Description

@gaborbernat

Interpreter discovery can hang after a candidate times out if that candidate left a child process holding stdout or stderr open.

Affected code

src/python_discovery/_cached_py_info.py:256 at the reported commit. The reproducer also returns BUG on current main 004294957ed227fe7e52b2864952ba09fd28b220.

Mechanism

The parent probe times out, but the pipe read waits for the candidate's surviving child to close its inherited descriptors.

Reproduction

Check out the reported commit or current main. Save the files below as named, then build and run:

docker build -t python-discovery-audit -f - . <<'DOCKERFILE'
FROM python:3.15-bookworm
RUN python -m venv /opt/venv
ENV PATH=/opt/venv/bin:$PATH
COPY . /src
WORKDIR /src
ENV SETUPTOOLS_SCM_PRETEND_VERSION=1.9.1
RUN pip install -e . --group test
DOCKERFILE
docker run --rm --network=none --memory=2g -u 0 -w /src \
  -v "$PWD/repro:/tmp/repro:ro" -v "$PWD/repro.d:/tmp/repro.d:ro" \
  python-discovery-audit /tmp/repro

repro

#!/usr/bin/env python3
import contextlib
import os
import shutil
import signal
import subprocess
import sys
import tempfile

# set up: a PATH directory whose only entry is the launcher
path_dir = tempfile.mkdtemp()
launcher = shutil.copy("/tmp/repro.d/python3.11", path_dir)
os.chmod(launcher, 0o755)

# trigger: PY_DISCOVERY_TIMEOUT=1, so the probe (one attempt, one retry) should give up on python3.11 after about 2 s
caller = subprocess.Popen(
    [sys.executable, "/tmp/repro.d/find_python.py", path_dir, "1"],
    stdout=subprocess.PIPE,
    stderr=subprocess.STDOUT,
    text=True,
    start_new_session=True,  # the session holds the caller and everything the probe starts
)
try:
    output, _ = caller.communicate(timeout=10)
    hung = False
except subprocess.TimeoutExpired:
    hung = True
    print("caller still running after 10 s; its session:")
    print(
        subprocess.run(
            ["ps", "-o", "pid,ppid,stat,etime,args", "--sid", str(caller.pid)], capture_output=True, text=True
        ).stdout
    )
    caller.terminate()  # find_python.py prints its stack on SIGTERM
    output, _ = caller.communicate()
with contextlib.suppress(ProcessLookupError):
    os.killpg(caller.pid, signal.SIGKILL)  # the launcher's sleep survives either way

# check: a hang, at the communicate() that follows process.kill() (_cached_py_info.py:256)
print(output)
print("BUG" if hung and "line 256 in _run_subprocess" in output else "OK")

repro.d/find_python.py

import faulthandler
import logging
import signal
import sys
import tempfile
import time
from pathlib import Path

from python_discovery import DiskCache, get_interpreter

path_dir, probe_timeout = sys.argv[1:]
faulthandler.register(signal.SIGTERM, chain=True)  # diagnostic: on SIGTERM, print where this process is, then exit
logging.basicConfig(level=logging.INFO)
logging.getLogger("python_discovery").setLevel(logging.DEBUG)  # logs every command the library spawns

started = time.monotonic()
found = get_interpreter(
    "python3.11",
    cache=DiskCache(Path(tempfile.mkdtemp())),
    env={"PATH": path_dir, "HOME": "/nonexistent", "PY_DISCOVERY_TIMEOUT": probe_timeout},
)
print(f"get_interpreter returned after {time.monotonic() - started:.1f}s: {found}")

repro.d/python3.11

#!/bin/sh
# A launcher for Python 3.11 that does not `exec`: each command runs as a child of this shell.
# The sleep stands in for a start-up step that is stuck (hung network mount, licence server, ...).
/bin/sleep 3600
/usr/bin/python3.11 "$@"

Output on 004294957ed227fe7e52b2864952ba09fd28b220:

caller still running after 10 s; its session:
    PID    PPID STAT     ELAPSED COMMAND
      7       1 Ss         00:10 /opt/venv/bin/python3 /tmp/repro.d/find_python.py /tmp/tmp2cg3mkf3 1
      9       7 Z          00:09 [python3.11] <defunct>
     10       1 S          00:09 /bin/sleep 3600

INFO:python_discovery._discovery:find interpreter for spec PythonSpec(major=3, minor=11, free_threaded=False)
DEBUG:python_discovery._cached_py_info:get interpreter info via cmd: /usr/local/bin/python3.15 /src/src/python_discovery/_py_info_collect.py 57e5b384972dc3646056bf5411cfee6d df70c167e993af81581e1bbfcf8ad199
DEBUG:python_discovery._cache:wrote python info at /tmp/tmp4qe0xqg8/py_info/4/755e5f8e9348854516c9513236b846adb15e9a21490fd9dc391537946af27457.json
DEBUG:python_discovery._compat:filesystem is case-sensitive
INFO:python_discovery._discovery:proposed PythonInfo(spec=CPython3.15.2.final.0-64-x86_64, system=/usr/local/bin/python3.15, exe=/opt/venv/bin/python3, platform=linux, version='3.15.2 (main, Oct 28 2026, 04:34:17) [GCC 12.2.0]', encoding_fs_io=utf-8-utf-8)
DEBUG:python_discovery._discovery:discover PATH[0]=/tmp/tmp2cg3mkf3
DEBUG:python_discovery._cached_py_info:get interpreter info via cmd: /tmp/tmp2cg3mkf3/python3.11 /src/src/python_discovery/_py_info_collect.py 27f4d764ee6281b282bc926a23797e85 595e55f77f9b1cc309f6fc54928c15bb
Current thread 0x00007fd5c2824b80 [python3] (most recent call first):
  File "/usr/local/lib/python3.15/selectors.py", line 398 in select
  File "/usr/local/lib/python3.15/subprocess.py", line 291 in _communicate_io_posix
  File "/usr/local/lib/python3.15/subprocess.py", line 2412 in _communicate
  File "/usr/local/lib/python3.15/subprocess.py", line 1395 in communicate
  File "/src/src/python_discovery/_cached_py_info.py", line 256 in _run_subprocess
  File "/src/src/python_discovery/_cached_py_info.py", line 127 in _get_via_file_cache
  File "/src/src/python_discovery/_cached_py_info.py", line 87 in _get_from_cache
  File "/src/src/python_discovery/_cached_py_info.py", line 65 in from_exe
  File "/src/src/python_discovery/_py_info.py", line 411 in _from_exe_with_args
  File "/src/src/python_discovery/_py_info.py", line 385 in from_exe
  File "/src/src/python_discovery/_discovery.py", line 261 in _propose_from_path
  File "/src/src/python_discovery/_discovery.py", line 203 in propose_interpreters
  File "/src/src/python_discovery/_discovery.py", line 135 in _find_interpreter
  File "/src/src/python_discovery/_discovery.py", line 52 in get_interpreter
  File "/tmp/repro.d/find_python.py", line 17 in <module>

BUG

Expected: The query should return within its timeout when a child retains the pipes.

Impact

Reproducibility is moderate because the setup requires a specific candidate or configuration. Exposure is niche: the candidate must launch a child that keeps its inherited output handles open. The reproduced effect stays within the caller's configured cache, search path, or interpreter.

Credits

Reported by Anthropic's OSS Scanner: ANT-2026-RASICDKU.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions