Skip to content

gh-130141: Clean up _SelectorTransport in __del__ - #158152

Open
v0ropaev wants to merge 1 commit into
python:mainfrom
v0ropaev:gh-130141-selector-transport-del
Open

v0ropaev wants to merge 1 commit into
python:mainfrom
v0ropaev:gh-130141-selector-transport-del

Conversation

@v0ropaev

@v0ropaev v0ropaev commented Sep 25, 2026 •

Copy link
Copy Markdown

Closes #130141. This is @lunixbochs's #130142 with the test @gvanrossum asked for; happy to close this and hand it back if he would rather finish his own.

A resurrected _SelectorTransport leaves the fd registered with the loop. __del__ closes the socket but does not unregister, and it leaves _sock_fd pointing at the closed number, so the next close() on the resurrected transport removes a descriptor the kernel has since handed to somebody else. On main:

registered before __del__: True
_sock: <socket.socket [closed] fd=-1, ...> | _sock_fd: 6 | _loop: alive
registered after __del__: True
new unrelated fd: 6, same as the transport's: True

With this change the fd leaves the selector and the cached number is invalidated:

registered after __del__: False
_sock: None | _sock_fd: -1 | _loop: None

The code follows the plan you laid out in the issue — warn if the protocol is connected and clear the flag, clear the buffer and drop the writer, set _closing and drop the reader, close the socket with _sock_fd = -1 first, unlink protocol and loop, detach from the server — and keeps the ordering of _force_close, as you preferred there. _call_connection_lost also sets _sock_fd = -1 now, so "the cached fd is valid only while the socket is open" holds on every path that closes it.

The warning is issued first rather than last. repr(self) reads _sock, _sock_fd and _loop, so issuing it after the cleanup turns the message into unclosed transport <_SelectorSocketTransport closed fd=-1> — it says "unclosed ... closed" and drops the fd, which is the one thing that makes the warning actionable. Measured both ways.

On the test, which was the merge condition. Five of them, and four fail against unmodified Lib/asyncio. Four use the mock loop already in test_selector_events.py and call __del__() directly, the way lunixbochs's repro did; the fifth builds a real SelectorEventLoop over a real socketpair and asserts the fd is gone from loop._selector.get_map(), so the suite is not just a mirror of the code — which was his stated reason for not writing one. Nothing waits on a race, on GC timing, or on which fd the kernel hands back.

@kumaraditya303 asked on #130142 why _loop is set to None and never got an answer. Because _call_connection_lost does it and __del__ is meant to leave the same state: once the fd is gone, a resurrected transport should not be holding the loop. It is safe because the two entry points that survive resurrection return before touching _loop — close() on _closing and abort() → _force_close() on _conn_lost, both of which __del__ now sets. I checked that rather than assuming: removing either guard makes the tests error with AttributeError: 'NoneType' object has no attribute '_remove_reader' and ... 'call_soon'.

Worth being explicit that this changes what a resurrected transport looks like: is_closing() flips to True, close() and abort() become no-ops, and write() drops data through the _conn_lost branch instead of raising OSError(EBADF). All of that is the point, but it is a semantic change in a finalizer, so main only and no backport.

./python.exe -m test test_asyncio is 2,932 passing, up from 2,927.

__del__ closed the socket but stopped there: the file descriptor stayed
registered with the event loop and self._sock_fd kept pointing at a
number the OS is now free to hand out to anything else.  So the loop
could go on polling a descriptor the transport no longer owned, and a
transport resurrected during garbage collection could later call
close() and unregister an unrelated descriptor from the selector,
hanging whoever was waiting on it.

Leave the transport in the state that _force_close() and
_call_connection_lost() would instead: clear the buffer and drop the
writer, mark the transport as closing and drop the reader, invalidate
_sock_fd before closing the socket, then unlink the protocol, the loop
and the server.  All of it stays under "if self._sock is not None", so
a finalizer running on a transport that has already lost its socket
still does nothing.  That matters for the server link especially:
_call_connection_lost() calls server._detach() before it clears
self._server, so a _detach() that raises leaves _server set on a
transport whose socket is gone, and an unguarded finalizer would then
detach a second time.

The ResourceWarning is issued first, while repr() can still report the
fd and the selector state.  _call_connection_lost() also sets _sock_fd
to -1 now, so the "_sock_fd is valid only while _sock is open"
invariant holds on every path that closes the socket; as a result
repr() of a closed transport reports fd=-1.

This is the patch Guido van Rossum worked out in the issue thread,
rebased, with the cleanup kept inside the socket guard and extended
with the _buffer_size reset that current main needs, plus the unit
tests the earlier attempt was missing.
@picnixz

picnixz commented Sep 25, 2026

Copy link
Copy Markdown
Member

You open lots of PRs and those are mainly automated. Please slow down because the backlog will simply grow. Keep in mind that such burdens can be regarded as spam because maintainers just do not have enough time. I would advise against tackling very old issues especially if they are not revived first.

@v0ropaev

Copy link
Copy Markdown
Author

Taken, and you're right — I got the balance wrong. Sorry for the pile.

I've closed five of mine just now, the ones on issues nobody had touched in years: #158154 (gh-66889, last comment 2014), #158153 (gh-79857, 2019), #158138 (gh-77391, 2018), #158131 (gh-83888, 2020) and #158135 (gh-67486, 2019). Those are exactly the "old and not revived" case you described and they should not have gone out without someone asking for them first. Happy to reopen any of them later if an issue actually comes back to life.

That leaves the ones where something recent was already happening: two you and Jelle have already approved, gh-130141 where Guido left a review and a stated merge condition, gh-88661 where Serhiy gave the direction in May, and gh-141749 where a PR was closed last week. I'll leave those as they are rather than churn them further, and I'm not opening more.

On the automated part: the work is mine and I can defend any line of it, but I take the point that the volume made it read otherwise. I'll keep to one at a time and only where a maintainer has recently said something.

@picnixz

picnixz commented Sep 25, 2026

Copy link
Copy Markdown
Member

Note that simply letting an agent reply on your behalf is also considered as a spam. We value human interactions and contributions solely made through an agent are agent contributions and, I personally do not consider them as associated to the one who made the prompts.

There was no need to close the PRs though. Most of them may be correct. i just want you to keep in mind that 1) a human MUST be here. We do not want proxies. 2) avoid having too many open PRs at the same time for the sake of reviewers, especially if those PRs have a large probability of not being merged in the few next days.

@v0ropaev

v0ropaev commented Sep 25, 2026 •

Copy link
Copy Markdown
Author

Sorry, that was sloppy of me. Reopened them.

I'll go back over mine myself before bothering anyone, and won't open new ones here for now.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

resurrected asyncio _SelectorTransport unregisters fds it doesn't own

2 participants