macOS doesn't like polling /dev/tty

macOS (more specifically Darwin) doesn’t support using kqueue(2) or poll(2) to monitor the /dev/tty file.1 You are required to use select(2) or pselect(2) instead. See below for some possible solutions I am aware of.

I recently ran into this issue while updating zf to support SIGWINCH (terminal window resize) signal handling. I couldn’t find much information about the /dev/tty issue online, so hopefully what I write here will save others the trouble I went through.

# Why use /dev/tty

Many common command line tools read from stdin and write to stdout. The device file /dev/tty represents the terminal for the current process. Even if a program has input or output redirected, using the /dev/tty file always allows reading or writing to the terminal.

Here’s an example shell session that illustrates this:

$ # execute pwd in a shell
$ bash -c 'pwd'

$ # execute pwd in a shell that has stdout redirected to /dev/null (no output)
$ bash > /dev/null -c 'pwd'

$ # this time redirect pwd to explicitly write to the terminal device file /dev/tty
$ bash > /dev/null -c 'pwd > /dev/tty'

Even though the bash subprocess was redirecting all stdout to /dev/null, we were still able to write directly to the terminal with /dev/tty.

So why is this useful? Any program that wants to control both the terminal and support I/O redirection needs to use /dev/tty.

Example programs that rely on /dev/tty include fuzzy finders like fzf or zf. That is why you can run vim $(fd -tf | fzf) without issue: fzf will read the list of files on stdin and open the selected file in vim while the interactive fuzzy finding interface uses /dev/tty to directly access the terminal.

# macOS makes things difficult

If you only need access to /dev/tty in the main loop of your program, then there isn’t an issue. Read and write to that file like normal and smile because you don’t have to worry about the /dev/tty polling problem.

But many programs need to asynchronously monitor events like multiple files, signals, sockets, etc. To monitor multiple events efficiently on macOS you would typically use kqueue(2). Sadly, macOS doesn’t allow using kqueue(2) or even poll(2) to monitor /dev/tty events.

I cannot find any authoritative documentation describing this limitation. If there is a good source to document this, please let me know and I will update this post. Most mentions I found online were issues in open source projects that are in various stages of dealing with this issue: crossterm and helix for example.2

If you want to poll /dev/tty on macOS, you must use select(2) or pselect(2). These functions have limitations, primarily being the max file descriptor number they can monitor. From the Linux select(2) manual page:

WARNING: select() can monitor only file descriptors numbers that are less than FD_SETSIZE (1024)—an unreasonably low limit for many modern applications—and this limitation will not change. All modern applications should instead use poll(2) or epoll(7), which do not suffer this limitation.

Another limitation is that kqueue(2) offers built-in support for monitoring signals, file vnode events (update, rename, etc.), while select(2) only supports file descriptors.

# Possible solutions

Here are some ideas on how to work around these limitations:

  • On macOS you can define _DARWIN_UNLIMITED_SELECT to bypass the file descriptor limit, but this isn’t portable.

  • For me, a max file descriptor limit is not an issue because zf shouldn’t require file descriptors anywhere near the limit of 1024. To handle /dev/tty events and SIGWINCH I am using pselect(2), which is explained well in this article. This relies on pselect(2) being more predictable with signal handling by atomically masking signals and waiting at the kernel level.

  • An alternative when pselect(2) is not available is the self-pipe trick. This uses a non-blocking pipe in a signal handler that writes a byte. The read end of the pipe is monitored by select(2).

  • libuv is an example of a library that needs to support large file descriptor numbers, and also offers support for /dev/tty. To do this they create a separate thread that uses select(2) and the self-pipe trick to communicate with the main event loop. See this blog post or this commit for more details. You could also use libuv or another similar library directly.

Hopefully this helps point you in the right direction if you are struggling with this problem like I was!

  1. /dev/null (and maybe other files?) are also unsupported.↩︎︎

  2. Coincidentally, there was an issue created just today on the Zig repository to add a select(2) wrapper to the standard library motivated by the /dev/tty limitation on macOS. It links to this wonderful blog post which I would have loved to find sooner.

    I had already written a draft of this post so I figured I would share it anyway. I struggled to find information on this issue, and it doesn’t hurt to put more out there in case it helps others.↩︎︎