Counting open file descriptors on macOS

Created On:

The typical way to count the number of open file descriptors by current process on macOS is to check the contents of the /dev/fd directory. This directory is not documented by Apple, but the code for populating this directory is in the XNU sources on GitHub. The code is borrowed from FreeBSD and this directory is documented in the FreeBSD documentation. The fdescfs(5) man page contains two facts below.

The file system’s contents appear as a list of numbered files which correspond to the open files of the process reading the directory. The files /dev/fd/0 through /dev/fd/# refer to file descriptors which can be accessed through the file system.

Note: /dev/fd/0, /dev/fd/1 and /dev/fd/2 files are created by default when devfs alone is mounted. fdescfs creates entries for all file descriptors opened by the process.

Based on the above, it seems counting the number of entries under /dev/fd would return the number of open file descriptors that reference files on the file system. By default a process would have exactly three file descriptors open, for stdin, stdout and stderr.

Surprisingly a the below program doesn’t return 3.

fn main() {
    let num_fds = std::fs::read_dir("/dev/fd").unwrap().count();
    println!("Number of open fds: {}", num_fds);
}

A naive Rust program to count open fds

$ ./target/debug/counting-fds
Number of open fds: 4

Output of the naive Rust program

The problem here is that in order to read the contents of /dev/fs the program has to allocate another file descriptor. This additional file descriptor causes this method to always return one more than the expected number of file descriptors. This limitation combined with /dev/fs will not show other kinds of file descriptors like sockets, means this method is limited and inaccurate.

A Better Approach§

A better approach on macOS is to use the completely undocumented libproc.h header in the macOS SDK. Within this header there is the proc_pidinfo function with the following signature.

int proc_pidinfo(int pid, int flavor, uint64_t arg, void *buffer, int buffersize) __OSX_AVAILABLE_STARTING(__MAC_10_5, __IPHONE_2_0);

Copied from libproc.h

Although undocumented, this is the same function that lsof uses. The pid argument is self explanatory. The flavor argument comes from the sys/proc_info.h header. The header lists many possible values but PROC_PIDLISTFDS appears to be the value needed for counting file descriptors.

/* Flavors for proc_pidinfo() */
#define PROC_PIDLISTFDS                 1
#define PROC_PIDLISTFD_SIZE             (sizeof(struct proc_fdinfo))

Copied from sys/proc_info.h

In our case PROC_PIDLISTFDS is the desired flavor and the proc_fdinfo struct shows some promising fields.

/* defns of process file desc type */
#define PROX_FDTYPE_ATALK       0
#define PROX_FDTYPE_VNODE       1
#define PROX_FDTYPE_SOCKET      2
#define PROX_FDTYPE_PSHM        3
#define PROX_FDTYPE_PSEM        4
#define PROX_FDTYPE_KQUEUE      5
#define PROX_FDTYPE_PIPE        6
#define PROX_FDTYPE_FSEVENTS    7
#define PROX_FDTYPE_NETPOLICY   9

struct proc_fdinfo {
    int32_t                 proc_fd;
    uint32_t                proc_fdtype;
};

Copied from sys/proc_info.h

Already this approach appears to be better for two reasons. First, proc_pidinfo accepts an arbitrary pid, allowing for inspection of an arbitrary process. Two, all kinds of file descriptors can be counted and inspected. The header indicates that file descriptors related to files, sockets, pipes, kqueue, AppleTalk and more will be returned.

The buffer and buffersize and the return value are all undocumented. However the source code of proc_pidinfo function is released by Apple. So it’s possible to understand these arguments by looking at the source.

int
proc_pidinfo(int pid, int flavor, uint64_t arg,  void *buffer, int buffersize)
{
    int retval;

    if ((retval = __proc_info(2, pid, flavor,  arg,  buffer, buffersize)) == -1)
        return(0);

    return(retval);
}

Copied from libproc.c from Apple’s libc sources

Where __proc_info appears to be a kernel system call. Inspecting the XNU sources eventually leads to the complete implementation of the PROC_PIDLISTFDS flavor in a function called proc_pidfdlist.

int
proc_pidfdlist(proc_t p, user_addr_t buffer, uint32_t  buffersize, int32_t *retval)
{
    uint32_t numfds = 0;
    uint32_t needfds;
    char * kbuf;
    uint32_t count = 0;
    int error = 0;

    if (p->p_fd->fd_nfiles > 0) {
        numfds = (uint32_t)p->p_fd->fd_nfiles;
    }

    if (buffer == (user_addr_t) 0) {
        numfds += 20;
        *retval = (numfds * sizeof(struct proc_fdinfo));
        return 0;
    }

    /* buffersize is big enough atleast for one struct */
    needfds = buffersize / sizeof(struct proc_fdinfo);

    if (numfds > needfds) {
        numfds = needfds;
    }

    kbuf = kheap_alloc(KHEAP_TEMP, numfds * sizeof(struct proc_fdinfo),
        Z_WAITOK | Z_ZERO);
    if (kbuf == NULL) {
        return ENOMEM;
    }

    /* cannot overflow due to count <= numfds */
    count = (uint32_t)proc_fdlist_internal(p, (struct proc_fdinfo *)kbuf, (size_t)numfds);

    error = copyout(kbuf, buffer, count * sizeof(struct proc_fdinfo));
    kheap_free(KHEAP_TEMP, kbuf, numfds * sizeof(struct proc_fdinfo));
    if (error == 0) {
        *retval = count * sizeof(struct proc_fdinfo);
    }
    return error;
}

Copied from proc_info.c in XNU kernel sources.

Based on this code it’s clear that buffer is supposed to be filled with proc_fdinfo structs that are returned from the call and buffersize is the size of this buffer in bytes. The return value is the number of entries successfully written to the buffer. If the function is called with NULL for the buffer, it returns the number of file descriptors plus 20 as a suggested buffer size. arg appears to be completely unused.

Given all of this, writing a program to use proc_pidinfo to accurately count the number of open fds in the current process is straight forward.

use std::error::Error;
use std::os::raw::{c_int, c_void};
use std::ptr::null_mut;
use std::convert::TryInto;

fn main() {
    let num_fds = count_open_fds().unwrap();
    println!("Number of open fds: {}", num_fds);
}

pub fn count_open_fds() -> Result<usize, Box<dyn Error>> {
    let pid = std::process::id() as c_int;
    let fds_flavor = 1 as c_int;

    let buffer_size_bytes = unsafe {
        proc_pidinfo(pid, fds_flavor, 0, null_mut(), 0)
    };

    if buffer_size_bytes < 0 {
        return Err("proc_pidinfo failed".into());
    }

    let fds_buffer_length : usize = (buffer_size_bytes as usize / std::mem::size_of::<proc_fd_info>()).try_into()?;
    let mut buf: Vec<proc_fd_info> = vec![proc_fd_info::new(); fds_buffer_length];
    buf.shrink_to_fit();

    let actual_buffer_size_bytes = unsafe {
        proc_pidinfo(pid, fds_flavor, 0, buf.as_mut_ptr() as *mut c_void, buffer_size_bytes)
    };

    if actual_buffer_size_bytes < 0 {
        return Err("proc_pidinfo failed".into());
    }

    if actual_buffer_size_bytes >= buffer_size_bytes {
        return Err("allocated buffer too small".into())
    }

    buf.truncate(actual_buffer_size_bytes as usize / std::mem::size_of::<proc_fd_info>());

    return Ok(buf.len());
}

// Copying the related definitions from the headers
#[repr(C)]
#[derive(Copy, Clone)]
struct proc_fd_info {
    pub proc_fd: i32,
    pub proc_fd_type: u32
}

impl proc_fd_info {
    fn new() -> proc_fd_info {
        return Self {
            proc_fd: 0,
            proc_fd_type: 0
        }
    }
}

extern "C" {
    fn proc_pidinfo(pid: c_int, flavor: c_int, arg: u64, buffer: *mut c_void, size: c_int) -> c_int;
}

A Rust program that uses proc_pidinfo to count the number of open fds

Running this program now returns the expected output.

$ ./target/debug/counting-fds
Number of open fds: 3

Conclusion§

Using the proc_pidinfo function is a better way of counting the number of open file descriptors for the current process on macOS. proc_pidinfo allows for inspecting an arbitrary process and allows for accurate results. In addition, the results can be filtered on type of file descriptor such as sockets or pipes.