An inotify watch descriptor is just an integer in your process. The kernel’s inode is the real identity, but you only have the wd. Unmount the filesystem or move the container’s bind mount and that wd is gone, the path you stored is stale, and your daemon is tracking nothing. Open file descriptors are the same: process-local, gone when the fd closes or the process restarts.
If you need to identify a file in a way that survives remounts and can be handed to a fresh
process to reopen later, the POSIX alternatives (path strings, st_ino + st_dev pairs) all
have holes. name_to_handle_at(2) is what the kernel gives you instead.
What you get
#define _GNU_SOURCE
#include <sys/stat.h>
#include <fcntl.h>
int name_to_handle_at(int dirfd, const char *pathname,
struct file_handle *handle,
int *mount_id, int flags);
int open_by_handle_at(int mount_fd, struct file_handle *handle, int flags);
name_to_handle_at returns two things: a struct file_handle (an opaque byte string the
filesystem owns) and a mount_id integer that identifies which mounted filesystem the file
lives on. You can serialize that handle, stash it in a database, and hand it to a completely
different process. That process calls open_by_handle_at with a file descriptor open to the
mount point and gets back a normal fd pointing at the original file, wherever it has moved
inside that filesystem.
The struct:
struct file_handle {
unsigned int handle_bytes; /* in/out: size of f_handle[] */
int handle_type; /* out: filesystem-defined type */
unsigned char f_handle[0]; /* out: opaque handle bytes */
};
The handle_bytes field is both input (how much buffer you allocated) and output (how many
bytes the filesystem filled). On first call, pass zero bytes and the kernel tells you how
much to allocate. On second call, pass the right size.
struct file_handle *fh;
int mount_id, fd;
/* First call: discover required size */
fh = malloc(sizeof(*fh));
fh->handle_bytes = 0;
if (name_to_handle_at(AT_FDCWD, "/srv/data/important.db", fh, &mount_id, 0) == -1) {
if (errno != EOVERFLOW) { perror("name_to_handle_at"); exit(1); }
}
/* Reallocate and retry */
fh = realloc(fh, sizeof(*fh) + fh->handle_bytes);
if (name_to_handle_at(AT_FDCWD, "/srv/data/important.db", fh, &mount_id, 0) == -1) {
perror("name_to_handle_at"); exit(1);
}
/* fh and mount_id are now serializable */
No special capability needed to call name_to_handle_at. Any process that can stat the file
can get its handle.
Reopening from a handle
open_by_handle_at is where the capability check lives. The mount_fd argument must be an
open fd for any file or directory on the same mounted filesystem as the original file. The
calling process needs CAP_DAC_READ_SEARCH. Without it, the call fails with EPERM.
int mount_fd = open("/srv/data", O_RDONLY);
int target_fd = open_by_handle_at(mount_fd, fh, O_RDWR);
if (target_fd == -1) { perror("open_by_handle_at"); }
The capability requirement is deliberate. A handle encodes an inode directly; a process with
one can bypass normal path-based permission checks and open files it cannot reach by name. The
kernel is not going to let you do that without CAP_DAC_READ_SEARCH.
AT_EMPTY_PATH in the flags argument to name_to_handle_at lets you get a handle for a
file you already have open by fd, with no pathname at all:
name_to_handle_at(existing_fd, "", fh, &mount_id, AT_EMPTY_PATH);
Useful when you want to checkpoint a file’s identity at fd-open time and retain it past a potential rename or unlink.
Filesystem support
Not every filesystem implements the export operations this syscall pair requires. ext4, XFS,
and Btrfs all support it. tmpfs does not: you will get EOPNOTSUPP. NFS, FUSE, and
overlayfs vary by kernel version and configuration. Before relying on this in production, test
on the actual filesystem the deployment will use.
The mount_id output matches the IDs in /proc/self/mountinfo. Cross-referencing lets you
find which mount point to open for open_by_handle_at, and it lets a daemon track whether
the underlying filesystem has been remounted between the handle-capture and the reopen.
When to use this
The practical cases are narrow but real:
NFS server userspace. Linux’s nfsd exports file handles to NFS clients. When a client
presents a handle on a subsequent request, the server has to reopen the file without relying
on a path. This syscall pair is the kernel hook that makes that work for userspace NFS
implementations like nfs-ganesha.
Backup and dedup daemons. You snapshot a directory tree, get handles for every file, then later (after bind-mount juggling or snapshot cycling) reopen files by handle instead of crawling paths. This is faster and correct across renames.
Container-aware file-change trackers. A process inside a container has a different mount namespace than the host. A handle obtained inside the namespace can be handed to a privileged host process that opens it from the host’s view of the same underlying filesystem, bypassing the namespace boundary cleanly.
Pitfalls
Handles are filesystem-specific and opaque. You cannot copy a handle from one filesystem to another and expect it to work. If you store handles in a database, store the filesystem UUID alongside them so you can detect filesystem replacements.
The mount_id changes on remount. The handle itself (the opaque f_handle bytes) is stable
as long as the inode exists on that filesystem, even across remounts. The mount_id is just
bookkeeping for finding the right mount_fd to pass to open_by_handle_at.
If the file is deleted, the handle still contains the old inode number. Whether open_by_handle_at
returns a valid fd to the unlinked-but-still-open inode, a stale handle error (ESTALE), or
a new file at the recycled inode depends on the filesystem and timing.
Availability
Linux 2.6.39+. No BSD equivalent: FreeBSD has no name_to_handle_at; the closest analog is
fhopen(2), which works only with superuser privileges and is specific to the NFS exporter
path. macOS has nothing.
For the broader fd-as-identity pattern, the
file descriptor series covers related syscalls: pidfd_open
for processes, memfd_create for anonymous memory, and SCM_RIGHTS for passing fds across
UNIX sockets. The inotify tip covers the watch
descriptor model that name_to_handle_at complements when you need handle-level stability.
