See what a process is really doing with your files and your network: which files it reads and writes, which addresses it talks to, and how many bytes go each way. iotap is a command-line tool for macOS and Linux.
Give iotap a process ID or a name and it reports every read and write that process makes as it happens: the file or the remote address, how many bytes were asked for and how many moved, how long the call took, and whether it failed. When you stop, it sums the trace up per file and per endpoint. On macOS it also measures TCP/UDP traffic through the system's network statistics, including Skywalk traffic that does not make ordinary socket read/write calls. These measurements are kept separate from syscall byte counts.
It is for the moment when something on your machine is busy and you want to know with what: a
process is chewing through the disk, an app you just installed is talking to somewhere you never
asked for, a build step is slow and you suspect I/O, or a program keeps failing and you want to see
which file or connection it trips on. The usual tools each show a piece. lsof and netstat list
what is open right now but not what moves through it, Activity Monitor and top count bytes but do
not say where they went, a packet capture shows the traffic of the whole machine with no process
behind it, and strace on Linux and fs_usage on macOS print the calls but leave the adding up to
you. iotap puts the pieces together: the bytes, the process they belong to, and the file or endpoint
they went to.
It works from the kernel's own record of the system calls a program makes, so no debugger is attached to the program, no code is injected into it, and it runs exactly as it would otherwise. iotap keeps only metadata (paths, addresses, sizes and timings) and never the data that moves.
Install a prebuilt binary with Homebrew:
brew install rath/tap/iotap
iotap --versionThen trace a running process:
sudo iotap 1234 # one process, by ID; replace 1234 with a running PID
sudo iotap curl # by name; processes started later under it too
sudo iotap --tui curl # the same as live tables you can drill intoTracing needs root. Why it needs root says what iotap does with that access
and what it leaves alone. Add -f to follow the processes a traced one starts, --json for one
JSON object per line, and --record FILE to save a trace that --replay FILE shows again later,
without root. Requirements lists what a build needs, and Usage lists
every option.
The Homebrew tap supports Apple Silicon Macs and Linux on ARM64 or x86-64. Rust is not needed. Homebrew manages runtime dependencies; its own operating-system requirements also apply.
brew update
brew upgrade iotap
brew test rath/tap/iotapYou can also download an archive for your platform from
GitHub Releases, verify it against
SHA256SUMS, unpack it, and put the iotap executable on your PATH. Linux
release binaries require glibc 2.28 or newer, libelf, and zlib. On Debian/Ubuntu,
install the runtime libraries with sudo apt install libelf1 zlib1g; on
RHEL/Fedora, use sudo dnf install elfutils-libelf zlib.
To build from source, including on Intel Macs, first install the build tools in Requirements, then run:
git clone https://github.com/rath/iotap.git
cd iotap
cargo build --release --locked
sudo ./target/release/iotap 1234Here is iotap following one curl download:
$ sudo iotap curl
iotap: tracing 4242 (curl); press Ctrl-C to stop
TIME PID OP FD REQUESTED RESULT LATENCY TARGET
14:13:20.004541 4242 sendto 5 517 517 0.041 ms tcp 192.168.1.20:61000 -> 93.184.216.34:443
14:13:20.104625 4242 recvfrom 5 16384 4096 0.041 ms tcp 192.168.1.20:61000 -> 93.184.216.34:443
14:13:20.104708 4242 recvfrom 5 16384 EAGAIN 0.041 ms tcp 192.168.1.20:61000 -> 93.184.216.34:443
14:13:20.104791 4242 write 4 4096 4096 0.041 ms /Users/me/page.html
14:13:20.104875 4242 write 1 20 20 0.041 ms /dev/ttys004
iotap: 4242 (curl) exited
iotap summary: 4242 (curl) traced for 0.1 s
Files (2 targets)
READ CALLS WRITTEN CALLS FAILED TARGET
0 B 0 4.0 KiB 1 0 /Users/me/page.html
0 B 0 20 B 1 0 /dev/ttys004
Network (1 target)
RECEIVED CALLS SENT CALLS FAILED TARGET
4.0 KiB 2 517 B 1 1 tcp 93.184.216.34:443
Totals
files read 0 B (0 calls), written 4.0 KiB (2 calls)
network received 4.0 KiB (2 calls), sent 517 B (1 call)
en0 received 4.0 KiB (2 calls), sent 517 B (1 call)
5 calls, 1 failed
Read top to bottom, this is the whole life of one curl: it sent a 517-byte request to
93.184.216.34 on port 443, asked for 16 KiB back and got 4 KiB, asked again and found nothing
waiting yet (EAGAIN), wrote the 4 KiB to page.html and 20 bytes of progress to the terminal,
then exited. The summary at the end is the same story per file and per endpoint, and the
network traffic went over en0, the interface that holds 192.168.1.20.
For a process that runs longer, --tui shows the same tables live and lets you drill into any
file or connection, --json streams every event as one line for jq or a log, and --record
saves the trace so you can replay it later, in any of these forms, without root.
iotap does not attach a debugger, inject code or change the program it watches; the program runs exactly as it would otherwise. Instead iotap reads the kernel's own record of what the program asks for. Every read, write, open and connect a program makes is a system call, and the kernel can be told to log each one as it happens, with the process it came from, its arguments and its result.
- On macOS that log is the kernel trace facility, kdebug, the same source Apple's
fs_usagereads. Network measurements use the built-in NetworkStatistics framework; root also gives it access to Skywalk statistics. Nothing has to be installed. - On Linux, iotap loads a small eBPF program of its own onto the kernel's syscall tracepoints. The kernel verifies the program before running it and unloads it when iotap exits.
Both are a view into every process on the machine, and that is what only root gets. A user who
could switch on kdebug or load an eBPF program could watch every other user's programs, so the
kernel allows neither to anyone else. That is why tracing needs sudo, exactly as fs_usage
does, and why iotap says so and stops before it touches anything when run without it. Replaying a
recording reads nothing from the kernel, so it needs no root.
What iotap does with that access is deliberately narrow:
- Metadata only. It records the call, the descriptor, byte counts, latency, the path and the socket endpoint. It never reads or stores the data being transferred: a process reading your SSH key shows up as a read of that file and its size, never its contents. Recordings hold the same and nothing more.
- Only the processes you name, and with
-fthe processes they start. The kernel is asked to record their calls and no others. - Nothing left behind. On macOS only one program can own kdebug at a time, so iotap releases it on every exit path, including errors, signals and panics. On Linux the kernel drops the eBPF program the moment iotap exits, however it exits. The terminal UI restores the terminal the same way.
- One binary, no daemon. iotap runs only while you run it, and makes no connections of its
own; the optional host name lookups (
--resolve) go through the system's resolver.
- macOS, or Linux 5.8 or later on 64-bit Arm or x86-64 with BPF and syscall tracepoints, as
distribution kernels have them. Tracing needs root, so run iotap with
sudo(see Why it needs root); replaying a recording does not. - To build: Rust 1.98.1, which
rust-toolchain.tomlselects, and- on macOS, the Xcode Command Line Tools, for the small C file that reads descriptor details from libproc;
- on Linux, clang for the eBPF program, and what the libbpf bundled with libbpf-rs needs to
build: make, pkg-config, a C compiler, and the libelf and zlib development files. On Debian
and Ubuntu that is
sudo apt install build-essential clang pkg-config libelf-dev zlib1g-dev. The binary links libelf and zlib.
iotap [OPTIONS] <TARGET>...
iotap --replay <FILE> [OPTIONS]
A TARGET is a process ID or a process name. A name matches every running process whose name,
executable's file name or first argument's file name equals it, ignoring case, and iotap also
traces processes started later under that name. At least one process must match when iotap
starts. -n makes numeric targets names.
With -f, iotap also traces the processes that the traced ones start, and theirs in turn: every
descendant, those running when it starts and those started later. The descriptors a child has
from its parent show their files and endpoints like any others. iotap leaves out itself and the
processes it runs under, such as its sudo, so tracing the shell it was started from takes in
the shell's other children. On Linux a child is traced from its start, on macOS from a few
milliseconds after (see Limitations).
The first argument, argv[0], holds the command a process was started by, and it is the name
pgrep and killall go by on macOS. The macOS kernel names a process after the file it runs,
links followed, so a program started through a link to a file named after its version, as some
installers set up, has the version for its name and still matches the name of the link. On Linux
a process's name is the one the kernel keeps, at most 15 characters, so a longer name matches
only the file names.
sudo iotap 1234 # one process
sudo iotap Safari # every Safari process, and new ones
sudo iotap -f make # make and every process it starts
sudo iotap --tui 1234 # live terminal UI
sudo iotap --tui -q 1234 # the same without the Events tab
sudo iotap --tui --resolve curl # host names in place of remote addresses
sudo iotap -i wlan0 firefox # only network I/O over wlan0
sudo iotap --json curl | jq -c 'select(.type == "event")'
sudo iotap -q -d 10 Finder # summary only, after 10 seconds
sudo iotap --net-only --record t.iotaprec 1234
iotap --replay t.iotaprec # the same output again, without root
| Option | Effect |
|---|---|
-f, --children |
Also trace every descendant of the traced processes, running or started later |
--tui |
Live terminal UI instead of the event stream |
--json |
JSON Lines instead of text |
-q, --quiet |
No event lines; notices and the summary remain. With --tui, no Events tab |
--files-only, --net-only |
Report one kind of I/O |
-i, --interface NAME |
Report only network I/O over this network interface; repeat for several (see Network interfaces) |
--resolve |
Show remote addresses as host names in the summary, and in the terminal UI from the start (see Host names); not with --json |
-d, --duration SECS |
Stop after this many seconds |
--top N |
Rows per table in the text summary; default 30 |
--buffer RECORDS |
Kernel buffer size in 64-byte records; default 524288, which is 32 MiB. On Linux the ring buffer takes as many bytes, rounded up to a power of two |
--record FILE |
Also save the trace for --replay |
--replay FILE |
Replay a saved trace through any output mode |
Tracing ends on Ctrl-C, when every traced process has exited, or when --duration runs out. The
summary follows. A second Ctrl-C exits at once, without a summary. The exit status is 0 after a
normal stop, 1 after an error and 130 after a forced exit.
One line per call. OP is the operation with _nocancel and guarded variants folded into their
base call. RESULT is the byte count, a message count, the errno name of a failed call, or ? when
the trace does not carry the size. REQUESTED and LATENCY show - when they are unknown, such as
for calls that take several buffers or calls that began before tracing did. TIME is local time.
Notices go to stderr, so stdout stays a clean event stream. They report processes that start, exit or exec, children that iotap could not trace, and records the kernel dropped because its buffer was full.
With --json, every line is one object with a type field. Times are nanoseconds since the Unix
epoch.
type |
Fields |
|---|---|
start |
time_ns, processes |
event |
time_ns, pid, tid, op, dir, syscall, fd, requested, bytes, messages, errno, error, latency_ns, target, interface, resolved |
network_sample |
macOS: time_ns, interval_start_ns, source, process_id, pid, target, interface, received_bytes, sent_bytes |
network_status |
macOS: time_ns, state (active, partial, unavailable), reason |
lost_events |
time_ns |
attached |
pid, name, parent |
untraced |
pid, parent, reason |
exec |
pid, path |
exited |
pid, name |
summary |
duration_ns, processes, totals, interfaces, lost_events, unfinished_calls, calls_started_before_trace, untraced_children, unknown_interface_calls, files, network, other, optional network_traffic |
event, totals, network and interfaces retain their syscall meanings. Never add their
bytes to network_traffic: they can describe the same communication. A network_sample
contains the increase in OS counters over its interval, not one syscall. Its target is an
endpoint (proto, optional local and remote); source identifies the observed connection
and process_id distinguishes different processes that reused the same PID. network_traffic
contains source, status, received_bytes, sent_bytes, per-interface interfaces and
per-endpoint targets. Check its status before interpreting totals; unavailable statistics
do not mean that the application used no network. -q suppresses samples but keeps status.
The parent of an attached process is the traced process that started it, or null when iotap
does not know it, as for a process attached by its name. An untraced object tells of a child
that -f could not trace: its reason is ended when the child ended before iotap could trace
it, and full when iotap was tracing as many processes as it can. The summary's
untraced_children counts them by reason.
A target is {"kind":"file","path":…}, {"kind":"socket","proto":…,"local":…,"remote":…}
with a path for Unix-domain sockets, {"kind":"other","fd_type":…} or {"kind":"unknown"}.
An event's interface is the name of the network interface its I/O went over, "?" when iotap
cannot tell it, or null for I/O that goes over none, such as a file's or a Unix-domain
socket's (see Network interfaces). The summary's interfaces sums the
network I/O over each, in the same terms, and unknown_interface_calls counts the calls that
--interface left out for going over an interface iotap cannot tell.
A file path that starts with … is only the end of a longer path (see
Limitations). An errno is the traced system's own number, which error names.
resolved says how iotap learned the target:
tracedmeans iotap saw the call that created the descriptor.snapshotmeans the descriptor was already open when tracing of the process began.lazymeans iotap looked the descriptor up when it was first used.nonemeans the descriptor is unknown. Its target isunknown, or a baresocketwhen the call works only on sockets.
{"type":"event","time_ns":1790259200104708333,"pid":4242,"tid":2,"op":"recvfrom","dir":"read","syscall":"recvfrom","fd":5,"requested":16384,"bytes":null,"messages":null,"errno":35,"error":"EAGAIN","latency_ns":41666,"target":{"kind":"socket","proto":"tcp","local":"192.168.1.20:61000","remote":"93.184.216.34:443"},"interface":"en0","resolved":"traced"}
--tui shows throughput for the last complete second and in total, then three tabs: files,
network endpoints, and the latest 10,000 events. i adds the network throughput of each
interface below the totals. With -q the Events tab is left out, and
events are not kept for it. This is the Files tab after replaying a recorded download:
iotap 4242 curl (exited) 0:00:12
FILE READ FILE WRITTEN NET RECEIVED NET SENT
per second 0 B 1.2 MiB 1.2 MiB 0 B
total 0 B 14.1 MiB 14.1 MiB 517 B
End of the recording. Press q for the summary.
1 Files (2) 2 Network (1) 3 Events (7,205) sort: bytes
READ CALLS WRITTEN CALLS FAILED IDLE TARGET
0 B 0 14.1 MiB 3601 0 <1s /Users/me/big.iso
0 B 0 20 B 1 0 <1s /dev/ttys004
4242 (curl) exited q quit s sort p pause i interfaces r reset ↑↓ select enter details
| Key | Action |
|---|---|
1 2 3, Tab, Left, Right |
Switch tabs |
s |
Sort by bytes, read, write, calls or most recent |
p or Space |
Pause the view; tracing goes on |
| Enter | Show or hide the details of the selected row; with none selected, select the top row and show its details |
y |
Copy the selected row's path, or its socket's address |
r |
Reset the view: tables, totals and events start again from zero, and the clock shows the time since the reset. The summary still covers the whole trace |
n |
Show remote addresses as host names, or as addresses again (see Host names). The summary printed on quitting does as the UI did |
i |
Show the network throughput of each interface below the totals, or hide it again (see Network interfaces) |
v |
On macOS, switch Network between OS-measured Traffic and socket Syscalls |
Up, Down, Page Up, Page Down, Home, End, or k j g G |
In the Files and Network tabs, select a row and move the selection; the first key selects the top row, or the last row for End. In the Events tab, scroll; End follows new events again |
| Esc | Back out a step: close the details, then let go of the selection, then quit |
q, Ctrl-C |
Quit and print the summary |
The UI stays open after tracing stops and says why it stopped.
On macOS, Network starts in Traffic view: received and sent bytes, interval-average rates, connection counts and endpoints. Syscalls keeps the existing socket-call table, including Unix-domain sockets, call counts, errors and latencies. The top network totals and interface table follow the selected view. Pause and reset apply to both views; resetting does not change the final summary. The text summary prints Traffic and Syscalls separately.
Traffic is sampled about once a second. Existing connections start at a baseline so their
earlier traffic is excluded. New connections whose start is known to follow attachment include
their first counters. Rates distribute each measured interval over the seconds it covers;
they are estimates within that interval, not packet timestamps. An interface change whose
bytes cannot be attributed unambiguously is shown as ?. Only processes selected by the usual
target rules are included; activity delegated to an unrelated system service is not reassigned.
NetworkStatistics is a private macOS interface. If it is unavailable, iotap says so and keeps tracing syscalls. Lost samples, restarted counters or an unresponsive collector mark measurements as partial, retaining the bytes already observed. The UI does not show partial data as a current rate. Linux and older recordings keep the syscall-only view.
Nothing is selected at first, and each table shows its top rows as they change. A selected row is bold, with a mark in the left margin, and the selection stays with its target as the rows re-sort.
Tables write the home directory of the user who ran sudo as ~. A path too long for its column
loses directory names from the left, each cut to its first letter as in the fish shell's prompt,
so the file name and the directories nearest it stay whole the longest:
~/L/A/G/C/Default/Cache/Cache_Data/data_1. The details panel and y use the full path.
The details panel opens below the Files or Network table and shows the selected target:
- its full path or endpoint
- while host names are shown, the name of its remote address, or why there is none
- bytes and calls in each direction, and the failed calls
- mean and longest latency
- when it was first and last used
- the processes that used it
- for a socket, the network interfaces its I/O went over
- for a connection, its local addresses
- its latest events, unless
-qleft out the Events tab
For a file or a Unix-domain socket with a path, the panel also shows what the path is now: kind,
size, time since modification, permissions and owner. It reads this metadata with lstat each time
it draws, never the contents, so a replay shows the file on the replaying machine.
y copies in every way the system has:
- The terminal's clipboard (OSC 52). This reaches the machine you sit at, even over SSH, in
terminals that allow it: iTerm2 (once "Applications in terminal may access clipboard" is on),
kitty, WezTerm, Ghostty and Alacritty. tmux passes it on with
set-clipboard on. Terminal.app ignores it. - The Mac's pasteboard, on macOS.
pbcopyruns as the user who ran sudo, so it works in any terminal on the Mac that iotap runs on.
On macOS the status line reports what pbcopy did. A terminal never says whether it honoured
OSC 52, so on Linux the status line says only that the terminal was asked.
With --resolve, or n in the terminal UI, remote addresses show as host names:
tcp www.example.com:443 in place of tcp 93.184.216.34:443. iotap asks the system's resolver
through getnameinfo, so the name comes from the hosts file, a reverse DNS lookup or multicast
DNS, as the system is set up to look. An answer can take half a minute, so up to 16 threads of
iotap's own wait for them, and an address shows as itself until its name arrives.
Only the addresses shown are looked up, each once: the rows on screen in the terminal UI, and
the rows of the summary, for which iotap waits at most two seconds. Event lines, JSON and
recordings keep the addresses, and replaying with --resolve looks the names up again.
The kernel's records do not say which network interface a call's data went over, so iotap tells
it from the socket's addresses and the host's interfaces, which it lists with getifaddrs as
tracing starts and again when a socket's local address is new to it, at most once a second of
the trace:
- Traffic to a loopback address or to one of the host's own addresses goes over the loopback
interface,
loon Linux andlo0on macOS, whichever address it comes from: the kernel routes it there. - Other traffic goes over the interface that holds the socket's local address. A link-local IPv6 address goes by the interface it is scoped to.
- A socket without a local address of its own, such as an unconnected UDP socket bound to every
address, sends each datagram wherever the route to its destination leads, which iotap does
not see. Its I/O, and that of a socket whose ends iotap never learned, goes over an interface
iotap cannot tell, shown as
?. - Unix-domain, netlink and routing sockets, files and other descriptors go over no interface,
shown as
nonewhere interfaces are listed.
-i NAME reports only the network I/O over the interface named NAME, exactly as
ip link or ifconfig names it, case included; repeat it for several. File and other I/O is
left out, and so is I/O over an interface iotap cannot tell, which the summary counts. iotap
warns when no interface has the name, but goes on, since the interface may come up later, as a
VPN's does. The text summary lists the network totals of each interface under the network line:
network received 14.1 MiB (3642 calls), sent 1.8 KiB (12 calls)
wlan0 received 14.1 MiB (3601 calls), sent 517 B (1 call)
lo received 2.0 KiB (21 calls), sent 1.1 KiB (9 calls)
? received 0 B (0 calls), sent 40 B (1 call)
none received 1.2 KiB (20 calls), sent 120 B (1 call)
In the terminal UI, i shows the same below the throughput, with the last complete second
beside the totals. The table's rows are the busiest interfaces first, then ? and none; when
the screen is short, the last row sums the interfaces that did not fit.
FILE READ FILE WRITTEN NET RECEIVED NET SENT
per second 0 B 1.2 MiB 1.2 MiB 0 B
total 0 B 14.1 MiB 14.1 MiB 1.8 KiB
INTERFACE RECEIVED/S SENT/S RECEIVED SENT
wlan0 1.2 MiB 0 B 14.1 MiB 517 B
lo 0 B 0 B 2.0 KiB 1.1 KiB
? 0 B 0 B 0 B 40 B
none 0 B 0 B 1.2 KiB 120 B
--record FILE saves the raw kernel records together with every answer libproc or /proc gave and
when it gave it, and each list of the host's network interfaces, and --replay FILE feeds them
through the same processing, so a replay reproduces the output of the live run in any output mode,
except that the text and terminal output write times of day in the local time zone of the machine
that shows them, where JSON Lines give times as nanoseconds since the epoch. A recording holds paths
and addresses but no transferred data. It replays on the operating system it was made on, since its
calls use that system's numbers for errors, address families and flags. iotap writes the file out
each time it has read the trace up to a new point, so the recording of a run that was killed replays
up to about there, and says that it ends abruptly.
Version 3 recordings also save macOS network counter observations, their timestamps and status. Their differences are calculated again during replay, without consulting the live system. Version 1 and 2 recordings remain readable and have no OS traffic measurements.
- On macOS, iotap configures kdebug to record BSD syscalls, file-system path lookups and process
exits, and only for the traced processes. On Linux, it loads its eBPF program onto the
kernel's syscall entry and exit tracepoints and, from Linux 6.16, its process exit tracepoint.
The program pairs the entry and return of each call of a traced process in the kernel, and
writes one record per call to a ring buffer, with the path, socket address or pair of new
descriptors the call took, read from the process's memory as the call returns.
With
-f, the Linux program also watches the kernel's task creation tracepoint, which fires in the parent before a child can run, and traces each child of a traced process from there. On macOS kdebug does not pass tracing on to a child, but it records the creation of every thread, whatever process it belongs to, and each exec; from these records iotap finds the processes that traced ones start, and flags each for tracing as soon as it reads the record. - A reader thread drains the kernel buffer at least every 10 ms, so that descriptors can be looked
up while they are still open. On Linux the program wakes it sooner once a quarter of the ring
buffer is full; until then records wait, so that a busy process is read in batches. Every
250 ms it also checks the processes for exits, exec and new processes with a traced name; with
-f, its first check also takes in any descendant started while tracing began. On Linux, calls that return on different processors reach the ring buffer slightly out of order, so the reader holds each record until 5 ms after its call returned and passes the records on in the order the calls returned. - The main thread pairs the entry and return record of each syscall on macOS, reassembles paths
from the lookup records, and tracks what each descriptor refers to. When a process is attached,
its open descriptors come from libproc on macOS and from
/procon Linux; after that, the traced open, socket, connect, accept, dup, fcntl and close calls keep the table current. - libproc and
/procdescribe a descriptor as it is when asked, a few milliseconds after the traced call. In between, the process may have closed it and received the same number for another one. So an event whose target comes from such an answer is held back until the trace has been read past the moment of the answer. If the trace shows the descriptor closed before then, the answer described whatever held the number at that moment. It still stands when that was the same file, which on macOS the kernel's lookups tell by the file's vnode; otherwise the event gets what the trace alone knows. An event waits about 10 ms for this, at most about 100 ms, and longer only while iotap lags behind the kernel. - Processing after the reader depends only on the records, on the answers of libproc or
/procand on the lists of the host's network interfaces, never on the clock, which is why recordings replay exactly. Only the live terminal UI reads the clock, for its elapsed time and its current second, and host names, when asked for, are what the resolver answers at the time. Times of day in text and terminal output are in the time zone of the machine that shows them.
- One owner, on macOS. Only one program can use kdebug at a time. iotap cannot run alongside fs_usage, ktrace, Instruments or tailspin. On Linux several iotap runs, and other eBPF tools, can trace at once.
- Syscall coverage. Memory-mapped file I/O and I/O the kernel does on a process's behalf, such as
page-cache writeback, never appear. On Linux neither does I/O submitted through io_uring or
libaio (
io_submit), nor data thatsplice,tee,vmspliceorcopy_file_rangemove. - Unknown sizes. On macOS,
sendfilereturns its byte count through a pointer the trace does not carry, andsendmsg_xandrecvmsg_xreturn message counts, so iotap counts those calls without bytes. On Linux,sendmmsgandrecvmmsgreturn message counts, andsendfilecounts as a write to its output descriptor. - Stale lookups. iotap discards an answer of libproc or
/procwhen the trace shows the descriptor closed before the answer was given, unless the number was by then held by a descriptor on the same file, which only macOS lets iotap tell. A descriptor closed in a way the trace does not show, as by exec for a close-on-exec descriptor, can still leave a lookup naming a later descriptor. - Children. Without
-f, the processes a traced one starts are not traced. With it, on macOS a child is traced from when iotap reads the kernel's record of its creation, a few milliseconds after it starts: its first calls are missed, and so is a child that ends sooner, which iotap reports. On Linux a child is traced from its start. There-fneeds the kernel'stask_newtasktracepoint laid out as iotap's program reads it; iotap checks, and refuses-fwhere it is not. - New processes by name. A name target picks up processes that start with that name, or take
it by exec, within 250 ms and misses their first calls under it. On macOS, a process that gives
itself the name in
argv[0]later, as Node.js programs do throughprocess.title, is picked up only if it does so within two seconds of starting or of its latest exec. On Linux such programs rename the process too, which iotap notices whenever it happens. - exec, on macOS. exec gives a process a new kernel identity without the trace flag. iotap flags the process again once it reads the kernel's record of the exec, usually within 10 ms and at most 250 ms later, and misses the calls in between. On Linux tracing goes on through exec.
- Short-lived sockets. A socket closed before iotap could look it up shows only what the trace
reveals: its protocol, such as
tcp ?, the path of a Unix-domain connect, the protocol and bound address of the socket it was accepted on, or justsocket. On Linux the trace also carries the address a connect named, so such a socket keeps its remote end, as inudp -> 127.0.0.53:53. On macOS, descriptors fromsocketpairreach the process through memory, so iotap learns them only through libproc. - Dropped records. Under heavy load the kernel buffer can overflow. iotap reports it and the
totals undercount; a larger
--bufferhelps. - Paths. The macOS kernel reports a path as it resolved it, after following symbolic links,
and before macOS 15.4 only its last 184 bytes. On Linux the program reads a path as the process
passed it. iotap takes the full name from libproc or
/procwhen the new descriptor, or on macOS a later one on the same file, still holds its number by the time iotap asks. Otherwise a relative path is joined to the working directory or the directory descriptor. On macOS that is wrong after a link with a relative target other than/etc,/tmpand/var, and a truncated path is shown as…followed by its end. A name that is not valid UTF-8 is shown with U+FFFD in place of the invalid bytes, so two files whose names differ only in those bytes count as one. On Linux, a file made byopenoropenatwithO_TMPFILEhas no name for the trace to give, so iotap takes it from/proc, as/tmp/#7301, when it looks the descriptor up; one made byopenat2shows as the directory it was made in. - 32-bit processes, on Linux. iotap knows the call numbers of 64-bit processes. A 32-bit program numbers its calls differently, so its trace is misread; do not trace one.
- Exits, on Linux before 6.16. Only from Linux 6.16 does the kernel tell the program when a
process's last thread exits. On older kernels iotap learns of an exit from its poll of
/proc, up to 250 ms later. - Containers, on Linux. The program knows processes by their IDs outside any PID namespace, so run iotap on the host, where it traces processes in containers by those IDs.
- Recording size. A recording grows by about 64 bytes per kernel record on macOS, and by 96 bytes plus the path or address it took per call on Linux.
- Interfaces. iotap names the interface that holds a socket's local address and does not
look up routes, so for a bridge or a bond it names the bridge or the bond, never a member
port, and it misses where policy routing sends traffic out another interface. Sockets that
send from every address, and sockets whose ends iotap never learned, count as
?. On Linux, a process in another network namespace than iotap's, as in a container, has other interfaces than the ones iotap lists, so its traffic counts as?too; run iotap in that namespace to tell them apart. A recording made before iotap listed interfaces knows none. - Host names. A reverse lookup finds the name the owner of an address gave it, often one of a
hosting or CDN provider rather than the name the program looked up, and often none. iotap
cannot learn the name the program looked up, since it never reads the data programs send or
receive. Each lookup is a query to the configured DNS servers, and while iotap traces the
resolver itself,
mDNSResponderon macOS orsystemd-resolvedon Linux, its own lookups show in the trace.
The automated tests need no root. They cover everything after the kernel with synthetic record
streams laid out the way XNU and iotap's eBPF program write them, but they cannot exercise the
kernel interface itself. After changing src/sys/, src/reader.rs, a syscall table, csrc/ or
bpf/, run the checks for each system the change affects.
scripts/live/run.py runs them and judges every expectation, printing PASS or FAIL; given check
numbers, it runs only those. Build with cargo build --release, then start it in a terminal
where sudo -v has been answered: each root step uses sudo -n, and the run ends with
sudo -k. On macOS, open -a Terminal scripts/live/macos.command asks sudo and runs it in a
window of its own, where sudo can use Touch ID. Its outputs go to target/live/. The terminal UI
checks need Python's pyte package, the host-name checks a connection to the Internet, and the
interface checks a route to 192.0.2.1, such as a default route.
-
Writes to a file. Run
yes > /dev/null &and thensudo ./target/release/iotap $!. Expect a stream ofwritelines to/dev/null, and after Ctrl-C a summary with one file row. -
Byte counts. Run
dd if=/dev/zero of=/dev/null bs=1m &and trace its pid. Everyreadof/dev/zeroand everywriteto/dev/nullshould move 1048576 bytes. -
Network. Start a slow download, such as
curl -o /dev/null --limit-rate 100k <URL>, and runsudo ./target/release/iotap curl. Expecttcp … -> <server>:443rows, and a summary when curl exits. -
Dropped records. The kernel buffer holds at least 8192 records per CPU, whatever
--bufferasks for, so make iotap fall behind instead. While it traces theyesprocess and one waiting to write, stop it withsudo kill -STOP <iotap pid>and continue it two seconds later withsudo kill -CONT. Then killyesand let the other process write a known number of times. Expect the dropped-records notice, and tracing to go on and count every one of those writes. -
Single owner. While
sudo fs_usageruns, iotap must fail with the "another tool … is using the kernel trace facility" error. -
Release. After iotap exits by Ctrl-C, by
--durationor because the target exited,sudo fs_usage -t 1must start normally. -
Replay. Trace with
--json --record t.iotaprec, then runiotap --json --replay t.iotaprec. The replayed output must be identical to the live output. -
Terminal UI. Run
sudo ./target/release/iotap --tui $!against theyesprocess. Try every key, then quit; the terminal must be restored and the summary printed. -
Names. While iotap traces a name, start a process that takes it by exec a second after it starts, such as
sh -c 'sleep 1; exec ./mycat'for acatyou built that waits for its input. A copy of/bin/catwill not do: macOS kills copies of its own programs as they start. Expect the "now tracing" notice for it. Then runyesthrough a link to/usr/bin/yesand trace the link's name; while that runs, start a secondyesthrough the link, and a process that takes the name inargv[0]a second after it starts, asperl -e 'sleep 1; $0 = "<name>"; …'does. Expectyesas the first process's name, and the "now tracing" notice for the other two. -
Host names. Repeat check 3 with
--resolve, then with--tui --resolve. Expect the server's host name in place of its address in the summary and in the Network and Events tabs, the address and the name in the details panel, andnswitching between names and addresses. -
Calls and targets. Trace a program that opens files through links, along long paths and relative to other directories, and one that makes known calls on files, a pipe and TCP, UDP and Unix-domain sockets, such as
labandworkloadinscripts/live/programs.py. Expect every file under the path the kernel gives its descriptor (F_GETPATH), every call with its size, and no socket with a wrong end. A socket closed at once may lack its ends, as Limitations says. -
Children. Trace with
-fa process that has a child running, and that then starts a child that starts one of its own, a child that runs another program by exec, and twenty children that each write a little and end at once, such asfamilyinscripts/live/programs.py. Expect the running child among the processes traced from the start, a "now tracing" notice naming its parent for each later child, and every write of the children that wait half a second before writing. Each child that ended at once must be traced or reported to have ended before iotap could trace it, and the summary must count the latter. Traced with-f, a process that iotap runs under, such as the shell that started it, must take in neither iotap nor its sudo, but must take in a process it starts meanwhile. -
Network interfaces. Trace, with
--jsonand--record, a program that receives on a UDP socket connected to an address the default route leads to, sends to 127.0.0.1, to::1and to the host's own address, sends over a socketpair and from a socket bound to every address, each with a size of its own, such asinterfacesinscripts/live/programs.py. Expect theinterfaceof the first to be the oneroute -n getnames, the loopback interface for the next three, null for the socketpair and?for the last, the summary'sinterfacesto add up to its network totals, and the replay to match the live output. With-iand that interface, expect only its I/O and a count of the calls left out; with-q -i lo0, a Totals line forlo0and no Files table; with a name no interface has, a warning and the trace. In the terminal UI,imust show a row for each interface, its columns in line with the throughput's, and the details of the connected socket its interface. -
Network statistics. Trace a Foundation HTTP client with
--json --record. Expectnetwork_samplerecords with its PID and endpoint, a separatenetwork_trafficsummary whose totals equal those samples, and a byte-identical replay. Compare received bytes withnettopwhile the connection is still open. The client reports whether macOS chose a Skywalk channel. In the TUI,vmust switch between Traffic and Syscalls without changing either account.
Run these on each processor the change affects. bpftool prog show lists the eBPF programs
loaded; iotap's are named sys_enter, sys_exit, from Linux 6.16 process_exit, and with -f
task_newtask.
- Root. Without sudo, iotap must fail with "tracing with eBPF requires root".
- Writes to a file. Run
sh -c 'sleep 1; exec dd if=/dev/zero of=/tmp/x bs=1M count=10 status=none' &and trace$!at once. Expect tenwritelines of 1048576 bytes to/tmp/x, and a summary when dd exits. dd is gone too soon for an exec notice; tracesh -c 'sleep 1; exec sleep 1'for one. - Network. Serve a file with
python3 -m http.server 8000 --bind 127.0.0.1and trace acurl -o /dev/nullof it. Expecttcp 127.0.0.1:… -> 127.0.0.1:8000rows whose received bytes add up to more than the file's size. - Sockets and pipes. Trace a program that talks over a Unix-domain socket with a path, one in
the abstract namespace, a pipe and a UDP socket on
::1, then keeps them open for a second. Expectunixrows with the path and with@name, audp [::1]:…row, and apiperow among the other descriptors. - Dropped records. Trace
yes > /dev/null &and a process waiting to write with--buffer 1024, stop iotap withkill -STOPand continue it two seconds later. Then killyesand let the other process write a known number of times. Expect the dropped-records notice, and tracing to go on and count every one of those writes:yescan make calls twice as fast in one run as in another, so its own count cannot show what iotap missed. Signal iotap from a root shell: where sudo runs commands in a pty, as Ubuntu's does, a stopped command makes sudo stop its own process group, the script that started it included. - Release. After iotap exits in any way, even by
sudo kill -KILLwhile tracing with-f,sudo bpftool prog showmust no longer list its programs. - Several at once. Two iotap runs tracing the same process must both report all its calls.
- A flood. Record
yes > /dev/nullfor three seconds and replay it with--json. No event may lack its latency but for calls truly under way when tracing began, and the summary must count none that were dropped. - Names. Trace a name, then start a new process with that name and one that takes it by exec,
each blocked in a read for a second. Expect the "now tracing" notice for both, and their calls.
Repeat with a name that only
argv[0]holds, asbash -c 'exec -a <name> cat'gives, and with a process that takes the name inargv[0]a second after it starts, asperl -e 'sleep 1; $0 = "<name>"; …'does. - Short-lived connections. Trace a program that connects UDP sockets to a local server and
closes each at once. Expect
udp -> 127.0.0.1:…rows, notudp ?. - Calls under way. Trace a process whose read began before iotap started and ends while it
runs, and one whose read begins while iotap runs and has not ended when it stops. The summary
must note one call that began before tracing, and with
--jsoncount one unfinished call. - Replay, terminal UI and host names, as on macOS, the last with a download from a public server.
- Children, as on macOS, except that every child is traced from its start: each of the children that end at once must be traced, with the write it makes.
- Network interfaces, as on macOS, with the interface
ip route getnames andlo.
When scripting these checks, signal iotap itself, or send the signal from another process group.
sudo does not pass on a signal that comes from its own process group, which is where kill -INT $!
in the script that started sudo iotap & sends it from.
See AGENTS.md for the module map, the rules and the commands every change must pass.