Scripting with lsof
Scope: lsof as a data source — the -F
field output built for parsers, a shipped
script that walks it, exit codes (dependable
for plain selections, not for the search
modes, with the captures to prove both), and
-r, lsof's own repeating snapshot.
Output meant for machines: -F
The nine-column table is aligned for eyes,
which makes it treacherous for scripts: a
filename with a space in it is
indistinguishable from two columns. -F
switches lsof to field output — one
field per item, each introduced by a
one-letter tag (p pid, c command, f
descriptor, n name, and more). With -F0
the separator is a NUL byte instead of a
newline, so no filename, however hostile,
can break the framing. The lab's holder
under -Fpcfn, NULs turned back into lines
by tr for the page:
$ lsof -Fpcfn -p 22472 | tr '\0' '\n' | head -24
p22472
cpython3
fcwd
n/home/hatch/workspace/lsof-lab
frtd
n/
ftxt
n/usr/bin/python3.12
fmem
n/usr/lib/locale/C.utf8/LC_CTYPE
...
f0
n/dev/null
The rhythm is mechanical: a p opens a
process, an f opens a descriptor, the n
after it is that descriptor's name. The
shipped examples/open-files.sh walks
exactly that rhythm with awk and prints a
script-friendly table:
$ /home/hatch/workspace/lsof-for-mere-mortals/examples/open-files.sh 22472
22472 cwd /home/hatch/workspace/lsof-lab
22472 rtd /
22472 txt /usr/bin/python3.12
22472 mem /usr/lib/locale/C.utf8/LC_CTYPE
...
22472 3 /home/hatch/workspace/lsof-lab/tree/script.txt
22472 4 /home/hatch/workspace/lsof-lab/tree/both.txt
[exit 0]
Fifteen lines for the holder, the two planted files last — the same table as chapter 2, flattened for a pipeline. If you script lsof, script this output, not the pretty one.
Exit codes: where they hold
For plain selections, lsof's exit code is a clean boolean — 0 found, 1 not found — and the lab has it from several directions:
$ lsof -p 999999
[exit 1]
$ lsof /home/hatch/workspace/lsof-lab/tree/unopened.txt
[exit 1]
A pid that doesn't exist; a file that exists but nobody holds (planted for the purpose, with content in it — an unopened file is invisible to lsof no matter how real it is). Chapter 5's dead port and chapter 6's empty AND agree: exit 1, no rows. A script may lean on that.
Where they don't: +D and +L
The search modes break the contract, and
the lab measured it rather than assuming.
With a tail planted on a lab file, three
consecutive +D runs over the lab tree —
the same tree chapter 3 showed returning
all three holders — all reported failure:
$ lsof +D /home/hatch/workspace/lsof-lab/tree (run 1)
[exit 1]
$ lsof +D /home/hatch/workspace/lsof-lab/tree (run 2)
[exit 1]
$ lsof +D /home/hatch/workspace/lsof-lab/tree (run 3)
[exit 1]
$ lsof +L1
[exit 0]
…and the +L1 that closed the session,
run when nothing on the machine held a
deleted file, printed nothing and exited
0. Under +D/+L, the exit status is
entangled with the internal search (files
that vanish mid-scan, entries it cannot
process) rather than with "did you find
any" — in this lab it pointed the wrong way
in both directions. The rule for scripts:
after a plain selection, trust $?; after
+D or +L, test the output (non-empty?
the row you expected?) and treat the code as
noise. Chapter 3's +D exhibit — right
rows, exit 1 — is the same lesson from the
other side.
Watching it change: -r
-r N repeats the query every N seconds,
separating snapshots with a ======= line —
lsof's own watch mode, no watch wrapper
needed. The lab let one run against the
planted listener under a 3-second timeout:
$ timeout 3 lsof -r 2 -i :8734
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
python3 22473 root 3u IPv4 859403 0t0 TCP localhost:8734 (LISTEN)
python3 22473 root 4u IPv4 859404 0t0 UDP localhost:8734
=======
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
python3 22473 root 3u IPv4 859403 0t0 TCP localhost:8734 (LISTEN)
python3 22473 root 4u IPv4 859404 0t0 UDP localhost:8734
=======
[exit 124]
Two snapshots two seconds apart, identical
(this listener's life is uneventful), and
the 124 at the end is timeout's exit, not
lsof's — lsof -r runs forever until
killed, which under timeout is the normal
way to take a bounded sample. In an
incident, -r on a port or pid is how you
catch the connection that only exists for a
moment.