@@ -128,9 +128,6 @@ def test_proc_exited_no_invalid_state_error_on_exit_waiters(self):
128128 exit_waiter = self .loop .create_future ()
129129 transport ._exit_waiters .append (exit_waiter )
130130
131- # _connect_pipes hasn't completed, so _pipes_connected is False.
132- self .assertFalse (transport ._pipes_connected )
133-
134131 # Simulate process exit. _try_finish() will set the result on
135132 # exit_waiter because _pipes_connected is False, and then schedule
136133 # _call_connection_lost() because _pipes is empty (vacuously all
@@ -143,6 +140,32 @@ def test_proc_exited_no_invalid_state_error_on_exit_waiters(self):
143140
144141 transport .close ()
145142
143+ def test_wait_returns_on_exit_with_open_pipe (self ):
144+ # gh-119710: wait() must resolve when the process exits even if a
145+ # pipe is still open and never reaches EOF (e.g. inherited by a
146+ # grandchild). Otherwise _call_connection_lost() never runs and
147+ # _wait() would hang forever despite the returncode being known.
148+ transport , protocol = self .create_transport ()
149+
150+ # Pipes are fully connected, but fd 1 stays open (never disconnects).
151+ pipe = mock .Mock ()
152+ pipe .disconnected = False
153+ transport ._pipes [1 ] = pipe
154+
155+ # A waiter registered via _wait() before the process exits.
156+ exit_waiter = self .loop .create_future ()
157+ transport ._exit_waiters .append (exit_waiter )
158+
159+ # _process_exited() must resolve exit_waiter even though the pipe
160+ # never disconnects (so _call_connection_lost() never runs). Without
161+ # the fix, exit_waiter stays pending forever and this hangs.
162+ transport ._process_exited (7 )
163+ self .loop .run_until_complete (exit_waiter )
164+
165+ self .assertEqual (exit_waiter .result (), 7 )
166+
167+ transport .close ()
168+
146169
147170class SubprocessMixin :
148171
@@ -436,6 +459,47 @@ async def len_message(message):
436459 self .assertEqual (output .rstrip (), b'3' )
437460 self .assertEqual (exitcode , 0 )
438461
462+ def test_wait_even_if_pipe_is_open (self ):
463+ # gh-119710: Process.wait() must return once the process exits even
464+ # if its stdout pipe is inherited by a grandchild that keeps it open,
465+ # so the pipe never reaches EOF. Otherwise wait() hangs forever
466+ # despite the returncode being known.
467+
468+ async def run ():
469+ # Just setup a pipe to pass to the grandchild for reading to ensure it dies.
470+ # Inheritable is to allow it to be passed on windows
471+ r , w = os .pipe ()
472+ os .set_inheritable (r , True )
473+
474+ code = textwrap .dedent (f"""\
475+ import subprocess, sys
476+ subprocess.run([sys.executable, "-c", "import sys;sys.stdin.read()"])
477+ """ )
478+
479+ proc = await asyncio .create_subprocess_exec (
480+ sys .executable , "-c" , code ,
481+ # This will be inherited by granchild and should not prevent
482+ # *this* process from firing .wait().
483+ stdout = subprocess .PIPE ,
484+ stdin = r ,
485+ pass_fds = (r ,) if sys .platform != "win32" else (),
486+ close_fds = False if sys .platform == "win32" else True ,
487+ )
488+ os .close (r )
489+
490+ try :
491+ # Ensure we start waiting before the process is killed.
492+ wait_proc = asyncio .create_task (proc .wait ())
493+ await asyncio .sleep (0.1 )
494+ proc .kill ()
495+ await asyncio .wait_for (wait_proc , timeout = 2.0 )
496+ finally :
497+ os .close (w ) # Allows the grandchild to exit
498+ if proc .stdout is not None :
499+ await proc .stdout .read ()
500+
501+ self .loop .run_until_complete (run ())
502+
439503 def test_empty_input (self ):
440504
441505 async def empty_input ():
0 commit comments