Skip to content

SocketPool: close a socket after resuming its suspensions - #250

Open
lhoward wants to merge 2 commits into
swhitty:mainfrom
PADL:socket-close-resumes-suspensions
Open

lhoward wants to merge 2 commits into
swhitty:mainfrom
PADL:socket-close-resumes-suspensions

Conversation

@lhoward

@lhoward lhoward commented Oct 15, 2026

Copy link
Copy Markdown
Contributor

Stacked on #244. This branch is based on #244, which this follows up. Until #244 is merged, the diff here includes its commit (SocketPool: keep a descriptor's registration when a closed socket's is reused); after that, only this PR's own commit remains.

Summary

This addresses the hazard #244 leaves open: a socket closed while it's suspended on a SocketPool is never woken. AsyncSocket.close() closes the descriptor without telling the pool. The kernel then drops the descriptor from the kqueue or epoll set without reporting an event, so a task waiting to read, write or accept on that socket waits for good.

In practice this bites whenever one task closes a connection that another task is reading. For example, a writer finds the peer gone and closes the connection, and the reader never returns. We hit it in SwiftEmberPlus, and it applies to SwiftOCA's device endpoints too.

Changes

  • AsyncSocketPool.close(_ socket:) is a new requirement. It closes a socket after first resuming each of its suspensions with SocketError.disconnected. A default implementation just closes the socket, so existing pools still compile and behave as before.
  • SocketPool.close(_:) resumes the socket's suspensions on the actor, which removes their events from the queue while the descriptor is still open, and then closes it. Because all of this happens on the actor, no other socket can be given the descriptor while its suspensions are still recorded, so this path never leaves stale registrations behind.
  • AsyncSocket.disconnect() closes the socket through its pool. close() is unchanged, and its documentation now points to disconnect() for a socket another task may be using.

I named it disconnect() because an async overload of close() would be preferred in async contexts. It would then make every existing try socket.close() there fail to compile without await. I'm happy to rename it.

Tests

The SocketPoolTests use the platform's real pool, as #244's do:

  • close_ResumesTheSocketsSuspensions: two reads suspended on a socket both throw SocketError.disconnected when the pool closes it.
  • close_LeavesOtherSocketsSuspended: another socket's suspension is untouched, and is still woken when it becomes readable.

In AsyncSocketTests:

  • disconnect_WakesASuspendedRead: the suspended read throws SocketError.disconnected, and the peer sees the end of the stream.
  • disconnect_Closes_WhenThePoolDoesNotTrackSuspensions: with a pool that relies on the default implementation, the socket is still closed.

The full suite passes on macOS (Swift 6.3.3): 478 tests, three runs. The change touches only the generic SocketPool, not the platform queues, but I couldn't build or run it on Linux, so Linux CI is its first run there.

Not changed here

HTTPServer.stop() closes its listening socket while accept is suspended on it, and relies on cancelling the server task after the timeout. Closing through config.pool.close(_:) would end the accept loop at once. That changes how run() ends, though, so it's better proposed separately.

🤖 Generated with Claude Code

lhoward and others added 2 commits September 11, 2026 15:57
…s reused

The kQueue and ePoll queues record which events each descriptor is
registered for, and trust that record. When a socket is closed while it
is suspended, the kernel drops its registration, but the record keeps it:

- Removing its events afterwards fails, because the descriptor has
  already left the kqueue or epoll set. That failure was thrown before
  the record was updated, so the entry stayed.
- A socket later given the same descriptor was taken to be registered
  already. kQueue skipped the EV_ADD. ePoll either skipped the change or
  used EPOLL_CTL_MOD, which fails with ENOENT. Either way the new socket
  was never woken.

Update the record before removing events. When adding events, always
tell the kernel: kqueue's EV_ADD just updates a filter it already has.
For epoll, fall back from EPOLL_CTL_MOD to EPOLL_CTL_ADD on ENOENT and
from ADD to MOD on EEXIST. A failed change leaves the descriptor recorded
as unregistered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Fq7ie1LzJERrh9uRuZZWE
A socket closed while it is suspended on a SocketPool is never woken: the
kernel drops a closed descriptor from the queue without an event. The pool
now closes a socket for its owner, first resuming the socket's suspensions
with SocketError.disconnected and removing their events while the
descriptor is still open. AsyncSocket.disconnect() closes that way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant