Skip to content

Ports and Sockets

Scope: lsof against the network — the -i selection, a planted TCP listener and UDP socket, the rows for a live connection from both ends at once, and the same questions put to ss until the two tools agree, including after the listener dies.

The plant: one process, two sockets

examples/listen.py holds a TCP listener and a UDP socket on the same loopback port, 8734:

$ python3 bin/listen.py 8734 &   [started pid 22428]
listen.py pid 22428 holding tcp+udp 127.0.0.1:8734

Ask lsof about the port — -i :8734 selects Internet sockets by port — and both handles come back:

$ lsof -i :8734
COMMAND   PID USER   FD   TYPE DEVICE SIZE/OFF NODE NAME
python3 22428 root    3u  IPv4 857701      0t0  TCP localhost:8734 (LISTEN)
python3 22428 root    4u  IPv4 857702      0t0  UDP localhost:8734 
[exit 0]

Sockets wear their file-table clothes: descriptor numbers (3 and 4), the both-ways access letter u, TYPE IPv4, and a NAME that is an address instead of a path — protocol host:port, with TCP's state in parentheses. Two refinements of the selection, both everyday forms:

$ lsof -iTCP:8734 -sTCP:LISTEN
COMMAND   PID USER   FD   TYPE DEVICE SIZE/OFF NODE NAME
python3 22428 root    3u  IPv4 857701      0t0  TCP localhost:8734 (LISTEN)
[exit 0]
$ lsof -iUDP:8734
COMMAND   PID USER   FD   TYPE DEVICE SIZE/OFF NODE NAME
python3 22428 root    4u  IPv4 857702      0t0  UDP localhost:8734 
[exit 0]

-iTCP narrows the protocol; -sTCP:LISTEN narrows by state — together they are the "who is listening on this port" one-liner. UDP, notice, has no state to show: UDP is connectionless, so its row just sits there with no (LISTEN) and no peer. A UDP row's existence is the information.

Small honesty note: lsof resolved the address to the name localhost and would resolve well-known ports to service names too. For scripts and for unambiguous reading, -n (no name resolution) and -P (ports as numbers) exist; the commands file keeps the -nP habit. Both forms are captured in the lab (session s13): under -nP the same rows render as TCP 127.0.0.1:8734 (LISTEN), and lsof -i @127.0.0.1 — select by the other end's address — returned exactly the planted listener and connection rows.

A connection, from both ends

The listener accepts connections and holds them without answering, so a client that connects stays connected. Plant one:

$ curl -s http://127.0.0.1:8734/ &   [started pid 22437; server never answers, so it hangs on]
$ lsof -i :8734
COMMAND   PID USER   FD   TYPE DEVICE SIZE/OFF NODE NAME
python3 22428 root    3u  IPv4 857701      0t0  TCP localhost:8734 (LISTEN)
python3 22428 root    4u  IPv4 857702      0t0  UDP localhost:8734 
python3 22428 root    5u  IPv4 857074      0t0  TCP localhost:8734->localhost:59048 (ESTABLISHED)
curl    22437 root    5u  IPv4 857074      0t0  TCP localhost:59048->localhost:8734 (ESTABLISHED)
[exit 0]

The connection appears twice, once per endpoint, and the NAMEs are mirror images: the server's accepted socket (a new descriptor, fd 5 — the listener on fd 3 is still listening) reads 8734->59048 (ESTABLISHED), and the client's reads 59048->8734 (ESTABLISHED). The client's port, 59048, was chosen by its kernel from the ephemeral range. When a real incident asks "which process is talking to that database," this pair of rows is the whole answer, and grepping lsof's output for the far address is a legitimate way to work.

The cross-check: ss

ss is the network-native neighbour (the Watching Your System neighbourhood, met properly in chapter 10). Put the same questions to it:

$ ss -ltnp
State  Recv-Q Send-Q Local Address:Port Peer Address:PortProcess                                 
LISTEN 0      0          127.0.0.1:6399      0.0.0.0:*    users:(("redis-server",pid=21798,fd=6))
LISTEN 0      0          127.0.0.1:8734      0.0.0.0:*    users:(("python3",pid=22428,fd=3))     
LISTEN 0      0              [::1]:6399            *:*    users:(("redis-server",pid=21798,fd=7))
[exit 0]
$ ss -lunp
State  Recv-Q Send-Q Local Address:Port Peer Address:PortProcess
UNCONN 0      0          127.0.0.1:8734      0.0.0.0:*    users:(("python3",pid=22428,fd=4))
[exit 0]

Same listener, same pid, same fd — 22428's fd 3 in both tables (and an unrelated redis-server on 6399 that happened to be running on the build machine; real listings always have neighbours). During the connection, ss -tnp shows the ESTAB rows with one detail lsof doesn't offer: Recv-Q 77 on the server's side — 77 bytes sitting unread in its receive queue, which is the client's HTTP request, unanswered by design.

The honest comparison, with the lab's numbers: on this small machine both tools are effectively instant (ss -tln 0.002 s, lsof -i 0.009 s, same session). The structural difference matters more than the stopwatch: ss asks the kernel's socket tables directly, while lsof assembles its answer from every process's descriptor table — so ss stays the sharper tool for pure socket questions (states, queues, timers), and lsof is the one that also answers everything else a process holds, including the sockets' owners in the same breath as their files. And one lsof-only sight: chapter 4's deleted-but-open file is invisible to ss by construction — it isn't a socket.

Proving it ended

Kill the client, kill the listener, ask both tools again:

[listener stopped]
$ lsof -i :8734
[exit 1]
$ ss -ltnp
State  Recv-Q Send-Q Local Address:Port Peer Address:PortProcess                                 
LISTEN 0      0          127.0.0.1:6399      0.0.0.0:*    users:(("redis-server",pid=21798,fd=6))
LISTEN 0      0              [::1]:6399            *:*    users:(("redis-server",pid=21798,fd=7))
[exit 0]

lsof prints nothing and exits 1 — no process holds port 8734 in any state — and ss's listener list is back to the unrelated redis. "The port is free" is a claim the guide just checked two ways, which is the standard every chapter is held to.