Almost every service on your machine is listening on a Unix socket right now. Your package manager talks to dbus over one, your logs go into /dev/log over one, docker and podman and systemctl are all clients of one, and your database is probably accepting local connections over one in preference to TCP. They are the most-used and least-explained object on a Linux system.

Networking, explained covers what a socket is in general terms, and The Life of a Packet follows one out through the network stack. This page is about the family that never goes near the network stack — and the thing worth knowing before you start is that a Unix socket is not a TCP connection with the network removed. Almost everything the packet article teaches about sockets is false for this family. The queue columns it tells you to read can be fabricated. The receive buffer it tells you to tune does nothing. The full listen queue it says will refuse a connection will block it instead.

What a Unix socket has instead is a name in a directory with an owner and a mode, a peer identity the kernel fills in for you, and the ability to carry an open file descriptor from one process to another. Each of those three is wrong in a specific and reproducible way that every diagnostic tool on the machine will hide from you. That is what this page is for.

Three things are deliberately left to other pages. The Life of a File Descriptor owns the mechanism of descriptor passing and the three levels — descriptor, open file description, inode — that make it work; this page picks up where that leaves off, at the receiving end, where the bugs are. Containers, All the Way Down owns namespaces; this page only says what each one does to a socket. And The Life of a Packet owns everything from the route lookup outward, none of which happens here.

The seven stages of a Unix socket. Every section below is named after one of them.

  1. Createsocket() makes an object with no name at all.
  2. Namebind() does two separate things, and this is where the trouble starts.
  3. Listen — a queue that blocks rather than refuses.
  4. Be handed one — most processes did not create the socket they use.
  5. Connect and accept — one permission bit, and a peer identity that never admits it does not know.
  6. Send — message boundaries, the sender’s buffer, and a queue ten items deep.
  7. Close — and what becomes of the name.

The diagnostic hinge: two things carry the name of a Unix socket, and neither of them checks the other.

bind() creates an entry in a directory — a name, with an owner and a mode, that anything able to write that directory can delete or replace. It also writes a copy of that string into the socket itself, where it stays forever, unchecked, and where ss, lsof and /proc/net/unix read it back to you. Nothing keeps the two together after bind() returns. A socket can go on running under a name that no longer exists, and a name can come to point at a completely different socket, and every tool on the machine will show you both as healthy.

So do not read the name back off a tool. Try to connect, and read the error.

nc -U /run/foo.sock </dev/null ; echo $?
socat - UNIX-CONNECT:/run/foo.sock          # names the errno more precisely
What it saysWhat it meansStages
ENOENTThere is no name. The socket may still be running.2–3
ECONNREFUSEDThe name exists and nothing is behind it — a file left behind by something that stopped, or, for an abstract socket, the wrong network namespace.2, 7
EACCESThe name exists and something is behind it. This is a permission question and nothing else.2
EAGAIN, or it hangsThe listener is there and is not accepting.3–4
It connectsThe binding is sound. Every remaining problem is yours.6–7

Those five errnos are the kernel’s documented semantics rather than local state, which is why they can be tabulated at all when most of what follows cannot be.

The exception, and it is a real one: a socket you did not name has nothing to test. A socketpair(), a descriptor that arrived over a socket, and a listener handed to you by systemd all have no path you can connect to. For those the answer is always stages 6–7 — and stage 4 is where you find out what you were actually given.

Stage 1: Create

socket(AF_UNIX, SOCK_STREAM, 0) returns a descriptor to an object with no name, no peer and no address. It is fully usable in that state: socketpair() hands you two of them already joined, and a great many programs never give a socket a name at all.

The object does have an inode, in a filesystem called sockfs that has no mount point and no directory and that you can never look at. This sounds like trivia and produces a first-class trap. fchmod() on a socket descriptor returns 0 and changes nothing anyone will ever consult. It writes to the sockfs inode. The mode that governs access is on the filesystem inode that bind() created, and only chmod() on the path reaches it:

fstat(fd)   : ino=2901     mode=140777 dev=9        # sockfs, invisible, never checked
stat(path)  : ino=950608   mode=140777 dev=65024    # the inode connect() checks

fchmod(fd, 0700) = 0 ; now:
  fstat(fd) mode=140700   stat(path) mode=140777    # the path did NOT change

This is the same shape as a finding in The Life of a File Descriptor, where fcntl(F_SETFL, O_CLOEXEC) also returns success and does nothing. A call that succeeds is not a call that did what you meant. The correct sequence for a socket you want restricted is umask or chmod on the path, or — better — the directory, which is covered in the next stage.

Stage 2: Name

There are four kinds of name, not three, and the fourth arrives by accident.

A pathname socket has an entry in a directory: /run/foo.sock. An abstract socket has a name in a per-network-namespace table and no filesystem presence at all; it is written @name by tooling and its address begins with a NUL byte. An unnamed socket has no name and is perfectly functional. And a socket autobinds — acquires an abstract name of five hex digits chosen by the kernel — either when you bind it with an address length of just the family field, or when SO_PASSCRED is set on an unbound socket and it then sends or connects:

fresh DGRAM socket                            -> UNNAMED
after setsockopt(SO_PASSCRED, 1)              -> UNNAMED
after sendto() with SO_PASSCRED on            -> ABSTRACT @6365c
a plain DGRAM socket after sendto()           -> UNNAMED

Note where the name appears: at the first send or connect, not at setsockopt. The manual’s wording — that autobind happens when the option “was specified” — reads as though the option itself binds it, which means an auditor grepping for stray abstract names sees them show up later than expected.

Two mechanical limits are worth knowing before anything else. Abstract names are not C strings — they carry a length and may contain embedded NULs, so a name of "\0my\0secret" prints as @my in most tooling. And sun_path is 108 bytes: bind a 199-character path and the call succeeds, having silently bound a 108-character one instead.

The write bit, and only the write bit

A pathname socket’s mode is checked when a client connects. Everybody knows this. What almost nobody has tested is which bit. It is write. Read and execute are irrelevant, and the results are counter-intuitive in both directions:

socket mode 0444 : connect() -> Permission denied
socket mode 0222 : connect() -> OK
socket mode 0644 : connect() -> Permission denied
socket mode 0666 : connect() -> OK
socket mode 0755 : connect() -> Permission denied
socket mode 0777 : connect() -> OK

Read those two ways round, because the corpus has it backwards in both. A socket created under the default umask of 022 comes out 0755, which nobody but its owner can connect to — “it’s 0755, that’s world-readable, so it’s open” is exactly wrong. And chmod 644 on a socket to make it safe breaks it, for the owner’s group and for everyone else, while looking entirely reasonable in a listing. So does 0640, and 0775.

The directory is the better control, and it needs only search permission — read is not required, so 0711 is a working confinement that also hides the name:

socket 0777, directory 0755          : connect() -> OK
socket 0777, directory 0700          : connect() -> Permission denied
socket 0777, directory 0711 (x, no r): connect() -> OK

One more thing the manual gets narrowly wrong: it says connecting to a stream socket requires write permission, and says nothing about datagrams. The same check applies to sendto() on a datagram socket — 0444 refuses, 0222 works. Given that /dev/log is a datagram socket, that omission is not hypothetical.

And if you are running a service under systemd, the mode is probably not yours anyway. SocketMode= defaults to 0666, so a socket created by an unmodified .socket unit is connectable by every local user.

The abstract namespace, and what it is scoped to

An abstract socket has no inode, so it has no owner and no mode. fchmod() on one returns 0 and writes a mode that will never be consulted by anything. Its access control is exactly one thing: the network namespace.

same netns, connect as root:     connect to @lt-abstract: OK
same netns, connect as uid 1000: connect to @lt-abstract: OK
a NEW network namespace:         connect to @lt-abstract: Connection refused
a new MOUNT namespace only:      connect to @lt-abstract: OK

Two things follow. The first is the errno: a wrong network namespace gives ECONNREFUSED, not ENOENT, so it presents as “the service is down” rather than as “I cannot see it from here”. A unit with PrivateNetwork=yes produces exactly this, and so does every container.

The second is the security history. Because the scope is the network namespace, a container run with --net=host shares the host’s abstract namespace, and any process in it can connect to anything named there as any uid. That is CVE-2020-15257, published 10 December 2020: containerd’s shim API was on an abstract socket whose only check was that the peer’s uid matched its own, and a host-networked container was root-equivalent through it. containerd moved the shim to pathname sockets in 1.3.9 and 1.4.3. The lesson stuck: if you are choosing, choose a pathname socket in a directory you control.

“No permissions at all” did stop being strictly true on 17 November 2024. Landlock ABI 6, in Linux 6.12, added LANDLOCK_SCOPE_ABSTRACT_UNIX_SOCKET, which restricts a sandboxed process to abstract sockets created within its own Landlock domain. Kernel documentation now describes a further access right for pathname sockets under ABI 9; the landlock(7) manual page, revised 21 April 2026, still stops at ABI 8, so the two sources disagree and the kernel documentation is ahead. That is worth knowing about and is not yet worth building on.

Stage 3: Listen

listen() takes a backlog, and the backlog behaves in the opposite way to the one you learned from TCP. A full AF_UNIX backlog blocks a connection; it does not refuse it. A non-blocking connect() gets EAGAIN; a blocking one simply waits until somebody calls accept():

backlog was 5, and nothing ever called accept():
  6 connects succeeded
  connect #7 failed: Resource temporarily unavailable   (non-blocking)
  BLOCKING connect on a full backlog: returned 0 after 2s, when a slot freed

The operational consequence is worth stating flatly: a service with a stuck accept loop does not present as clients being refused. It presents as clients hanging, so no retry logic fires, no circuit breaker trips, and nothing anywhere logs an error. And the column you would look at to see the pile-up — Recv-Q on the listening socket, which for TCP is the count of connections waiting to be accepted — reads 0. Stage 6 explains why.

Stage 4: Be handed one

Every tutorial written before about 2012 assumes the process using a socket is the process that created it. On a current machine, most are not. systemd creates the socket and passes it to the service. A supervisor creates it and forks. A privileged helper creates it and sends it to an unprivileged worker over another socket. This is the stage where a socket’s identity becomes uncertain, and almost every silent failure in this subject lives here.

Socket activation is the common case. systemd binds and listens, and the service inherits the listening descriptor through the environment:

LISTEN_PID=2107      # I am 2107
LISTEN_FDS=1
LISTEN_FDNAMES=mysock
  fd 3 -> socket:[4237]

Descriptors start at 3, which the systemd headers call SD_LISTEN_FDS_START. FileDescriptorName= defaults to the name of the socket unit including its suffix. And LISTEN_PID must equal getpid() — which is the mechanism behind a specific and very quiet failure: a daemon that forks into the background before calling sd_listen_fds() sees a mismatch, gets 0, and falls through to creating its own socket. Nothing errors. The unit reports success.

Which brings us to the single worst idiom in this subject, present in more or less every “write a Unix socket server” tutorial ever published:

# the universal tutorial idiom
unlink("/run/app.sock");
bind(fd, ...);
listen(fd, 5);

Under socket activation that code orphans the listener systemd handed you. LISTEN_FDS=1 arrives, is ignored, and the socket unit goes on reporting active (listening) on a socket nobody will ever accept from. The idiom is not wrong because it is old; it is wrong because it predates the mechanism entirely, and it fails by producing two working-looking listeners rather than an error. There is a worked diagnosis of exactly this at the end of the page.

A socket can also arrive by being sent to you over another socket — the descriptor-passing mechanism that The Life of a File Descriptor covers in full. It is the same situation from the receiver’s point of view: you hold a socket you did not create and cannot assume anything about.

What can you learn about a socket someone gave you? Less than you would like, which is why sd_listen_fds() and sd_is_socket_unix() exist at all. The descriptor does not announce its type, whether it is listening, or how many of them you got — ask with SO_TYPE, SO_ACCEPTCONN and getsockname() rather than assuming. From a shell:

sudo ls -l /proc/$(systemctl show -P MainPID app.service)/fd
systemctl show app.socket -p Listen -p SocketMode -p RemoveOnStop
systemctl show app.service -p RuntimeDirectory -p RuntimeDirectoryPreserve

Stage 5: Connect and accept

The connection itself is the least interesting part. The permission check from stage 2 happens here, on the socket’s own mode and on every directory in the path. What happens after the connection exists is the thing that makes Unix sockets worth having and is also the largest single source of security bugs in local services: the kernel will tell you who is on the other end.

A server reads SO_PEERCRED on the accepted socket and gets a pid, a uid and a gid, recorded at connect() time and impossible for the peer to forge. That is real, and no other local transport on the machine offers anything like it. It is also, as normally used, a mistake.

SO_PEERCRED has no error return. Every way it can fail to know the answer, it returns a number instead.

There is no errno meaning “I cannot tell you who this is”. On a socket that has never been connected it returns success and fills the structure with sentinel values that look like data:

SO_PEERCRED on a brand-new, unconnected STREAM socket: rc=0 pid=0 uid=-1 gid=-1

Across a PID namespace the pid comes back as 0 — not an error, and not -1, but a number that is a perfectly valid argument to kill(2) meaning “every process in my process group”. Across a user namespace that does not map the peer, the uid comes back as the overflow uid, 65534, and two different unmapped peers are indistinguishable:

server in a PID namespace, client is real pid 2282:
  SO_PEERCRED: pid=0 uid=0 gid=0

server in a user namespace with uid_map "0 1000 1", client is real uid 0:
  SO_PEERCRED: pid=2316 uid=65534 gid=65534

The values are translated into your namespaces, and when the translation cannot be performed it is not reported as an error — it is reported as a plausible number. A service that checks cred.uid == 0 for admin operations sees 65534 for actual root once it is packaged into a rootless container, and nothing in its configuration changed.

If you need an answer that can be wrong loudly, ask for SO_PEERPIDFD, added in Linux 6.5 on 27 August 2023. It returns a reference to the peer process rather than a number, so when the peer is gone it says so and cannot silently become a different process.

The namespace cases are the ones that surprise people, but they are not the commonest failure. This is:

--- (1) a client connecting DIRECTLY ---
  client pid is 2142
  backend sees SO_PEERCRED pid=2142 (python3) uid=0 gid=0

--- (2) the SAME client through systemd-socket-proxyd ---
  client pid is 2147
  backend sees SO_PEERCRED pid=2146 (systemd-socket-) uid=0 gid=0

Put anything in the path — systemd-socket-proxyd, socat, an nginx or Envoy upstream over a Unix socket, a service-mesh sidecar — and the credentials are the proxy’s. The kernel’s statement is entirely true. It is just the answer to a different question from “who is asking me to do this”.

Two more ways the number decays. The peer can exec after connecting, so the pid stays valid and the program behind it becomes something else. And the peer can exit and have its pid recycled while your connection is still open:

connection accepted; SO_PEERCRED pid=2165 uid=0
the client has exited and been reaped; the connection is still open
burning pids until 2165 is handed out again...
  after 3763 forks, pid 2165 was reissued to a brand new process
  SO_PEERCRED on the old connection still says pid=2165 uid=0

Two related things, briefly, because they are routinely confused with each other. SCM_CREDENTIALS is checked, not filled: the peer states its own pid, uid and gid in a message, and the kernel rejects a claim it is not entitled to make. An unprivileged process cannot lie. Root can — it can claim pid=1 uid=0 and the send succeeds. It is therefore strictly weaker than SO_PEERCRED and should never be treated as equivalent. This matters more than it sounds, because a datagram server has no accept() and therefore no SO_PEERCRED at all; per-message credentials are the only option there, and /dev/log is a datagram socket.

And one piece of good news that almost nobody uses: the client can read SO_PEERCRED too, and gets the server’s credentials as they were at listen(). A client can verify it is talking to a root-owned daemon rather than to an impostor socket somebody dropped into a writable directory. Given how much of this page is about names being unreliable, that is a check worth making.

Stage 6: Send

Three socket types, and the difference that matters is not the one usually drawn. SOCK_STREAM is a byte stream with no message boundaries. Both SOCK_DGRAM and SOCK_SEQPACKET preserve boundaries — the manual says so plainly and most tutorials still credit only the second — and both set MSG_TRUNC and discard the remainder on a short read:

wrote 3+5+1 bytes, then read once | 10-byte message read 4 at a time
  SOCK_STREAM      9 bytes: AAABBBBBC | got 4 (TRUNC=no),  next read 6
  SOCK_DGRAM       3 bytes: AAA       | got 4 (TRUNC=yes), next read -1
  SOCK_SEQPACKET   3 bytes: AAA       | got 4 (TRUNC=yes), next read -1

What is actually distinctive about SOCK_SEQPACKET is that it has a connection and therefore an end of file. That is also its one footgun, and it is not in any tutorial: a zero-length message is indistinguishable from EOF. A read returning 0 might be a message. The only way to tell is to read again — EAGAIN means the peer is alive and you were handed an empty message; a second 0 means it is gone. Almost nothing uses SOCK_SEQPACKET, which is the main reason this rarely bites.

Flow control is the sender’s, and the queue is ten

For a stream socket, the amount you can write before blocking is governed by the sender’s SO_SNDBUF. The receiver’s SO_RCVBUF does nothing at all. This is the exact inverse of TCP, and it is the reason a familiar tuning change has no effect:

sender SO_SNDBUF=212992  receiver SO_RCVBUF=212992
  wrote 180224 bytes before EAGAIN
receiver RCVBUF forced to 8192:
  sender still wrote 180224 bytes
sender SNDBUF forced to 8192:
  sender wrote 8128 bytes

For a datagram socket it is not a byte budget at all. The limit is a count of ten datagrams, regardless of their size, set by /proc/sys/net/unix/max_dgram_qlen. Eleven fit, because the check is a strict comparison. SO_RCVBUF is irrelevant:

payload 1024 B  rcvbuf=212992  -> 11 datagrams queued
payload   64 B  rcvbuf=212992  -> 11 datagrams queued
payload    1 B  rcvbuf=212992  -> 11 datagrams queued
payload 1024 B  rcvbuf=  8192  -> 11 datagrams queued

Unix datagram sockets do not drop messages; they make the sender stop. So a log collector that stalls for a moment does not lose lines, it stops the service that is writing them — no error anywhere, nothing in any log, just a process that has become intermittently slow. And when the operator finds the sysctl and raises it, nothing changes, because the value is latched into the socket at creation time:

receiver created BEFORE sysctl raised to 200:  11 datagrams queued
receiver created AFTER  sysctl raised to 200: 201 datagrams queued

Raise it, then restart the receiver — in that order. A connected pair, including anything from socketpair(), is memory-limited instead and does not have this problem. And unix(7) does not mention max_dgram_qlen anywhere, which is discussed below.

Receiving descriptors: the two-descriptor bug

Sending an open file descriptor to another process is the one thing a Unix socket can do that nothing else can, and The Life of a File Descriptor covers what actually crosses: not a number and not a copy of the file, but a reference to the open file description. The receiving side is where the bugs are, and the corpus has none of this.

sender: sent 3 descriptors in one SCM_RIGHTS message
receiver: budgeted for one, with CMSG_SPACE(sizeof(int))
receiver: open descriptors before recvmsg = 6
receiver: recvmsg returned 1, msg_flags=0x8  MSG_CTRUNC=YES
receiver: cmsg_len says 2 descriptor(s) actually arrived
receiver: open descriptors after recvmsg  = 8

Three descriptors were sent and two arrived. The third was not queued for a later read; the kernel destroyed it. Three facts in that output, each of which is a bug in ordinary-looking code.

CMSG_SPACE(sizeof(int)) has room for two descriptors, because of alignment. Code that budgets for one and reads one receives two and leaks the second — which is precisely why the bug never appears in a test that passes a single descriptor. The surplus beyond the buffer is closed by the kernel, so the sender’s sendmsg() succeeds and neither end learns that anything went missing. And the peer controls the count: send 200 descriptors to a receiver that budgets for one, and it leaks one per message, unbounded, from a peer that needed nothing more than permission to connect.

Correct receive code loops over CMSG_FIRSTHDR and CMSG_NXTHDR, derives the count from cmsg_len rather than from its own expectation, and checks msg_flags & MSG_CTRUNC. If you never check that flag you will never know this happened.

Since Linux 6.16, merged 23 May 2025, a receiver can also refuse descriptors outright with SO_PASSRIGHTS; the sender then fails with EPERM. It defaults to enabled for compatibility, so you must ask. The reason it exists is worth repeating as a rule of thumb: an untrusted peer can send you a descriptor pointing at a hung NFS mount, and you will block forever inside close().

ss -x is not ss -t. Its queue columns can be fabricated, with exit status 0.

The Life of a Packet teaches you to read Recv-Q and Send-Q, and that advice is sound for TCP. ss gets those numbers from a netlink interface; for Unix sockets the provider is a kernel module called unix_diag. When it is unavailable, ss silently falls back to parsing /proc/net/unix, which carries no queue lengths and no peer information. It does not warn. It prints 0 rather than “unknown”, * for every peer, SYN-SENT for pending connections, and exits 0:

180224 bytes are sitting unread on this ESTABLISHED socket,
and 10 more connections are unaccepted on the listener.

u_str SYN-SENT 0      0      /tmp/gate/q3.sock 0     * 0
  ... (ten of these)
u_str ESTAB    0      0      /tmp/gate/q3.sock 4133  * 0

$ ss -x >/dev/null 2>&1 ; echo $?
0

Distribution kernels build it as a module, so on an ordinary host it autoloads and the numbers are real. Inside a container without CAP_SYS_MODULE, if the host has not already loaded it, it cannot be loaded — so the place you are most likely to be debugging is the place the numbers are most likely to be invented. Check before you believe them:

lsmod | grep -w unix_diag

# the decisive test: queue something, then look for it
python3 -c '
import socket
a, b = socket.socketpair(); a.setblocking(False); n = 0
try:
  while True: n += a.send(b"x" * 4096)
except BlockingIOError: pass
print("queued", n, "bytes"); input("now run: ss -xp | grep python3")'

If ss reports Recv-Q 0 against that, its queue columns are fabricated on this machine and every capacity conclusion drawn from them is invented.

A word about speed

The figure everybody quotes — Unix sockets are two to three times faster than loopback TCP — traces to a single Node.js benchmark from March 2014 that did not set TCP_NODELAY, and the reason usually given for it, that TCP has a checksum to compute, has never been true on loopback. On one 2-vCPU virtual machine running Linux 6.18, with TCP_NODELAY set and three pinned runs, ping-pong round trips came out within noise at 1 and 64 bytes and loopback TCP was 10–28% faster at 64 KB. That is one machine and it should not be generalised either; the point is that the ratio is not a property of the mechanism, and if you care you must measure your own case.

The reason to use a Unix socket is not speed. It is that it has a name with an owner and a mode, a peer the kernel will identify, and no address reachable from off the machine. TCP has none of those.

Stage 7: Close

Closing the last descriptor destroys the socket. It does not remove the name — and systemd, which created most of the .sock files on your machine, does not remove it either: RemoveOnStop= defaults to off. A left-behind socket file is therefore the normal state of affairs rather than a sign that something crashed.

The file is not inert. It blocks a fresh bind() while correctly reporting to a client that there is nothing behind it, and that contrast is the most useful diagnostic in the whole subject:

after close(), the socket file is still there: yes
  a fresh bind() to it: Address already in use     # EADDRINUSE, with nothing listening
  and connect() to it:  Connection refused         # this is how you tell stale from live

stat tells you a file exists. Only connect() tells you whether anything is behind it. EADDRINUSE does not mean something is listening, and killing whatever fuser points at is how an innocent process dies.

Deleting the file yourself is worse than the usual advice suggests. It does not close existing connections — those keep running — and it does not merely prevent new ones. It frees the name, and anything that can write that directory can then take it:

UNLINKING /tmp/gate/u.sock now
  the existing connection still reads and writes: fine
  a NEW connect() to the unlinked path: No such file or directory
  a SECOND listener bind()ing the same path: OK -- it now owns the name
  a new client connects; who accepts?
    old listener accept = -1 (EAGAIN)   new listener accept = 9 (got it)

Two LISTEN rows, one path, and nothing on the machine will tell you which one clients reach. That is the hinge stated as a failure: the name and the socket parted company, and every tool went on printing the name.

A worked diagnosis: the socket unit that is listening and unreachable

A service has been socket-activated for a year without anyone touching it. After a routine restart, clients start getting “no such file or directory”. systemctl status app.socket says active (listening). Restarting the socket unit fixes it for a while.

Every obvious observable says nothing is wrong:

$ ss -xl | grep app.sock
u_str LISTEN 0  0  /run/lt/app.sock 5769  * 0

$ lsof -U | grep app.sock
systemd-s 2406 root 3u unix 0x…c06f 0t0 5769 /run/lt/app.sock type=STREAM (LISTEN)

/proc/net/unix agrees with both. All three are wrong; two commands tell the truth:

$ ls -l /run/lt/app.sock
ls: cannot access '/run/lt/app.sock': No such file or directory

$ nc -U /run/lt/app.sock </dev/null ; echo $?
Ncat: No such file or directory.
1                                    # ENOENT -- the hinge sends you to stages 2-3

The cause is two documented defaults meeting each other. The .socket unit has ListenStream=/run/lt/app.sock. The .service unit has RuntimeDirectory=lt, and RuntimeDirectoryPreserve= defaults to no, so systemd deletes /run/lt — and the socket file inside it — every time the service stops. The socket unit is not watching, because RemoveOnStop= defaults to off and it was never asked to manage that file. So it stays active (listening) forever, holding a socket whose name no longer resolves.

And ss, lsof and /proc/net/unix all keep printing the path because the kernel stored that string at bind() and has never looked at it since. This is stage 2 producing a stage-4 failure: the socket unit cannot know, because nothing told it, and nothing will.

There is a nastier variant, which is what happens when the service also runs the universal tutorial idiom from stage 4 — unlink the stale socket, bind our own. Now the hinge’s connect branch says everything is fine, because something does answer:

daemon: systemd handed me 1 listening fd(s) starting at 3
daemon: unlinked the stale socket file
daemon: bound and listening on /tmp/gate/svc.sock myself (inode 950586)

$ ss -xl | grep svc.sock
u_str LISTEN 0  0  /tmp/gate/svc.sock 4511  * 0
u_str LISTEN 0  0  /tmp/gate/svc.sock 5691  * 0

One path, one process, two listening sockets, and systemd’s is the one that will never accept anything again — so the service is never re-activated and nothing logs a word about it. The fix is one line in the unit (RuntimeDirectoryPreserve=yes, or move the socket out of the service’s runtime directory) and one deletion in the daemon.

The moral: your tools do not resolve the name; they repeat it. Every one of them is quoting the socket, and the socket is quoting a string it was handed once and has never checked since.

What the manual does not say

unix(7) and socket(7) are the canonical pages, and the current revision of both is dated 8 February 2026 — revised this year. Neither mentions SO_PEERPIDFD, SO_PASSPIDFD, SCM_PIDFD or SO_PASSRIGHTS. Those are not obscure additions. They are the options that fix the two most dangerous properties of a Unix socket — a peer identity that goes stale, and descriptors you cannot refuse — and a reader consulting the manual in 2026 to solve exactly those problems is told that SO_PEERCRED and SCM_RIGHTS are what exists.

The subject has moved a great deal in three years, which is the argument for reading anything about it with a date in mind:

ChangeKernelDate
SO_PEERPIDFD, SO_PASSPIDFD, SCM_PIDFD6.527 Aug 2023
The AF_UNIX garbage collector was replaced outright6.1014 Jul 2024
Landlock can scope abstract sockets (ABI 6)6.1217 Nov 2024
SO_PASSRIGHTS — a receiver can refuse descriptors6.16merged 23 May 2025
user.* extended attributes on sockets, abstract ones included7.114 Jun 2026

That last one is the neatest current fact in the subject. An abstract socket, which famously has no filesystem presence and no permissions, can now carry metadata — systemd-journald wants it on /dev/log for protocol negotiation. It gained an attribute surface without gaining a permission surface.

Two more omissions worth naming. unix(7) never mentions max_dgram_qlen; what it says instead is that Unix datagram sockets are always reliable and do not reorder, which is true and, alone, actively misleading — it tells the reader there is nothing to worry about. And its paragraph on descriptor truncation describes what the kernel does as though the reader were the kernel: it never says that MSG_CTRUNC is set, that you should check it, or that the count must come from cmsg_len. A reader who follows that paragraph literally writes the vulnerable code.

Symptoms and which stage owns them

What you seeStageWhat to run
ss -xl shows the socket and clients get “no such file”2ls -l the path; the tools print the name from bind()
Socket unit is active (listening) and nothing can reach it2, 4systemctl show app.service -p RuntimeDirectoryPreserve
Permission denied connecting, and the mode “looks fine”2check the write bit; 0644 and 0755 refuse everyone but the owner
Connection refused and the file is definitely there2, 7a stale file; connect() is the only test, not stat
Clients hang instead of being refused3the accept loop is stuck; a full backlog blocks
Half the traffic reaches a process you thought you stopped7ss -xl | grep -F the path; count the rows
The service never gets socket-activated again4it unlinked and rebound; delete that code
Admin checks pass or fail wrongly after containerising5SO_PEERCRED: uid 65534 or pid 0; use SO_PEERPIDFD
Credentials are always the same pid, whoever connects5something is proxying; the kernel is answering about the proxy
A service is intermittently slow and drops nothing6a datagram queue of ten; raise the sysctl, then restart the receiver
Raising SO_RCVBUF changed nothing6stream flow control is the sender’s SO_SNDBUF
Descriptor count climbing on a server that receives them6CMSG_SPACE(sizeof(int)) fits two; check MSG_CTRUNC
ss -x shows nothing queued and the service is clearly stalledlsmod | grep -w unix_diag; the zeros may be invented

Advice that has expired

Commonly saidWhat is actually true
SO_PEERCRED cannot be forged, so you can authenticate on it”Unforgeable is not the same as meaningful. Behind a proxy it is the proxy’s; after exec it is a different program; after exit it may be a recycled pid; in a PID namespace it is 0; in a user namespace it may be 65534.
“The abstract namespace is safer — it is not on disk, so nothing can find it”It has no permissions at all. Its scope is the network namespace, which --net=host shares. That is CVE-2020-15257.
“systemd socket units are secure by default”SocketMode= defaults to 0666.
SCM_CREDENTIALS is as good as SO_PEERCREDIt is checked, not filled. Root can claim pid=1 uid=0.
“Unlink the stale socket and bind your own” — the universal idiomUnder socket activation it orphans systemd’s listener, silently.
“Raise SO_RCVBUF to stop a datagram socket backing up”The limit is ten datagrams by count, and SO_RCVBUF does not move it.
“A full backlog refuses connections”It blocks them.
“Unix sockets are 2–3× faster than loopback TCP”Traceable to one 2014 Node.js benchmark with no TCP_NODELAY. Not reproducible; use them for the name and the peer identity, not the speed.

How to tell whether a page about Unix sockets is worth reading

Every wrong thing above comes from one model, and it is worth naming because it generates the errors rather than being one of them: the corpus treats a Unix socket as a TCP connection with the network removed — the same object, reached by a shorter path. That model cannot see the properties AF_UNIX has that TCP does not (a name with an owner and a mode, a kernel-attested peer, descriptor passing, a socket that outlives its own name), and it misapplies the properties TCP has that AF_UNIX does not (receive-side flow control, a queue that refuses, a working Recv-Q, a checksum worth removing).

Which gives you a one-sentence test. Replace every occurrence of “Unix socket” on the page with “127.0.0.1”. If nothing on the page becomes false, the author never learned what a Unix socket is.

The silences are more diagnostic than the claims:

The page never mentions……which tells you
the write bitThe author has never watched a 0644 socket refuse a connection. They are describing sockets they read about.
SO_PEERCRED at allThe page is about a pipe. Peer identity is the only thing here that no other local transport offers.
the network namespace, while covering abstract socketsThe author learned them before 2020 and has not read the containerd advisory.
MSG_CTRUNC, while showing descriptor-receiving codeThe code has only ever been tested with exactly one descriptor, where the bug is invisible.
max_dgram_qlen, on a page about datagram sockets or /dev/logThe author has never had one back up. Ten is not a number you reach in a demo.
that ss -x‘s queue columns can be fabricatedThe author has never run their diagnostics inside a container.
that bind() records a stringThe author believes ss performs a lookup. Everything they say about diagnosis follows from that and is wrong.

One page has visibly fixed itself, and what it fixed is the interesting part. Matt Oswalt’s write-up from August 2025 gets the half almost nobody gets: it states that you need write permission to call connect(), covers the abstract namespace properly, and deliberately declines the speed claim, framing a Unix socket as a guarantee to stay local rather than as something fast. And it says nothing at all about SO_PEERCRED, descriptor passing or socket activation.

That is the shape of where the corpus has got to. Writers have absorbed the filesystem half of the subject and none of the identity half. They have learned that a Unix socket is a file with permissions. They have not learned that it is a channel with an attested peer and a means of moving references — which is the half where the security failures are, and the half where every kernel change since 2023 has landed.

What to remember

  • The name and the socket are two things, joined once at bind() and never checked against each other again. Connect to the path and read the errno; do not read the name back off a tool.
  • Only the write bit gates a connection. Control access with the directory, and remember that a systemd socket unit gives you 0666 unless you say otherwise.
  • Most processes are handed their socket rather than creating it. Never unlink and rebind a path a supervisor gave you.
  • SO_PEERCRED answers “who connected”, not “who is asking”, and it never admits to not knowing. Use SO_PEERPIDFD where the answer matters.
  • Datagrams queue ten deep by count, latched at creation; stream flow control is the sender’s buffer; and ss -x‘s queue columns can be invented. Verify before you tune.

Related reading