CodeRigor

FREE LESSON

Descriptors & open file descriptions

Own Linux descriptors in C++: move-only RAII, atomic close-on-exec duplication, shared offsets and status flags, independent descriptor flags and errno-safe cleanup.

A file descriptor is a process-local integer used to refer to kernel state. It is not a pointer, a filename, or a globally unique resource identity. Linux can reuse a number immediately after it closes. A C++ wrapper therefore owns a responsibility: close one acquired descriptor exactly once.

The three layers

Keep the descriptor table entry, the open file description and the file object separate. A new open normally makes a new open file description. Duplication adds another descriptor referring to the existing one. Reads and seeks through those duplicates share a cursor; O_NONBLOCK is shared too. FD_CLOEXEC belongs to the descriptor table entry, so duplicates can differ on inheritance.

Two descriptors, one open file description
start / 3
The descriptor numbers and FD_CLOEXEC bits are separate. Offset and O_NONBLOCK live in the shared kernel object.
Two descriptors, one open file description
The descriptor numbers and FD_CLOEXEC bits are separate. Offset and O_NONBLOCK live in the shared kernel object.
C++
#include <cassert>
#include <cstdio>
#include <cstdlib>
#include <fcntl.h>
#include <unistd.h>

int main() {
    char name[]="/tmp/unix-check-XXXXXX"; int fd=::mkstemp(name); assert(fd>=0); ::unlink(name);
    assert(::write(fd,"abcdef",6)==6); assert(::lseek(fd,0,SEEK_SET)==0);
    int other=::dup(fd); assert(other>=0); char a=0,b=0;
    assert(::read(fd,&a,1)==1); assert(::read(other,&b,1)==1);
    std::printf("%c%c %ld\n",a,b,long(::lseek(fd,0,SEEK_CUR))); ::close(other);::close(fd);
}

The example deliberately uses raw handles so the kernel relationship stays visible. In the build, put acquisition under ownership immediately. Return std::expected<sys_fd,int> to keep the failure errno next to an empty value state. A move transfers the cleanup duty without making another kernel handle. Use an actual duplication syscall when you want two independently owned entries.

Borrowing, release and reset

get() borrows without extending lifetime. release() relinquishes cleanup without closing; the caller must establish a new owner. reset(next) closes old ownership and adopts the replacement. Handle reset(get()) and self-move explicitly so a harmless operation does not invalidate a live resource.

Which layer owns each piece of state?
Use the right fcntl command for the layer you intend to change.
Which layer owns each piece of state?
Use the right fcntl command for the layer you intend to change.

Acquire close-on-exec atomically with O_CLOEXEC, SOCK_CLOEXEC or F_DUPFD_CLOEXEC. Opening first and setting the flag later leaves a window for another thread to exec. Close-on-exec does not stop inheritance at fork; it controls the later image replacement.

C++
#include <cassert>
#include <cstdio>
#include <cstdlib>
#include <fcntl.h>
#include <unistd.h>

int main() {
    char name[]="/tmp/unix-check-XXXXXX"; int fd=::mkstemp(name); assert(fd>=0); ::unlink(name);
    assert(::write(fd,"abcdef",6)==6); assert(::lseek(fd,0,SEEK_SET)==0);
    assert(::fcntl(fd,F_SETFD,FD_CLOEXEC)==0); int other=::dup(fd); assert(other>=0);
    std::printf("%d %d\n",bool(::fcntl(fd,F_GETFD)&FD_CLOEXEC),bool(::fcntl(other,F_GETFD)&FD_CLOEXEC)); ::close(other);::close(fd);
}

Cleanup and evidence

A destructor cannot repair a close error by blindly retrying its old integer. On Linux, that number may already have been released and reused. Close once, empty the owner, and preserve the errno that the caller was handling. If an application needs to report delayed write errors, provide an explicit close or sync policy before destruction; a noexcept destructor cannot return an error.

A move transfers cleanup responsibility
The moved-from owner becomes empty. The target closes its old resource before adopting a different incoming resource.
A move transfers cleanup responsibility
The moved-from owner becomes empty. The target closes its old resource before adopting a different incoming resource.

Before the fill, trace a = std::move(b) when both owners are nonempty. Name the old destination handle, the incoming handle and the moved-from state. The checks then ask which state is shared by duplication, while the build inspects actual offsets, flags, moves, reset and closure. Heap sanitizers do not detect all descriptor leaks, so the tests query descriptor state as well.

Next: positioned file I/O. The later process lesson shows why inheritance flags matter.

Watch & read

Free lectures, talks and readings that cover this skill from another angle. All resources →

Start with the descriptor-table model. This lesson adds C++ ownership, the shared open file description, atomic close-on-exec duplication and independent descriptor flags.