Skip to content

Commit a501fc1

Browse files
committed
wip
1 parent 0c29f83 commit a501fc1

File tree

2 files changed

+57
-2
lines changed

2 files changed

+57
-2
lines changed

Lib/asyncio/base_subprocess.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,7 @@ def _try_finish(self):
265265
# to avoid hanging forever in self._wait as otherwise _exit_waiters
266266
# would never be woken up, we wake them up here.
267267
for waiter in self._exit_waiters:
268-
if not waiter.cancelled():
268+
if not waiter.done():
269269
waiter.set_result(self._returncode)
270270
if all(p is not None and p.disconnected
271271
for p in self._pipes.values()):
@@ -278,7 +278,7 @@ def _call_connection_lost(self, exc):
278278
finally:
279279
# wake up futures waiting for wait()
280280
for waiter in self._exit_waiters:
281-
if not waiter.cancelled():
281+
if not waiter.done():
282282
waiter.set_result(self._returncode)
283283
self._exit_waiters = None
284284
self._loop = None

Lib/test/test_asyncio/test_subprocess.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,31 @@ def test_subprocess_repr(self):
111111
)
112112
transport.close()
113113

114+
def test_proc_exited_no_invalid_state_error_on_exit_waiters(self):
115+
# gh-145541: when _connect_pipes hasn't completed (so
116+
# _pipes_connected is False) and the process exits, _try_finish()
117+
# sets the result on exit waiters. Then _call_connection_lost() must
118+
# not call set_result() again on the same waiters.
119+
waiter = self.loop.create_future()
120+
transport, protocol = self.create_transport(waiter)
121+
122+
# Simulate a waiter registered via _wait() before the process exits.
123+
exit_waiter = self.loop.create_future()
124+
transport._exit_waiters.append(exit_waiter)
125+
126+
# _connect_pipes hasn't completed, so _pipes_connected is False.
127+
self.assertFalse(transport._pipes_connected)
128+
129+
# Simulate process exit. _try_finish() will set the result on
130+
# exit_waiter because _pipes_connected is False, and then schedule
131+
# _call_connection_lost() because _pipes is empty (vacuously all
132+
# disconnected). _call_connection_lost() must skip exit_waiter
133+
# because it's already done.
134+
transport._process_exited(6)
135+
self.loop.run_until_complete(waiter)
136+
137+
self.assertEqual(exit_waiter.result(), 6)
138+
114139

115140
class SubprocessMixin:
116141

@@ -918,6 +943,36 @@ async def main():
918943
asyncio.run(main())
919944
gc_collect()
920945

946+
@warnings_helper.ignore_warnings(category=ResourceWarning)
947+
def test_subprocess_pipe_cancelled_no_invalid_state_error(self):
948+
# gh-145541: when _connect_pipes is cancelled and the process
949+
# subsequently exits, _call_connection_lost() must not raise
950+
# InvalidStateError by calling set_result() on exit waiters that
951+
# were already resolved by _try_finish().
952+
async def main():
953+
loop = asyncio.get_running_loop()
954+
loop.connect_read_pipe = mock.AsyncMock(
955+
side_effect=asyncio.CancelledError,
956+
)
957+
958+
proc = None
959+
with self.assertRaises(asyncio.CancelledError):
960+
proc = await asyncio.create_subprocess_exec(
961+
*PROGRAM_BLOCKED,
962+
stdout=asyncio.subprocess.PIPE,
963+
)
964+
965+
if proc is not None:
966+
proc.kill()
967+
# wait() adds an exit waiter. Before the fix,
968+
# _call_connection_lost() would call set_result() on it
969+
# after _try_finish() already did, raising
970+
# InvalidStateError.
971+
await proc.wait()
972+
973+
asyncio.run(main())
974+
gc_collect()
975+
921976
if sys.platform != 'win32':
922977
# Unix
923978
class SubprocessWatcherMixin(SubprocessMixin):

0 commit comments

Comments
 (0)