11"""Plugin dev client implementation."""
22
33import asyncio
4+ import io
45import os
6+ import signal
57import subprocess
68import sys
79
810from contextlib import asynccontextmanager
9- from pathlib import Path
1011from functools import partial
11- from typing import AsyncGenerator , Iterable , TypeAlias
12+ from pathlib import Path
13+ from threading import Event as SyncEvent
14+ from typing import Any , AsyncGenerator , Iterable , TypeAlias
1215
1316from typing_extensions import (
1417 # Native in 3.11+
@@ -115,6 +118,7 @@ async def register_dev_plugin(self) -> AsyncGenerator[tuple[str, str], None]:
115118 async def _run_plugin_task (
116119 self , result_queue : asyncio .Queue [int ], debug : bool = False
117120 ) -> None :
121+ notify_subprocess_thread = SyncEvent ()
118122 async with self .register_dev_plugin () as (client_id , client_key ):
119123 wait_for_subprocess = asyncio .ensure_future (
120124 asyncio .to_thread (
@@ -123,18 +127,20 @@ async def _run_plugin_task(
123127 self ._plugin_path ,
124128 client_id ,
125129 client_key ,
126- debug ,
130+ notify_subprocess_thread ,
131+ debug = debug ,
127132 )
128133 )
129134 )
130135 try :
131136 result = await wait_for_subprocess
132137 except asyncio .CancelledError :
133138 # Likely a Ctrl-C press, which is the expected termination process
139+ notify_subprocess_thread .set ()
134140 result_queue .put_nowait (0 )
135141 raise
136142 # Subprocess terminated, pass along its return code in the parent process
137- await result_queue .put (result . returncode )
143+ await result_queue .put (result )
138144
139145 async def run_plugin (
140146 self , * , allow_local_imports : bool = True , debug : bool = False
@@ -149,24 +155,82 @@ async def run_plugin(
149155 return await result_queue .get ()
150156
151157
158+ def _get_creation_flags () -> int :
159+ if sys .platform == "win32" :
160+ return subprocess .CREATE_NEW_PROCESS_GROUP
161+ return 0
162+
163+
164+ def _start_child_process (
165+ command : list [str ], * , text : bool | None = True , ** kwds : Any
166+ ) -> subprocess .Popen [str ]:
167+ creationflags = kwds .pop ("creationflags" , 0 )
168+ creationflags |= _get_creation_flags ()
169+ return subprocess .Popen (command , text = text , creationflags = creationflags , ** kwds )
170+
171+
172+ def _get_interrupt_signal () -> signal .Signals :
173+ if sys .platform == "win32" :
174+ return signal .CTRL_C_EVENT
175+ return signal .SIGINT
176+
177+
178+ _PLUGIN_INTERRUPT_SIGNAL = _get_interrupt_signal ()
179+ _PLUGIN_STATUS_POLL_INTERVAL = 1
180+ _PLUGIN_STOP_TIMEOUT = 2
181+
182+
183+ def _interrupt_child_process (process : subprocess .Popen [Any ], timeout : float ) -> int :
184+ process .send_signal (_PLUGIN_INTERRUPT_SIGNAL )
185+ try :
186+ return process .wait (timeout )
187+ except TimeoutError :
188+ process .kill ()
189+ raise
190+
191+
152192# TODO: support the same source code change monitoring features as `lms dev`
153193def _run_plugin_in_child_process (
154- plugin_path : Path , client_id : str , client_key : str , debug : bool = False
155- ) -> subprocess .CompletedProcess [str ]:
194+ plugin_path : Path ,
195+ client_id : str ,
196+ client_key : str ,
197+ abort_event : SyncEvent ,
198+ * ,
199+ debug : bool = False ,
200+ ) -> int :
156201 env = os .environ .copy ()
157202 env [ENV_CLIENT_ID ] = client_id
158203 env [ENV_CLIENT_KEY ] = client_key
159204 package_name = __spec__ .parent
160205 assert package_name is not None
161206 debug_option = ("--debug" ,) if debug else ()
207+ # If stdout is unbuffered, specify the same in the child process
208+ stdout = sys .__stdout__
209+ unbuffered_arg : tuple [str , ...]
210+ if stdout is None or not isinstance (stdout .buffer , io .BufferedWriter ):
211+ unbuffered_arg = ("-u" ,)
212+ else :
213+ unbuffered_arg = ()
214+
162215 command : list [str ] = [
163216 sys .executable ,
217+ * unbuffered_arg ,
164218 "-m" ,
165219 package_name ,
166220 * debug_option ,
167221 os .fspath (plugin_path ),
168222 ]
169- return subprocess .run (command , text = True , env = env )
223+ process = _start_child_process (command , env = env )
224+ while True :
225+ result = process .poll ()
226+ if result is not None :
227+ print ("Child process terminated unexpectedly" )
228+ break
229+ if abort_event .wait (_PLUGIN_STATUS_POLL_INTERVAL ):
230+ print ("Gracefully terminating child process..." )
231+ result = _interrupt_child_process (process , _PLUGIN_STOP_TIMEOUT )
232+ break
233+ return result
170234
171235
172236async def run_plugin_async (
0 commit comments