Repository navigation
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This addresses the hazard #244 leaves open: a socket closed while it's suspended on a
SocketPoolis 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 withSocketError.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 todisconnect()for a socket another task may be using.I named it
disconnect()because an async overload ofclose()would be preferred in async contexts. It would then make every existingtry socket.close()there fail to compile withoutawait. I'm happy to rename it.Tests
The
SocketPoolTestsuse the platform's real pool, as #244's do:close_ResumesTheSocketsSuspensions: two reads suspended on a socket both throwSocketError.disconnectedwhen 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 throwsSocketError.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 whileacceptis suspended on it, and relies on cancelling the server task after the timeout. Closing throughconfig.pool.close(_:)would end the accept loop at once. That changes howrun()ends, though, so it's better proposed separately.🤖 Generated with Claude Code