33import asyncio
44import io
55import os
6+ import signal
67import subprocess
78import sys
89
910from contextlib import asynccontextmanager
10- from pathlib import Path
1111from functools import partial
12- 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
1315
1416from typing_extensions import (
1517 # Native in 3.11+
@@ -116,6 +118,7 @@ async def register_dev_plugin(self) -> AsyncGenerator[tuple[str, str], None]:
116118 async def _run_plugin_task (
117119 self , result_queue : asyncio .Queue [int ], debug : bool = False
118120 ) -> None :
121+ notify_subprocess_thread = SyncEvent ()
119122 async with self .register_dev_plugin () as (client_id , client_key ):
120123 wait_for_subprocess = asyncio .ensure_future (
121124 asyncio .to_thread (
@@ -124,18 +127,20 @@ async def _run_plugin_task(
124127 self ._plugin_path ,
125128 client_id ,
126129 client_key ,
127- debug ,
130+ notify_subprocess_thread ,
131+ debug = debug ,
128132 )
129133 )
130134 )
131135 try :
132136 result = await wait_for_subprocess
133137 except asyncio .CancelledError :
134138 # Likely a Ctrl-C press, which is the expected termination process
139+ notify_subprocess_thread .set ()
135140 result_queue .put_nowait (0 )
136141 raise
137142 # Subprocess terminated, pass along its return code in the parent process
138- await result_queue .put (result . returncode )
143+ await result_queue .put (result )
139144
140145 async def run_plugin (
141146 self , * , allow_local_imports : bool = True , debug : bool = False
@@ -150,10 +155,49 @@ async def run_plugin(
150155 return await result_queue .get ()
151156
152157
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+
153192# TODO: support the same source code change monitoring features as `lms dev`
154193def _run_plugin_in_child_process (
155- plugin_path : Path , client_id : str , client_key : str , debug : bool = False
156- ) -> 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 :
157201 env = os .environ .copy ()
158202 env [ENV_CLIENT_ID ] = client_id
159203 env [ENV_CLIENT_KEY ] = client_key
@@ -176,7 +220,17 @@ def _run_plugin_in_child_process(
176220 * debug_option ,
177221 os .fspath (plugin_path ),
178222 ]
179- 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
180234
181235
182236async def run_plugin_async (
0 commit comments