Skip to content

Commit 3ce66f2

Browse files
committed
gh-156333: rebuild the proactor self-pipe on EOF instead of busy-looping
When the self-pipe socketpair of a BaseProactorEventLoop reaches a clean EOF (e.g. the OS tears the loopback connection down across a power or session state change on Windows), _loop_self_reading re-armed recv() on the dead socket, which completed immediately and rescheduled the callback forever, pinning one core at 100% CPU with nothing logged. Detect the EOF via the empty recv result and rebuild the socketpair instead: allocate the replacement first (so a failure leaves the previous state untouched), re-register signal.set_wakeup_fd on the new socket before closing the old one (mirroring close()), then arm the next read on the new socket so cross-thread wakeups keep working.
1 parent 7b4364d commit 3ce66f2

4 files changed

Lines changed: 114 additions & 2 deletions

File tree

Lib/asyncio/proactor_events.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -783,10 +783,30 @@ def _make_self_pipe(self):
783783
self._csock.setblocking(False)
784784
self._internal_fds += 1
785785

786+
def _rebuild_self_pipe(self):
787+
# gh-156333: the self-pipe socketpair reached EOF -- the OS tore the
788+
# loopback connection down (e.g. across a power/session state change).
789+
# Re-arming a read on the dead socket would busy-loop the CPU, so
790+
# rebuild the pair instead. Build the replacement before touching the
791+
# old sockets so a failure leaves the previous state intact, and
792+
# re-register the wakeup fd before closing the old sockets, mirroring
793+
# close().
794+
ssock, csock = socket.socketpair()
795+
ssock.setblocking(False)
796+
csock.setblocking(False)
797+
if threading.current_thread() is threading.main_thread():
798+
# The wakeup fd was registered with the old socket.
799+
signal.set_wakeup_fd(csock.fileno())
800+
self._ssock.close()
801+
self._csock.close()
802+
self._ssock, self._csock = ssock, csock
803+
786804
def _loop_self_reading(self, f=None):
787805
try:
788-
if f is not None:
789-
f.result() # may raise
806+
if f is None:
807+
data = None
808+
else:
809+
data = f.result() # may raise
790810
if self._self_reading_future is not f:
791811
# When we scheduled this Future, we assigned it to
792812
# _self_reading_future. If it's not there now, something has
@@ -795,6 +815,8 @@ def _loop_self_reading(self, f=None):
795815
# that case stop here instead of continuing to schedule a new
796816
# iteration.
797817
return
818+
if f is not None and not data:
819+
self._rebuild_self_pipe()
798820
f = self._proactor.recv(self._ssock, 4096)
799821
except exceptions.CancelledError:
800822
# _close_self_pipe() has been called, stop waiting for data

Lib/test/test_asyncio/test_proactor_events.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -831,6 +831,34 @@ def test_loop_self_reading_exception(self):
831831
self.loop._loop_self_reading()
832832
self.assertTrue(self.loop.call_exception_handler.called)
833833

834+
def test_loop_self_reading_eof_rebuilds_self_pipe(self):
835+
# gh-156333: a clean EOF on the self-pipe (recv returns b'') must
836+
# rebuild the socketpair instead of re-arming a read that completes
837+
# immediately, which would busy-loop the CPU at 100%.
838+
fut = mock.Mock()
839+
fut.result.return_value = b''
840+
self.loop._self_reading_future = fut
841+
842+
new_ssock, new_csock = mock.Mock(), mock.Mock()
843+
with mock.patch('asyncio.proactor_events.socket.socketpair',
844+
return_value=(new_ssock, new_csock)):
845+
with mock.patch('signal.set_wakeup_fd') as m_wakeup_fd:
846+
self.loop._loop_self_reading(fut)
847+
848+
# the dead pipe is closed and replaced
849+
self.assertTrue(self.ssock.close.called)
850+
self.assertTrue(self.csock.close.called)
851+
self.assertIs(self.loop._ssock, new_ssock)
852+
self.assertIs(self.loop._csock, new_csock)
853+
self.assertEqual(self.loop._internal_fds, 1)
854+
# the wakeup fd is re-registered to the new socket before the old
855+
# sockets are closed
856+
self.assertEqual(m_wakeup_fd.call_args.args, (new_csock.fileno(),))
857+
# a new read is armed on the NEW socket, not the dead one
858+
self.proactor.recv.assert_called_with(new_ssock, 4096)
859+
self.assertIs(self.loop._self_reading_future,
860+
self.proactor.recv.return_value)
861+
834862
def test_write_to_self(self):
835863
self.loop._write_to_self()
836864
self.csock.send.assert_called_with(b'\0')

Lib/test/test_asyncio/test_windows_events.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,61 @@ def test_read_self_pipe_restart(self):
252252
self.close_loop(self.loop)
253253
self.assertFalse(self.loop.call_exception_handler.called)
254254

255+
def test_read_self_pipe_eof_rebuild(self):
256+
# Regression test for gh-156333: if the self-pipe socketpair
257+
# reaches a clean EOF (e.g. the OS tears down the loopback
258+
# connection across a power/session state change), re-arming
259+
# recv() on the dead socket completes immediately and reschedules
260+
# _loop_self_reading forever, pinning a CPU core. The loop must
261+
# instead rebuild the pipe.
262+
loop = self.loop
263+
calls = 0
264+
orig = loop._loop_self_reading
265+
def counting(f=None):
266+
nonlocal calls
267+
calls += 1
268+
return orig(f)
269+
loop._loop_self_reading = counting
270+
271+
old_ssock = loop._ssock
272+
273+
async def main():
274+
# Let the loop arm its self-pipe read first.
275+
await asyncio.sleep(0.1)
276+
# Graceful half-close: the read half sees a clean EOF, which
277+
# is what an OS teardown of the loopback connection looks like.
278+
loop._csock.shutdown(socket.SHUT_WR)
279+
# Wait (bounded) for the rebuild instead of assuming a fixed
280+
# delay, so a slow machine cannot fail the test spuriously.
281+
deadline = time.monotonic() + support.LOOPBACK_TIMEOUT
282+
while (loop._ssock is old_ssock
283+
and time.monotonic() < deadline):
284+
await asyncio.sleep(0.01)
285+
# Let any (buggy) busy-loop rescheduling surface.
286+
await asyncio.sleep(0.3)
287+
288+
loop.run_until_complete(main())
289+
290+
# Without the fix, _loop_self_reading is rescheduled hundreds of
291+
# thousands of times here; with the fix, the pipe is rebuilt and
292+
# the loop goes back to sleep.
293+
self.assertIsNot(loop._ssock, old_ssock)
294+
self.assertLess(calls, 100)
295+
296+
# The rebuilt pipe must still deliver cross-thread wakeups.
297+
woke = []
298+
async def main2():
299+
threading.Thread(
300+
target=lambda: loop.call_soon_threadsafe(woke.append, True)
301+
).start()
302+
for _ in range(200):
303+
if woke:
304+
break
305+
await asyncio.sleep(0.01)
306+
loop.run_until_complete(main2())
307+
self.assertEqual(woke, [True])
308+
self.close_loop(self.loop)
309+
255310
def test_address_argument_type_error(self):
256311
# Regression test for https://github.com/python/cpython/issues/98793
257312
proactor = self.loop._proactor
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
Fix :class:`asyncio.ProactorEventLoop` spinning at 100% CPU forever when the
2+
event loop's self-pipe socketpair reaches EOF. The connection can be torn
3+
down underneath the running process by a system power or session state
4+
change, but also by any in-path network filter driver re-applying its
5+
filters (a clean EOF, with no event logged anywhere). The loop now detects
6+
the EOF, rebuilds the self-pipe, and re-arms the read on the new socket
7+
instead of re-arming a read that completes immediately.

0 commit comments

Comments
 (0)