Skip to content

Accepting a dead socket kills IPC listeners in macOS (Cap'n proto bug) #319

Description

@xyzconstant

In macOS, when a queued client disconnects before being accepted, ListenConnections's accept path throws:

mp/proxy.cpp:48: error: Uncaught exception in daemonized task.; exception = kj/async-io-unix.c++:1365: failed: setsocketopt(IPPROTO_TCP, TCP_NODELAY): Invalid argument

This issue was first seen while working on adding coverage for ListenConnections in #310. While reviewing bitcoin/bitcoin#35037, I came across it again while experimenting with socat. It's trivial to hit because of the new -ipcbind settings introduced there.

To reproduce it, here are the steps (macOS):

  1. The ipc: support per-address max-connections options on -ipcbind bitcoin/bitcoin#35037 branch is needed since it has a max-connections cli setting available, so pull the changes and compile bitcoin-node.

  2. Start bitcoin-node in regtest and cap the connection limit at 1

    ./build/bin/bitcoin-node -regtest -ipcbind=unix:/tmp/a.sock,max-connections=1 
    
  3. In another terminal (let's call it terminal A), connect to the listening address using socat.

    socat - UNIX-CONNECT:/tmp/a.sock
    

    The node should log the accepted connection:

    2026-07-23T18:52:00Z ipc: {bitcoin-node-90447/b-capnp-loop-13449344} IPC server: socket connected.
    

    Because the connection slots are limited to only one, a single connection is enough to saturate it and start queuing new ones.

  4. Open another terminal (Terminal B), try to connect to the listener again using the command from step 3. Since the listener is already saturated, the connection shouldn't be accepted, so no new "socket connected" log line should appear.

  5. At this point, the second connection is queued, so close it in Terminal B.

  6. Next, close the accepted connection in Terminal A so the listener attempts to accept the already-dead socket that is next in the queue, triggering the bug.

     2026-07-23T19:21:31Z ipc: {bitcoin-node-1724/b-capnp-loop-13498216} IPC server: socket disconnected.
     mp/proxy.cpp:48: error: Uncaught exception in daemonized task.; exception = kj/async-io-unix.c++:1365: failed: setsocketopt(IPPROTO_TCP, TCP_NODELAY): Invalid argument
     stack: 105b5b327 105b5abbb 1047447b7 1047464bb 1047468e3
     2026-07-23T19:21:31Z [error] ipc: {bitcoin-node-1724/b-capnp-loop-13498216} Uncaught exception in daemonized task.
    

    Now the listener has stopped accepting connections

    $ socat - UNIX-CONNECT:/tmp/a.sock
    2026/07/23 16:26:36 socat[3651] E connect(, LEN=13 AF=1 "/tmp/a.sock", 13): Connection refused
    

This is a Cap'n Proto bug, @ViniciusCestarii found the root cause (see #310 (review)): After accept(), kj always calls setsockopt(TCP_NODELAY) on the connection socket. For Unix sockets, kj handles the usual "not supported" error codes. On macOS, EINVAL is returned when the client has already disconnected, but kj only tolerates that code when built for FreeBSD. The unexpected error then goes uncaught in the accept loop (escapes mp::ListenConnections), causing the listener to stop accepting new connections.

It's fixed on capnproto's v2 branch (capnproto/capnproto@7df5bd078), but no release includes it. I proposed another solution at the libmultiprocess layer, but it unconditionally (and dangerously) continued the accept loop, ignoring errors that may deserve attention.

Until it's released (or backported), a temporary workaround may be to add a patch to Bitcoin Core's depends, as suggested by @ryanofsky in #310 (review). I've already opened a PR to Bitcoin Core: bitcoin/bitcoin#35796.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions