A file descriptor created without close-on-exec state can escape into a newly executed program during a narrow concurrency window. In a multithreaded Linux process, setting FD_CLOEXEC with a later fcntl() call leaves that window open between descriptor creation and the flag update.
O_CLOEXEC removes the split operation. The kernel creates the descriptor with its close-on-exec flag already set, so another thread cannot observe an intermediate state in which the descriptor exists but remains inheritable across a successful execve().
Descriptor state crosses fork and meets exec
A process created by fork() inherits copies of the parent’s file descriptors. Those descriptors continue to refer to the same open file descriptions as their counterparts in the parent.
A successful execve() changes the process image but does not normally close every descriptor. Descriptors with FD_CLOEXEC set are closed as part of successful execution. Descriptors without that flag remain open.
This makes descriptor inheritance a boundary between two separate mechanisms: fork() copies the descriptor table, while execve() applies close-on-exec state as it replaces the process image.
A later fcntl call leaves an interval
The historical two-step pattern creates a descriptor first and marks it afterward:
int fd = open(path, O_RDONLY);
if (fd == -1)
return -1;
int flags = fcntl(fd, F_GETFD);
if (flags == -1)
return -1;
if (fcntl(fd, F_SETFD, flags | FD_CLOEXEC) == -1)
return -1;In a single-threaded process with controlled signal behavior, the interval may be easy to contain. In a multithreaded process, another thread can execute a process-creation path during that interval.
A possible ordering is:
- Thread A calls
open()and receives a descriptor withoutFD_CLOEXEC. - Thread B calls
fork(). - The child from thread B calls
execve(). - Thread A sets
FD_CLOEXEC.
The flag update in step 4 cannot affect the descriptor table already copied into the child. If the child reaches a successful execve() before receiving equivalent close-on-exec state, the new program retains the descriptor.
The leak can carry more than a numeric handle. The inherited descriptor can preserve access to a file, socket, pipe endpoint, device, or other kernel-backed object that the executed program was never intended to hold.
O_CLOEXEC moves the flag into descriptor creation
open() accepts O_CLOEXEC as a file creation flag:
int fd = open(path, O_RDONLY | O_CLOEXEC);The returned descriptor has FD_CLOEXEC set from the moment it becomes visible to the calling process. There is no separate userspace interval between descriptor allocation and the close-on-exec state.
The atomic property concerns descriptor creation plus descriptor-flag initialization. It does not make later I/O atomic, and it does not alter the file status flags stored in the open file description.
That distinction is visible with duplicated descriptors. File status such as O_APPEND belongs to the shared open file description, while FD_CLOEXEC is a per-descriptor flag. Two descriptors can therefore share offsets and file status while carrying different close-on-exec state.
Descriptor-producing APIs need the same property
The race is not specific to open(). Any operation that creates a descriptor and then relies on a separate fcntl(F_SETFD) call can expose the same kind of interval.
Linux provides atomic close-on-exec variants across multiple descriptor-producing interfaces. Examples include pipe2() with O_CLOEXEC, dup3() with O_CLOEXEC, accept4() with SOCK_CLOEXEC, eventfd() with EFD_CLOEXEC, and F_DUPFD_CLOEXEC for descriptor duplication.
The flag names vary because the APIs have different flag namespaces, but the boundary is the same: close-on-exec state is established before the new descriptor is returned to userspace.
Successful exec is the closing boundary
FD_CLOEXEC does not close a descriptor when fork() occurs. The child initially inherits the descriptor and its descriptor flags. The close occurs during a successful execve().
If execve() fails, the process image is not replaced and the descriptor remains open. Code that handles execution failure still owns that descriptor unless it closes it through another path.
This behavior matters for error paths that continue running after a failed execution attempt. Close-on-exec is not a general lifetime mechanism; it is a specific transition rule attached to successful program execution.
The race is a resource-boundary defect
Unexpected descriptor inheritance can keep resources alive after their intended owners release them. A leaked pipe endpoint can delay end-of-file observation. A leaked socket can extend connection lifetime. A leaked file descriptor can retain access to an object even when pathname permissions or directory structure later change.
Atomic close-on-exec creation prevents the inheritance window rather than attempting to repair it after descriptor allocation. The relevant invariant is established at the point the descriptor enters the process table, before concurrent process creation can copy that state.