A process preparing to execute another program often needs a simple descriptor invariant: standard input, output, and error remain available, while unrelated descriptors do not cross the execution boundary. Closing descriptors one at a time can turn that invariant into an enumeration problem. Linux close_range() instead applies an operation to an inclusive numeric interval in the calling task’s file-descriptor table.
The interface is small, but its semantics reach into descriptor-table sharing, execve() inheritance, concurrent descriptor allocation, and privilege transitions. The flags select more than implementation strategy: they determine whether descriptors disappear immediately, become close-on-exec, or are first separated from a table shared with other tasks.
The interval targets descriptor numbers, not open file descriptions
close_range(first, last, flags) identifies file descriptor numbers from first through last, inclusive. With flags equal to zero, descriptors in that interval are closed. Descriptor numbers in the interval that are not open do not make the operation fail merely because they are absent.
This operates at the descriptor-table layer. A descriptor is an entry that refers to an open file description or another kernel object represented through the descriptor interface. Closing one entry does not necessarily destroy the referenced object. Other descriptors may still refer to the same open file description, and another process may hold references inherited through fork() or received through descriptor passing.
That distinction keeps the operation’s scope precise. close_range() removes selected references from one descriptor table; it is not a bulk shutdown primitive for the underlying files, sockets, pipes, or event objects.
A common boundary starts above the conventional standard descriptors:
if (close_range(3, ~0U, 0) == -1) {
/* handle error */
}Here ~0U supplies the largest unsigned value accepted by the interface, expressing an interval from descriptor 3 through the implementation’s upper descriptor space without first querying a current maximum and iterating through it.
Range closure removes userspace enumeration from the contract
A loop based on a descriptor limit has two separate concerns: selecting the numeric search space and issuing one close operation for each candidate. A scan of /proc/self/fd replaces the numeric search with directory enumeration, but it still requires care around the directory descriptor used for the scan and around concurrent changes to the table.
close_range() moves interval selection into one system call. The kernel can operate on its descriptor-table representation directly rather than requiring userspace to reconstruct which entries exist.
The semantic benefit is more important than assuming a particular performance result. Application code states the intended postcondition as a range operation. It does not need a snapshot of every open descriptor merely to request that those entries be removed.
This does not make every concurrent descriptor policy automatic. If another thread can allocate or duplicate descriptors while cleanup is occurring, the sharing relationship of the descriptor table still matters.
A shared descriptor table makes immediate close externally visible
Linux tasks created with CLONE_FILES share a file-descriptor table. Threads in a conventional multithreaded process normally have this sharing relationship. When one thread closes an entry in the shared table, the entry is removed for the other threads that use that same table as well.
Calling plain close_range() from one such thread can therefore affect descriptor use elsewhere in the process. This is not a special side effect of the range API; it follows from operating on a shared descriptor table.
CLOSE_RANGE_UNSHARE changes that boundary. The kernel first arranges for the caller to have an unshared descriptor table as part of the operation, then applies the requested range semantics to the caller’s table. Other tasks that retained the prior shared table are not subjected to those descriptor removals through the caller’s new table.
if (close_range(3, ~0U, CLOSE_RANGE_UNSHARE) == -1) {
/* handle error */
}The flag is therefore about ownership of descriptor-table mutation. It is useful when cleanup belongs to one task’s transition and should not mutate a table still used by peer tasks.
Unsharing does not revoke external references. A peer that already has its own descriptor entry continues to hold that reference according to the normal lifetime rules of the referenced kernel object.
CLOSE_RANGE_CLOEXEC defers removal to the execution boundary
CLOSE_RANGE_CLOEXEC does not close the selected descriptors immediately. It marks them close-on-exec, equivalent in effect to setting the FD_CLOEXEC descriptor flag across the selected interval. The descriptors remain usable before a successful execve()-family transition and are closed by the execution semantics when that transition replaces the process image.
if (close_range(3, ~0U, CLOSE_RANGE_CLOEXEC) == -1) {
/* descriptors remain open if this fails */
}
execve(path, argv, envp);This creates a different failure contract from immediate closure. If execve() fails, the marked descriptors are still present in the current process and retain the close-on-exec flag. With immediate range closure, an execution failure occurs after those descriptors have already been removed.
That distinction can matter to error paths. A launcher that must report an execution failure over a dedicated descriptor needs to place that descriptor outside the affected range or otherwise preserve it until the reporting protocol is complete.
The close-on-exec form also provides a useful boundary when later setup steps still require descriptors that must not survive successful execution. The table remains operational during setup, while inheritance policy is encoded in descriptor flags.
Close-on-exec flags and descriptor allocation are separate synchronization concerns
FD_CLOEXEC belongs to a descriptor entry. New descriptors created after a range has been marked do not retroactively acquire that flag merely because their numeric values fall inside the earlier interval.
This means close_range(..., CLOSE_RANGE_CLOEXEC) is not a permanent policy over descriptor numbers. It mutates entries that exist under the system call’s descriptor-table semantics at that point. Code that permits concurrent descriptor creation still needs a synchronization or ownership model for the period leading to execve().
Many descriptor-creating APIs provide atomic close-on-exec creation flags, such as O_CLOEXEC, SOCK_CLOEXEC, EPOLL_CLOEXEC, and related interfaces. Those flags address a different race: they create a descriptor with its inheritance restriction already attached, avoiding a separate create-then-fcntl() window.
Range marking complements those APIs when a process needs to impose an inheritance boundary over a broader set of existing entries. It does not replace atomic creation flags for descriptors that may be created concurrently.
Unshare and close-on-exec can define a private execution policy
The flags can be combined where supported by the interface. CLOSE_RANGE_UNSHARE | CLOSE_RANGE_CLOEXEC separates the caller’s descriptor table and marks the selected entries close-on-exec in that private table rather than immediately closing them.
This combination separates two decisions that are easy to conflate:
ownership boundary: which task's descriptor table is changed
inheritance boundary: which entries survive successful execThe first is established by unsharing. The second is represented by close-on-exec state. Keeping them distinct is valuable in process-launch code because descriptor ownership before execution and descriptor inheritance after execution are different properties.
The exact process model still controls the result. A task that does not share its descriptor table gains no new isolation from peers that were already separate, while a task that does share the table changes the mutation scope by requesting unsharing.
Errors apply to the range operation, not to each absent descriptor
Bulk cleanup code often treats close(fd) errors one descriptor at a time. close_range() has a different shape: the caller receives one result for the requested range operation.
An interval with first greater than last is invalid. Unsupported flag bits are invalid. Operations that require allocating or unsharing descriptor-table state can also fail for resource or system-limit conditions documented by the kernel interface.
Absent descriptor numbers inside an otherwise valid interval are not individual errors to collect. This property fits cleanup code whose desired state is simply that no selected open entries remain, rather than code that needs a per-descriptor audit trail.
Applications that require object-specific shutdown behavior still need object-specific operations before range cleanup. Closing a socket descriptor, for example, is not a substitute for a protocol-level drain or application acknowledgement when such behavior is part of the surrounding contract.
Seccomp policy can make operation ordering observable
A process may install a seccomp filter that blocks later close_range() calls. If descriptor inheritance must be restricted before a restrictive filter takes effect, operation ordering becomes part of the process-launch protocol.
CLOSE_RANGE_CLOEXEC can be relevant in such sequences because the process can mark the intended descriptor interval before installing a filter that prevents the range call, then continue setup with those existing descriptors until execution. This is an interface-ordering property, not a guarantee that any arbitrary seccomp policy is compatible with the sequence.
The filter itself, the descriptor setup calls still required afterward, and the final execution call all need to be permitted according to the actual policy. close_range() only supplies the descriptor-table transition; it does not coordinate security-policy installation on the application’s behalf.
Descriptor hygiene is a process-boundary contract
Unexpected descriptor inheritance can keep resources alive beyond their intended owner. A leaked pipe endpoint can prevent another participant from observing end-of-file. An inherited socket can extend a connection’s kernel lifetime. An inherited directory or file descriptor can also expose capabilities that the new program was not intended to receive.
close_range() gives Linux process-launch code a direct way to express a broad descriptor-table boundary. Plain closure removes entries now. CLOSE_RANGE_CLOEXEC preserves current use while excluding entries from a successful execution transition. CLOSE_RANGE_UNSHARE changes which shared table receives the mutation.
Those modes solve related but distinct problems. Correct use starts from the required descriptor state before and after execution, plus the task-sharing model during setup. Once those boundaries are explicit, the range operation can encode them without turning descriptor cleanup into a userspace inventory procedure.