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.
#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.
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.
#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.
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.