A file descriptor is a small integer used within one process to refer to an open resource. The process’s descriptor table maps that number to an open file description maintained by the kernel. For a regular file, the description includes the current offset and file status flags. Another process can use the same integer for a completely different resource, so the number alone is not a global file identity.
When it breaks
dup allocates another descriptor referring to the same open file description; it does not simply copy the integer or reopen the pathname. Reads through the two descriptors therefore share a regular file’s offset. By contrast, opening the same pathname again normally creates another description with its own offset. Equal device/inode identity does not imply equal open-description state.
Status flags and descriptor flags also live at different levels. An F_SETFL change to a shared open description is visible through duplicated references. The close-on-exec flag belongs to each descriptor separately. Confusing these layers can change another consumer’s behavior or unintentionally expose a descriptor to a subsequently executed program.
Closing a descriptor releases that table entry, and its integer can immediately be reused. Code retaining only a stale number may then operate on a new resource instead of reliably receiving a bad-descriptor error. Ownership and lifetime rules are essential when descriptors pass between callbacks, threads, or libraries; checking that an integer is nonnegative is not sufficient validation.
Check it quickly
Run this Python script in a disposable Linux environment. It creates two harmless temporary files and closes every descriptor it opens. The recorded environment used Linux 6.12.76 with Python 3.11.2. The exercise runs without background threads so allocation and reuse can be inspected without another thread racing to consume the released descriptor number:
from pathlib import Path
from tempfile import TemporaryDirectory
import fcntl,os
with TemporaryDirectory(prefix='xbcatm-fds-') as directory:
root=Path(directory);original=root/'original';other=root/'other'
original.write_bytes(b'abcdef');other.write_bytes(b'replacement')
handles=set()
try:
first=os.open(original,os.O_RDONLY);handles.add(first)
duplicate=os.dup(first);handles.add(duplicate)
independent=os.open(original,os.O_RDONLY);handles.add(independent)
assert first!=duplicate
assert os.read(first,2)==b'ab' and os.read(duplicate,2)==b'cd'
assert os.read(independent,2)==b'ab'
assert os.lseek(first,0,os.SEEK_CUR)==4
print('dup uses a different descriptor but shares offset; a separate open has its own offset')
flags=fcntl.fcntl(duplicate,fcntl.F_GETFL)
fcntl.fcntl(duplicate,fcntl.F_SETFL,flags|os.O_NONBLOCK)
assert fcntl.fcntl(first,fcntl.F_GETFL)&os.O_NONBLOCK
assert not fcntl.fcntl(independent,fcntl.F_GETFL)&os.O_NONBLOCK
os.set_inheritable(first,False);os.set_inheritable(duplicate,True)
assert not os.get_inheritable(first) and os.get_inheritable(duplicate)
print('file status flags shared by dup; close-on-exec flags remain descriptor-specific')
os.close(first);handles.remove(first)
reused=os.open(other,os.O_RDONLY);handles.add(reused)
assert reused==first and os.read(first,32)==b'replacement'
assert os.read(duplicate,2)==b'ef'
print('closed descriptor number is reused for another file; surviving duplicate still reads original')
original.unlink();os.lseek(duplicate,0,os.SEEK_SET)
assert os.fstat(duplicate).st_nlink==0 and os.read(duplicate,32)==b'abcdef'
print('unlink removes the pathname while an open descriptor retains access')
finally:
for handle in handles:os.close(handle)
The first open reads ab, and its duplicate then reads cd. Both now observe offset four. A separate open of the same file still reads ab from its own initial position. These exact bytes establish which state is shared; merely seeing that both paths contain the same data would not distinguish duplicated references from independent opens.
The script sets O_NONBLOCK through the duplicate and observes the flag through the first descriptor, while the independent open retains its previous flags. For this regular-file fixture, the test concerns flag ownership, not a claim that ordinary disk reads become asynchronous. It then gives the duplicate a different inheritable setting and confirms that the original descriptor’s close-on-exec behavior is unchanged.
After the first descriptor closes, opening the other file reuses that integer. Reading through the stale variable now returns replacement, while the surviving duplicate still reads the original file’s final ef. This is a concrete demonstration of why a closed integer is not a durable reference to an object that used to exist.
Unlinking the original pathname removes its directory entry. The duplicate’s file status reports link count zero, yet seeking and reading through it still returns abcdef. Other open references in the fixture also remain valid until closed. Removing a name, closing one descriptor, and releasing the final open reference are distinct operations.
The script keeps an explicit set of currently owned descriptors for cleanup, removing the closed entry before its number is reused. That avoids accidentally treating the reused integer as two separately owned handles. Real code needs the same clarity when a function transfers ownership or a wrapper is responsible for closing its underlying descriptor.
Define ownership at the handoff
Document whether a function borrows a descriptor or takes responsibility for closing it. Use scoped wrappers where their ownership model fits, and avoid having two independent owners both close the same integer. Duplicating a reference can separate descriptor lifetime, but it still shares open-description state unless a genuinely independent open is intended.
Choose inheritance explicitly when starting another program. Language runtimes may create descriptors as non-inheritable, and subprocess APIs may close all except an allowed set. Verify the actual launch behavior rather than assuming that fork, exec, and a high-level process API expose identical descriptors.
Finally, match operations to the resource type. A pipe or socket is addressed through a descriptor too, but it does not acquire a regular file’s seekable offset contract. Correct descriptor handling combines resource semantics, open-description state, and a clear rule for who may close each process-local handle.