1+ # N.B.: We apply the monkeypatch before subprocess is imported because subprocess will 
2+ # hold strong references to os.waitpid. 
3+ from  __future__ import  annotations 
4+ 
5+ import  os 
6+ import  sys 
7+ import  textwrap 
8+ import  traceback 
9+ from  functools  import  wraps 
10+ 
11+ orig_waitpid  =  os .waitpid 
12+ orig_kill  =  os .kill 
13+ freed_pids  =  set [int ]()
14+ 
15+ 
16+ @wraps (orig_waitpid ) 
17+ def  waitpid (pid : int , options : int , / ) ->  tuple [int , int ]:
18+     print (f"--DBG: start  waitpid({ pid !r}  , { options !r}  ) @" )
19+     print (
20+         textwrap .indent (
21+             "" .join (traceback .extract_stack (sys ._getframe (1 ), limit = 2 ).format ()),
22+             prefix = " "  *  (- 2  +  len ("--DBG: " )),
23+         ),
24+         end = "" ,
25+     )
26+     try :
27+         res  =  orig_waitpid (pid , options )
28+     except  BaseException  as  exc :
29+         print (f"--DBG: finish waitpid({ pid !r}  , { options !r}  ) -> { exc !r}  " )
30+         raise 
31+     else :
32+         res_pid , status  =  res 
33+         if  res_pid  !=  0 :
34+             freed_pids .add (res_pid )
35+         print (f"--DBG: finish waitpid({ pid !r}  , { options !r}  ) = { res !r}  " )
36+         return  res 
37+ 
38+ 
39+ @wraps (orig_kill ) 
40+ def  kill (pid : int , sig : int , / ) ->  None :
41+     print (f"--DBG: kill({ pid }  , { sig }  )" )
42+     if  pid  in  freed_pids :
43+         raise  ValueError (
44+             "caller is trying to signal an already-freed PID! did a site call waitpid without telling the sites with references to that PID about it?" 
45+         )
46+     return  orig_kill (pid , sig )
47+ 
48+ 
49+ os .waitpid  =  waitpid 
50+ os .kill  =  kill 
51+ 
52+ assert  "subprocess"  not  in   sys .modules 
53+ 
54+ import  asyncio 
55+ import  subprocess 
56+ from  signal  import  Signals  as  Signal 
57+ from  typing  import  Literal 
58+ from  typing  import  assert_never 
59+ 
60+ 
61+ async  def  main () ->  None :
62+     _watcher_case : Literal ["_PidfdChildWatcher" , "_ThreadedChildWatcher" ]
63+     if  sys .version_info  >=  (3 , 14 ):
64+         _watcher  =  asyncio .get_running_loop ()._watcher   # type: ignore[attr-defined] 
65+         if  isinstance (_watcher , asyncio .unix_events ._PidfdChildWatcher ):  # type: ignore[attr-defined] 
66+             _watcher_case  =  "_PidfdChildWatcher" 
67+         elif  isinstance (_watcher , asyncio .unix_events ._ThreadedChildWatcher ):  # type: ignore[attr-defined] 
68+             _watcher_case  =  "_ThreadedChildWatcher" 
69+         else :
70+             raise  NotImplementedError ()
71+     else :
72+         _watcher  =  asyncio .get_child_watcher ()
73+         if  isinstance (_watcher , asyncio .PidfdChildWatcher ):
74+             _watcher_case  =  "_PidfdChildWatcher" 
75+         elif  isinstance (_watcher , asyncio .ThreadedChildWatcher ):
76+             _watcher_case  =  "_ThreadedChildWatcher" 
77+         else :
78+             raise  NotImplementedError ()
79+     print (f"{ _watcher_case  =  !r}  " )
80+ 
81+     process  =  await  asyncio .create_subprocess_exec (
82+         "python" ,
83+         "-c" ,
84+         "import time; time.sleep(1)" ,
85+         stdin = subprocess .DEVNULL ,
86+         stdout = subprocess .DEVNULL ,
87+         stderr = subprocess .DEVNULL ,
88+     )
89+     print (f"{ process .pid  =  !r}  " )
90+ 
91+     process .send_signal (Signal .SIGKILL )
92+ 
93+     # This snippet is contrived, in order to make this snippet hit the race condition 
94+     # consistently for reproduction & testing purposes. 
95+     if  _watcher_case  ==  "_PidfdChildWatcher" :
96+         os .waitid (os .P_PID , process .pid , os .WEXITED  |  os .WNOWAIT )
97+         # Or alternatively, time.sleep(0.1). 
98+ 
99+         # On the next loop cycle asyncio will select on the pidfd and append the reader 
100+         # callback: 
101+         await  asyncio .sleep (0 )
102+         # On the next loop cycle the reader callback will run, calling (a) waitpid 
103+         # (freeing the PID) and (b) call_soon_threadsafe(transport._process_exited): 
104+         await  asyncio .sleep (0 )
105+ 
106+         # The _PidfdChildWatcher has now freed the PID but hasn't yet told the 
107+         # asyncio.subprocess.Process or the subprocess.Popen about this 
108+         # (call_soon_threadsafe). 
109+     elif  _watcher_case  ==  "_ThreadedChildWatcher" :
110+         if  (thread  :=  _watcher ._threads .get (process .pid )) is  not   None :  # type: ignore[attr-defined] 
111+             thread .join ()
112+         # Or alternatively, time.sleep(0.1). 
113+ 
114+         # The _ThreadedChildWatcher has now freed the PID but hasn't yet told the 
115+         # asyncio.subprocess.Process or the subprocess.Popen about this 
116+         # (call_soon_threadsafe). 
117+     else :
118+         assert_never (_watcher_case )
119+ 
120+     # The watcher has now freed the PID but hasn't yet told the 
121+     # asyncio.subprocess.Process or the subprocess.Popen that the PID they hold a 
122+     # reference to has been freed externally! 
123+     # 
124+     # I think these two things need to happen atomically. 
125+ 
126+     try :
127+         process .send_signal (Signal .SIGKILL )
128+     except  ProcessLookupError :
129+         pass 
130+ 
131+ 
132+ # Pretend we don't have pidfd support 
133+ # if sys.version_info >= (3, 14): 
134+ #     asyncio.unix_events.can_use_pidfd = lambda: False  # type: ignore[attr-defined] 
135+ # else: 
136+ #     asyncio.set_child_watcher(asyncio.ThreadedChildWatcher()) 
137+ 
138+ asyncio .run (main ())
0 commit comments