Repository navigation
resurrected asyncio _SelectorTransport unregisters fds it doesn't own #130141
Description
Bug report
Bug description:
Short story:
- Socket is closed in
_SelectorTransport.__del__, so it doesn't own the fd anymore. - Anything else in the process can create a new file descriptor at this point.
- If the transport is gc-resurrected to call
close(), it can then remove the reader/writer fromloop._selectorhere or here for a fd it doesn't control anymore, causing access to that fd to hang.
Long story:
- Introduce a GC cycle, such that an
asyncio.selector_events._SelectorTransportand the object owning it are collected at the same time. - The transport
__del__will be called, closing the socket, freeing its file descriptor, but not performing any other cleanup. - The owning object, being a good citizen, resurrects the transport during gc to have
close()called on it properly. In the case of this issue, an asyncio task was created to close it later. (I definitely don't love seeingasyncio.create_taskin a__del__method, but I expect that's not the only way to trigger this) - At this point, anything in your asyncio runloop can open a file descriptor. In my case, it was
asyncio.create_subprocess_execcallingos.pidfd_create()inPidfdChildWatcher.add_child_handler. If you're lucky (or running a lot of tasks at once), the new fd will be the same as the resurrected socket's fd. - Now
transport.close()can run any time later, which will callloop._selector.remove_child_watcher(fd), despite not really owning the fd anymore. - Now the pidfd has been removed from
loop._selector, soawait proc.wait()will hang forever. Or whatever asyncio stream you were trying to use got removed from the selector and you won't be able to wait on it.
I definitely think the non-stdlib code in play here was unsound, but I don't think that should trigger such a cursed result in the stdlib, so I'd rather defend against this in asyncio.
I think this is the minimum patch to mitigate the issue, to prevent the remove_child_reader and remove_child_writer calls by breaking their pre-conditions. I'm not sure if it's worth trying to interact with the loop or selector from __del__ to clean up the fd. (Selecting on a missing fd is probably handled somewhere else anyway?)
--- a/Lib/asyncio/selector_events.py
+++ b/Lib/asyncio/selector_events.py
@@ -871,6 +871,8 @@ def close(self):
def __del__(self, _warn=warnings.warn):
if self._sock is not None:
_warn(f"unclosed transport {self!r}", ResourceWarning, source=self)
+ self._closing = True
+ self._buffer.clear()
self._sock.close()
if self._server is not None:
self._server._detach(self)Another consideration is that asyncio is using a cached self._sock_fd here without really knowing if the underlying socket was closed and fd was recycled or not. I think this isn't the first time the cached fd has caused a surprising behavior, see also #88968
CPython versions tested on:
3.12
Operating systems tested on:
Linux
Linked PRs
Metadata
Metadata
Assignees
Labels
Projects
- StatusShow more project fieldsTodo
Activity
pythongh-130141: mark _SelectorTransport as closing during del
kumaraditya303 commentedon Feb 15, 2025
This seems like a bigger problem especially wrt free-threading of reusing of sockets and file descriptors.
cc @colesbury @gvanrossum
gvanrossum commentedon Feb 15, 2025
I'm not sure how free-threading makes things worse here -- even without it, another thread might execute and open a new file whose fd equals the old fd of the closed socket.
It looks like
__del__here was written without considering the consequences of resurrection.I feel the need to think in terms of invariants.
For example,
self._sock_fdshould roughly behave as if it was a property returningself._sock.fileno(). Therefore, every place where we either callself._sock.close()or setself._sock = None, we should setself._sock_fdto some clearly invalid value -- maybe-1whenself._sockis closed (since that's whatfileno()returns for a closed socket -- a universally invalid fd) andNonewhenself._sock = Noneis executed (hopefully after the socket is closed :-). But I am also okay with always setting it toNone.An invariant that seems to be already maintained is to call
self._server.detach(self), except we should also setself._server = Noneas soon as that is done.I feel it's also important to remove the fd from the loop; otherwise the loop may be polling events for an unrelated fd (see first line). IIUC, I think it's fine to call
loop._remove_{reader,writer}, since they are not blocking calls (notasync!) -- though they might make system calls for certain selector types: I assume those don't block, since they "just" have to update a kernel data structure. In fact, this should probably be done before closing the socket!Finally I also believe we have a commitment to tell the protocol that the connection is lost. But since the code currently warns that the transport is unclosed, I believe that we can punt on that -- otherwise
__del__might as well just callself.close(). :-)All in all, I think this should do it:
This passes the existing asyncio tests (though it could use a test that specifically tests the resurrection use case).
lunixbochs commentedon Feb 15, 2025
I'm not sure if you intended to remove
self._sock.close()in that patch but it otherwise makes sense. I'm also in favor of setting sock_fd to -1 immediately anytime we close the socket.gvanrossum commentedon Feb 15, 2025
Good call; and I think I need to research how
self._bufferis actually used.I will go over the other places where the invariants might be violated, and make them all uniform. Then the only thing that
closedoes that__del__doesn't is schedule the call toself._call_connection_lost-- instead we have the warning and set_protocolto None and_protocol_connectedto False.gvanrossum commentedon Feb 16, 2025
(Notes for myself:)
self._sock = sock; self._sock_fileno = sock.fileno()(sockis an argument to__init__)self.protocol_connected = False; self.set_protocol(protocol)(ditto)self._server = server(optional argument)self._buffer = collections.deque()self._conn_lost = 0(set to 1 when_call_connection_lostis scheduled, after that counts how many times we've seenwritecalled since that call was scheduled -- if there are more than 5, we log a warning)self._closing = False(set byclose())self._paused = False(managed by{pause,resume}_reading)[EDIT: I rewrote this paragraph for greater clarity, and added the rest.]
There is an invariant that if
self._bufferis empty, no writer is configured, and when it is non-empty, a writer is configured. Butclose()apparently doesn't fully trust invariant this and removes the writer when the buffer is empty (which is harmless since the socket is still open and thus it can't be anyone else's fd). If the buffer isn't empty,close()assumes that the write callback does the rest of the cleanup. That callback is either_write_sendmsgor_write_send(both defined on_SelectorSocketTransport), which are identical where it comes to cleanup. Each calls_remove_writer, and then also (sinceclosehas setself._closing) calls (not schedules!)_call_connection_lost, which does most of the work forclose()and friends. Those callbacks may be called multiple times, until they manage to empty the buffer. They do the cleanup part only in that case.The intended invariant around
self._buffer, and_add_writer/_remove_writeris then:The invariants around the writer callback are:
Note that in the special case where
self._conn_lostis nonzero, it returns immediately, maintaining the invariant. In this case, the_call_connection_lostcallback is always scheduled and when it runs, will finish the closing of the transport.TO BE CONTINUED.
kumaraditya303 commentedon Feb 16, 2025
When running free-threading with tsan, it will report these as data races, I haven't seen in practise on gil builds but i think it can happen with gil too, just that it is much more likely to happen in free-threading. See #121544
gvanrossum commentedon Feb 16, 2025
So the tasks of
_call_connection_lostare:connection_lost, but only ifself._protocol_connectedis set. (byset_protocol). If this fails, it still does the rest of the cleanup (re-raising the exception in the end)._sock_fdto -1 too).It does all of these unconditionally (except for the server detach). I don't know how much it cares about the order
It is scheduled from
closeand_force_close, and is called directly from_write_sendmsgand_write_send(in the subclass_SelectorSocketTransport). It is also called directly from_sendto_readyin_SelectorDatagramTransport.It looks as if everywhere it's called or scheduled,
_remove_writeris called first, and before that we ensure that the buffer is empty -- either by clearing it (_force_close) or by checking that it's empty. If the buffer is not empty, we normally wait for it to become empty, which it will eventually.A plan is emerging...
Going through
__del__should be the same as going through_force_close, except we never call the protocol'sconnection_lost-- instead, we issue the warning (ifself._protocol_connectedis set).self._protocol_connected = False.self._closing = True, and remove the reader callback.self._sock = None, andself._sock_fd = -1(actually, the latter happens first).The bullets through "close the socket" are done conditionally (only if the socket is still there).
We could bicker about the order in which to do these things, but I propose to keep them in the same order as
_force_closeand_call_connection_lostdo them. There's one difference in order withclose(it setsself._closingand removes the reader before checking the buffer) and it probably doesn't matter, but I think I prefer aligning with_force_close. (Alternatively, if we decide thecloseorder is better, we should swap this in_force_close.)Other than that, cosmetic changes to
closeand_call_connection_lost.gvanrossum commentedon Feb 16, 2025
Then again, the minimal version would be:
We do still have to worry about being deleted during shutdown -- what if the loop is already dead? Or the server? (Could this happen if everything was part of a big cycle?)
gvanrossum commentedon Feb 18, 2025
@lunixbochs @kumaraditya303 Any thoughts? Anybody want to prepare an actual PR? Or even just write a test that reproduces the original issue? so we can appreciate it's actually being fixed?
pythongh-130141: clean up asyncio._SelectorTransport during __del__
lunixbochs commentedon Feb 19, 2025
couldn't get the GC to behave in a small example, so here's a repro that calls
__del__directly, works for me on Linux (wherePidfdChildWatcheris used)@gvanrossum's patch fixes this repro, so I've updated my existing PR with that patch #130142
gvanrossum commentedon Feb 19, 2025
Great! Do you feel up to the task of turning that into an actual unittest in your PR? We need a unittest before the PR can be merged.
lunixbochs commentedon Feb 19, 2025
I'm not sure how to unit test this exact repro correctly, as it relies on Linux happening to reuse a file descriptor and then the wait on the selector hanging, and the function call ordering here is fragile to changes in both the gc and asyncio.
The more direct test would be to manually check the side effects of del -> close, and I usually feel weird about making tests which are 1:1 with the code and mostly test "the code didn't change".
lunixbochs commentedon Mar 25, 2025
bumping this - I have no idea how to write a reasonable test for this
lunixbochs commentedon Apr 17, 2025
If a test is required, I still need advice on how to test for this in a non-fragile way. The above repro relies on implicit behavior of both asyncio and Linux. I don't think it actually tests the issue, just the current circumstances. I have absolutely no idea how to correctly test for this and I'm not hearing any other ideas. This is a cpython bug with a known fix that is causing issues for people today. Please advise.
gvanrossum commentedon Apr 18, 2025
I'm on vacation and I've not followed the whole thread, but the fact that the open() and other fd-creating syscalls on Unix-ish systems uses the lowest unused file descriptor is not a "implicit behavior". IIUC this behavior has been specified since at least UNIX v6 (50 years ago!). Until the addition of dup2() much later, the official way to e.g. equate stderr (fd 2) to stdout (fd 1) was close(2); dup(1). If this is what you worry about, please stop worrying!
cforce commentedon Jun 26, 2025
@lunixbochs Azure-Samples/azure-search-openai-demo#2586 same issue
lbliii commentedon Jul 9, 2026
We encountered a related
asynciotransport-finalizer failure in Pounce, an ASGI server targeting free-threaded Python 3.14. The failure appeared under real SIGTERM load on Python 3.14t on both Ubuntu and macOS:The sequence we observed was:
asyncio.Server.close()saw an empty transportWeakSet, called_wakeup(), and set_waiters = None._SelectorTransport.__del__()subsequently calledServer._detach()._detach()called_wakeup()a second time, which attempted to iterateNone.Our integration reproducer uses two workers with concurrent short, slow, streaming, keep-alive, and newly arriving connections during real SIGTERM. Retaining every handler task through
writer.wait_closed()was correct downstream ownership but did not eliminate this pre-callback/finalizer path. An explicitgc.collect()immediately beforeServer.close()also still reproduced the traceback under concurrent stress.Our downstream compatibility guard makes the per-server
_wakeup()idempotent once_waiters is None. The same high-concurrency profile that reproduced the failure before the guard passed 120/120 runs afterward on an exact CPython 3.14.6 free-threaded build. The fix also passed Pounce's Ubuntu and macOS 3.14t CI lanes.References:
test_sigterm_drains_under_mixed_loadBy inspection, #130142 addresses finalizer cleanup and stale fd ownership, but it still calls
server._detach(self)and leavesServer._wakeup()non-idempotent. So the Pounce failure may be an additional invariant exposed by the same late-finalizer family rather than proof that #130142 alone fixes our case.Would you prefer this double-wakeup path as a separate CPython issue/test, or is it useful to track it here alongside #130142?
Add axis 2: complete classification of all 73 open topic-asyncio issues
Export wave-2 asyncio patches (pythongh-130141 revived + tested, pyth…