Skip to content

os.closerange(0, 0) closes an independently opened file descriptor #159182

Description

@youknowone

Bug report

Bug description

On Linux with a working close_range() path, os.closerange(0, 0) closes an independently opened file descriptor, although the range is empty.

The documented contract is equivalent to closing descriptors in range(fd_low, fd_high). Therefore, (0, 0) should be a no-op.

Reproducer

Run this with the interpreter being tested. The call happens only in a disposable child process; the parent reports the exit status without relying on the child's standard streams.

import subprocess
import sys

child = r'''
import errno
import os
import sys

fd = os.open(os.devnull, os.O_RDONLY)
if sys.argv[1] == "empty":
    os.closerange(0, 0)
try:
    os.fstat(fd)
except OSError as exc:
    os._exit(42 if exc.errno == errno.EBADF else 43)
os._exit(0)
'''

print(sys.version)
for case in ("control", "empty"):
    result = subprocess.run(
        [sys.executable, "-I", "-c", child, case],
        stdin=subprocess.DEVNULL,
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
        close_fds=True,
        timeout=5,
    )
    print(f"{case}: {result.returncode}")

Expected: both cases return 0.

Actual on the affected builds:

control: 0
empty: 42

42 explicitly means os.fstat(fd) failed with EBADF; 43 distinguishes other OSError values.

Tested versions and platform

Reproduced on Linux x86-64, kernel 6.18.44, glibc 2.41, with:

A controlled same-source feature-toggle experiment also reproduces the distinction: with HAVE_CLOSE_RANGE defined, the results are 0 / 42; with it undefined, they are 0 / 0. Both affected translation units were recompiled and the interpreters relinked, keeping unrelated objects identical. This was an incremental A/B experiment, not two clean configure-from-scratch builds.

Likely cause

The Python wrapper converts the exclusive upper bound by calling _Py_closerange(fd_low, fd_high - 1). For (0, 0), that becomes (0, -1).

The helper passes these values to close_range() without first rejecting an empty range. Linux defines unsigned, inclusive endpoints, so -1 converts to UINT_MAX, requesting closure of every descriptor from zero upward.

An early empty-range check before this conversion/path appears necessary. A subprocess regression test would avoid closing the test runner's descriptors.

The runtime results above are limited to these builds and this Linux environment; I have not tested older release binaries or other platforms.

CPython versions tested on:

3.15

Operating systems tested on:

Linux

Linked PRs

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

    extension-modulesC modules in the Modules dirtype-bugAn unexpected behavior, bug, or error

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions