3232_SETUP_DELAY_SECONDS = 0.5
3333_SETUP_POLL_INTERVAL_SECONDS = 0.05
3434_MAX_SETUP_WAIT_SECONDS = 2.0
35+ _INTERRUPT_GRACE_SECONDS = 0.5
3536
3637_WINDOWS_SPECIALS : dict [str , str ] = {
3738 "ENTER" : "\n " ,
@@ -172,6 +173,7 @@ def close(self) -> None:
172173 return
173174
174175 self ._stop_reader .set ()
176+ self ._terminate_child_processes ()
175177
176178 if self .process is not None :
177179 try :
@@ -327,6 +329,62 @@ def clear_screen(self) -> None:
327329 time .sleep (_SCREEN_CLEAR_DELAY_SECONDS )
328330 self ._command_running_event .clear ()
329331
332+ def _terminate_child_processes (self ) -> bool :
333+ """Terminate descendants of the persistent PowerShell process."""
334+ if (
335+ platform .system () != "Windows"
336+ or self .process is None
337+ or self .process .poll () is not None
338+ ):
339+ return False
340+
341+ script = f"""
342+ $root = { self .process .pid }
343+ $childrenByParent = @{{}}
344+ Get-CimInstance Win32_Process | ForEach-Object {{
345+ $parentId = [int]$_.ParentProcessId
346+ if (-not $childrenByParent.ContainsKey($parentId)) {{
347+ $childrenByParent[$parentId] = New-Object System.Collections.Generic.List[int]
348+ }}
349+ $childrenByParent[$parentId].Add([int]$_.ProcessId)
350+ }}
351+ $toStop = New-Object System.Collections.Generic.List[int]
352+ function Add-Descendants([int]$processId) {{
353+ if (-not $childrenByParent.ContainsKey($processId)) {{ return }}
354+ foreach ($childId in $childrenByParent[$processId]) {{
355+ if ($childId -eq $PID) {{ continue }}
356+ $toStop.Add($childId)
357+ Add-Descendants $childId
358+ }}
359+ }}
360+ Add-Descendants $root
361+ for ($i = $toStop.Count - 1; $i -ge 0; $i--) {{
362+ Stop-Process -Id $toStop[$i] -Force -ErrorAction SilentlyContinue
363+ }}
364+ if ($toStop.Count -gt 0) {{ exit 0 }} else {{ exit 1 }}
365+ """
366+ startupinfo = None
367+ startupinfo_cls = getattr (subprocess , "STARTUPINFO" , None )
368+ if startupinfo_cls is not None :
369+ startupinfo = startupinfo_cls ()
370+ startupinfo .dwFlags |= getattr (subprocess , "STARTF_USESHOWWINDOW" , 0 )
371+ creationflags = getattr (subprocess , "CREATE_NO_WINDOW" , 0 )
372+
373+ try :
374+ result = subprocess .run (
375+ [self .shell_path , "-NoLogo" , "-NoProfile" , "-Command" , script ],
376+ stdout = subprocess .DEVNULL ,
377+ stderr = subprocess .DEVNULL ,
378+ check = False ,
379+ timeout = 5.0 ,
380+ startupinfo = startupinfo ,
381+ creationflags = creationflags ,
382+ )
383+ return result .returncode == 0
384+ except (subprocess .TimeoutExpired , OSError ) as exc :
385+ logger .debug ("Failed to terminate PowerShell child processes: %s" , exc )
386+ return False
387+
330388 def interrupt (self ) -> bool :
331389 """Interrupt the active command if the process is still alive."""
332390 if self .process is None or self .process .poll () is not None :
@@ -341,15 +399,21 @@ def interrupt(self) -> bool:
341399 except Exception as exc :
342400 logger .debug ("Failed to send CTRL_BREAK_EVENT: %s" , exc )
343401
344- if not sent_ctrl_break :
402+ if sent_ctrl_break :
403+ time .sleep (_INTERRUPT_GRACE_SECONDS )
404+
405+ terminated_children = self ._terminate_child_processes ()
406+ sent_ctrl_c_input = False
407+ if not sent_ctrl_break and not terminated_children :
345408 try :
346409 self ._write_to_stdin (_WINDOWS_SPECIALS ["C-C" ])
410+ sent_ctrl_c_input = True
347411 except RuntimeError as exc :
348412 logger .debug ("Failed to write Ctrl+C to PowerShell stdin: %s" , exc )
349413 return False
350414
351415 self ._command_running_event .clear ()
352- return True
416+ return sent_ctrl_break or terminated_children or sent_ctrl_c_input
353417
354418 def is_running (self ) -> bool :
355419 """Return whether a command is still running in the PowerShell session."""
0 commit comments