$RodHat_
Console Tips

close_range: one syscall instead of a million EBADFs

Published by

close_range: one syscall instead of a million EBADFs
Photo: AI-generated — no human photographer / RodHat AI Cover

The canonical pre-exec fd cleanup loop has been embarrassing C programmers for decades:

/* everybody's guilty of this */
int maxfd = (int)sysconf(_SC_OPEN_MAX);
for (int fd = 3; fd < maxfd; fd++)
    close(fd);

sysconf(_SC_OPEN_MAX) returns 1048576 on any modern Linux system with a sane resource limit. You are making one million close() syscalls. Almost all of them return EBADF. The kernel dutifully validates each one, looks the fd up in the table, finds nothing, and hands you back an error you are explicitly ignoring. This pattern exists in OpenSSH, in every shell ever written, in half the daemon code you’ve inherited. It works, barely, in the same way that heating your house by opening the refrigerator door works.

FreeBSD solved this in 7.3 with closefrom(fd): close all file descriptors from fd to the highest open one, one call, done. That was 2008. Linux shipped an equivalent fifteen years later, in kernel 5.9 (October 2020), under the name close_range(). Took them a while. The result is better than closefrom() because it adds two flags that FreeBSD did not have at the time.

The syscall

#define _GNU_SOURCE
#include <unistd.h>   /* glibc 2.34+ */
#include <linux/close_range.h>  /* CLOSE_RANGE_UNSHARE, CLOSE_RANGE_CLOEXEC */

int close_range(unsigned int first, unsigned int last, unsigned int flags);

first and last are unsigned. To close from some fd to the end of the table, pass ~0U (i.e., UINT_MAX) as last. The kernel clamps it to the actual table size internally.

Glibc got the wrapper in 2.34 (September 2021, shipped in Debian 12, Ubuntu 22.10+, Fedora 35+). On older glibc, call the syscall directly:

#include <sys/syscall.h>
#include <linux/close_range.h>
#include <unistd.h>

static inline int close_range_compat(unsigned int first,
                                      unsigned int last,
                                      unsigned int flags)
{
    return (int)syscall(SYS_close_range, first, last, flags);
}

SYS_close_range is 436 on x86-64. Check asm/unistd.h for your architecture if you are targeting arm64 or riscv64.

Replacing the loop

/* before */
int maxfd = (int)sysconf(_SC_OPEN_MAX);
for (int fd = 3; fd < maxfd; fd++)
    close(fd);

/* after */
close_range(3, ~0U, 0);

That is it. One syscall. The kernel walks the fd table once, closes everything in range, and returns. No EBADF noise. No million-iteration loop. If the process has no open fds above 2, the call returns immediately.

CLOSE_RANGE_UNSHARE: the multi-threaded case

When a process forks or spawns threads, all threads share the same fd table. If you are doing pre-exec cleanup in a multi-threaded program (which posix_spawn() implementations, shells, and container runtimes often are), closing fds from one thread while another thread might be using them is wrong.

CLOSE_RANGE_UNSHARE tells the kernel to unshare the fd table before modifying it. The closing happens on a private copy, so other threads keep their view of the table unchanged. It is the correct thing to do any time you are not certain you are the only thread:

/*
 * Multi-threaded pre-exec cleanup. Unshare first so other threads
 * keep their fds while this thread-to-be-exec'd cleans house.
 */
close_range(3, ~0U, CLOSE_RANGE_UNSHARE);
execvp(argv[0], argv);

glibc’s internal posix_spawn() implementation uses exactly this on kernels that support it.

CLOSE_RANGE_CLOEXEC: the better flag

This is the genuinely clever one. Instead of closing the range, it sets FD_CLOEXEC on every fd in the range. The fds stay open in the current process but get closed atomically across any subsequent execve().

The use case: you have done something fd-heavy during initialization (opened config files, connected to databases, set up monitoring sockets, whatever), and you want to make sure none of that leaks into a child process you are about to spawn. You could track every fd and open it with O_CLOEXEC, but that requires discipline across every call site and every library you use. Or:

/*
 * After initialization, atomically cloexec everything from fd 3 onward.
 * Fds we explicitly want to pass to children get dup2()'d to known
 * positions afterward, which clears FD_CLOEXEC on the new fds.
 */
close_range(3, ~0U, CLOSE_RANGE_CLOEXEC);

/* Now explicitly hand specific fds to the child by position. */
dup2(pipe_write_end, CHILD_PIPE_FD);

The combination CLOSE_RANGE_UNSHARE | CLOSE_RANGE_CLOEXEC does the unshare and then marks everything cloexec on the private copy. That is the correct sequence for multi-threaded code that wants a clean exec environment without disturbing sibling threads.

Checking support

uname -r   # need 5.9+

# Quick userspace test without compiling anything:
python3 -c "
import ctypes, os
SYS_close_range = 436  # x86-64
libc = ctypes.CDLL(None, use_errno=True)
# open a throwaway fd then close it via close_range
fd = os.open('/dev/null', os.O_RDONLY)
r = libc.syscall(SYS_close_range, ctypes.c_uint(fd),
                 ctypes.c_uint(fd), ctypes.c_uint(0))
print('close_range supported:', r == 0)
"

The FreeBSD side: closefrom(fd) arrived in FreeBSD 7.3 and is directly equivalent to close_range(fd, ~0U, 0). FreeBSD added CLOSE_RANGE_CLOEXEC semantics later via fdcloexec() in 14.0. OpenBSD has closefrom() as well. At this point every serious UNIX has the primitive; the loop is just legacy scar tissue.

A realistic pre-exec helper

#define _GNU_SOURCE
#include <unistd.h>
#include <sys/syscall.h>
#include <fcntl.h>
#include <errno.h>

#ifndef SYS_close_range
#define SYS_close_range 436  /* x86-64 */
#endif

#ifndef CLOSE_RANGE_UNSHARE
#define CLOSE_RANGE_UNSHARE (1U << 1)
#endif

/*
 * Close all fds >= 'keep_below' before exec. Falls back to the
 * loop if the kernel is old enough to predate close_range.
 */
void close_fds_before_exec(int keep_below)
{
    int r = (int)syscall(SYS_close_range,
                         (unsigned int)keep_below,
                         ~0U,
                         CLOSE_RANGE_UNSHARE);
    if (r == 0)
        return;

    /* ENOSYS: kernel < 5.9, fall back to the ugly loop. */
    if (errno == ENOSYS) {
        long maxfd = sysconf(_SC_OPEN_MAX);
        if (maxfd < 0) maxfd = 4096;
        for (int fd = keep_below; fd < (int)maxfd; fd++)
            close(fd);
    }
}

The ENOSYS fallback is how every serious runtime handles the transition. glibc’s posix_spawn() does the same thing internally: try close_range, if the kernel is too old, fall back to the loop.

What this does not replace

close_range() cleans up your fd table. It does not restrict what fds the process can open after exec, which is landlock’s job. It does not prevent opening files via path traversal, which is openat2’s job. And it does not restrict which syscalls the child can call at all, which needs seccomp-BPF.

close_range() with CLOSE_RANGE_UNSHARE | CLOSE_RANGE_CLOEXEC in your pre-exec setup, landlock for filesystem confinement, and seccomp-BPF for syscall filtering: that is a complete unprivileged sandbox you can deploy without touching root. The pieces compose. Use all of them.


The pidfd post covers the parallel work on process handles: same era, same theme, kernel finally giving userspace race-free primitives for things we have been papering over with PID numbers and fd loops since V7. The memfd_create post covers the anonymous fd end of the same design. The openat2 post covers the path-resolution side.