A process preparing to call execve() may have hundreds or thousands of open file descriptors, while the new program should inherit only a small selected set. Closing descriptors one by one creates both bookkeeping cost and a concurrency problem: another thread can allocate a descriptor while the cleanup loop is still running.
Linux close_range() moves that operation to the descriptor-table boundary. A caller specifies an inclusive numeric range and asks the kernel either to close descriptors in that range or mark them close-on-exec. With CLOSE_RANGE_UNSHARE, the caller can first detach its descriptor table from threads or processes that share it.
The mechanism is particularly useful in launchers, service managers, sandbox setup, and other code that crosses an execve() boundary with a deliberately small descriptor set.
A numeric range replaces descriptor enumeration
The basic interface is:
#define _GNU_SOURCE
#include <linux/close_range.h>
#include <unistd.h>
int close_range(unsigned int first, unsigned int last, int flags);Both endpoints are inclusive. A common pre-exec operation keeps standard input, output, and error while removing everything above them:
if (close_range(3, ~0U, 0) == -1)
return -1;
execve(path, argv, envp);~0U expresses the largest unsigned int, so the caller does not need to query the current descriptor-table limit or enumerate /proc/self/fd. Descriptors absent from the range do not turn the operation into a per-descriptor failure sequence; errors encountered while closing individual descriptors are ignored by the interface.
This differs from walking /proc/self/fd. Enumeration creates a snapshot-like sequence in userspace, requires access to procfs, and opens a descriptor for the directory being inspected. close_range() instead expresses the intended range directly to the kernel.
Closing a range does not isolate a shared table
Linux threads normally share a file descriptor table through CLONE_FILES. In that state, closing a descriptor changes the shared table, so other threads observe the descriptor as closed too.
That behavior is correct for ordinary descriptor ownership inside a process, but it is awkward during a transition in which one thread is preparing a distinct execution image. A cleanup sequence should not accidentally dismantle descriptors still used by siblings.
CLOSE_RANGE_UNSHARE changes the operation:
if (close_range(3, ~0U, CLOSE_RANGE_UNSHARE) == -1)
return -1;
execve(path, argv, envp);Conceptually, this combines detaching the caller from a shared descriptor table with closing the selected range. The kernel can optimize the common case where the range extends to ~0U: rather than copying a complete table and then closing its high entries, it can construct the caller’s private table only up to the retained prefix.
The flag therefore changes more than performance. It defines which descriptor table the closure mutates and removes a class of races caused by concurrent users of a shared table.
CLOEXEC mode separates policy from destruction
CLOSE_RANGE_CLOEXEC does not immediately close descriptors. It sets their close-on-exec state across the requested range:
if (close_range(3, ~0U, CLOSE_RANGE_CLOEXEC) == -1)
return -1;
/* Additional setup can still use descriptors >= 3 here. */
execve(path, argv, envp);This sequencing matters when pre-exec setup still needs descriptors that must not reach the final program. A launcher may need open files while installing a security policy, preparing namespaces, or completing other process state. Marking the range first establishes inheritance policy without destroying those resources immediately.
On a successful execve(), descriptors carrying close-on-exec are closed as part of the execution transition. If execve() fails, the current program remains in place and the descriptors remain open, still carrying the flag. Code handling the failure therefore retains access to them unless it closes them explicitly.
CLOSE_RANGE_CLOEXEC is a range operation over descriptor flags; it does not alter the status flags of the underlying open file descriptions.
Range operations reduce one race but do not define the retained set
A call such as close_range(3, ~0U, ...) works cleanly when descriptors 0, 1, and 2 are the only entries intended for inheritance. Real launchers often need additional descriptors: a socket activation channel, a log pipe, a control socket, or an already-open executable.
Those descriptors need deliberate placement or preservation. One pattern duplicates retained descriptors into a compact reserved interval, applies close_range() outside that interval, then performs any final dup2() or dup3() mapping.
The ordering must account for descriptor allocation. Closing a hole can make its number immediately available to later open(), dup(), socket(), or similar calls. Code that treats descriptor numbers as stable identities across unrelated allocation steps can therefore reintroduce ambiguity even after range cleanup.
The stronger invariant is not that a particular high descriptor number once referred to a desired object. It is that the launcher constructs a final descriptor table whose retained entries are explicit and whose unwanted range is eliminated or marked close-on-exec at a defined transition point.
CLOEXEC at creation remains the first containment boundary
close_range() is useful at a bulk transition, but it does not replace atomic close-on-exec flags on descriptor creation. Interfaces such as open(..., O_CLOEXEC), pipe2(..., O_CLOEXEC), accept4(..., SOCK_CLOEXEC), and dup3(..., O_CLOEXEC) prevent newly created descriptors from becoming inheritable during the interval before a later cleanup operation.
In a multithreaded process, creating a descriptor and then setting FD_CLOEXEC with a separate fcntl() call leaves an interval in which another thread can execute a new program with that descriptor still inheritable. Atomic creation flags close that interval at the source.
The two mechanisms therefore operate at different boundaries. Creation-time CLOEXEC constrains each new descriptor. close_range() provides a bulk policy operation when a process is about to cross a lifecycle boundary.
Failure handling is part of the launch protocol
close_range() returns -1 for invalid arguments and can report resource errors when CLOSE_RANGE_UNSHARE requires construction of a new descriptor table. A launcher cannot safely treat failure as equivalent to an empty descriptor range.
Fallback code also needs the same concurrency properties as the primary path. Replacing a failed close_range(..., CLOSE_RANGE_UNSHARE) with an unsynchronized loop over /proc/self/fd may preserve basic functionality while losing the isolation property that motivated the flag.
Compatibility handling should therefore state its invariant explicitly: whether the environment guarantees a single thread, whether a private descriptor table has already been established, and whether procfs enumeration is acceptable. Kernel-version checks alone are weaker than probing the operation and handling ENOSYS or unsupported flags in the context of those constraints.
The descriptor table is part of the exec interface
An execve() transition preserves open descriptors unless their close-on-exec state says otherwise. That makes the descriptor table an input to the new program just as arguments, environment, credentials, and namespaces are inputs.
close_range() gives Linux programs a direct way to shape that input as a range operation. Immediate closure is appropriate when setup no longer needs the descriptors. CLOSE_RANGE_CLOEXEC defers destruction to the execution boundary. CLOSE_RANGE_UNSHARE separates the caller’s table before mutation when sharing would make cleanup unsafe.
The useful property is not merely fewer close() calls. The system call makes descriptor inheritance a table-level transition that can be placed deliberately in the process-launch protocol.