2025, Dec 09 18:01
Корректное завершение .bat, запущенного из Python в Windows
Разбираем, почему Ctrl+C и terminate() не всегда останавливают .bat в Windows, и показываем решение на psutil: как убить всё дерево процессов из Python.
Остановка пакетного файла Windows, запущенного из Python, кажется простой задачей до тех пор, пока Ctrl+C не сработает в неподходящий момент. Подпроцесс продолжает отсчёт, Python поднимает KeyboardInterrupt, и вы обнаруживаете, что terminate() ведёт себя не так, как ожидается. Ниже — краткий разбор проблемы и практичный способ корректно завершить именно то, что было запущено.
Воспроизведение проблемы
Ниже фрагмент, который создаёт простой .bat с командой TIMEOUT, запускает его в новой консоли со скрытым окном и опрашивает процесс до завершения. При KeyboardInterrupt он пытается остановить процесс, дождаться его завершения, удалить файл и выйти.
import os
import time
import subprocess
with open('test.bat', 'w', encoding='utf-8') as fh:
fh.write('@echo off\nTIMEOUT /t 20')
with open('test.txt', 'w', encoding='utf-8') as fh:
fh.write('test file')
worker = None
HIDE_FLAG = 1
start_cfg = subprocess.STARTUPINFO()
start_cfg.dwFlags = subprocess.STARTF_USESHOWWINDOW
start_cfg.wShowWindow = HIDE_FLAG
worker = subprocess.Popen('test.bat', startupinfo=start_cfg, creationflags=CREATE_NEW_CONSOLE)
print(f"Subprocess started with PID: {worker.pid}")
try:
while worker.poll() is None:
time.sleep(0.5)
except KeyboardInterrupt as exc:
print('stopping')
worker.terminate()
worker.wait()
os.remove('test.txt')
quit()
print('continue code')
os.remove('test.txt')
Что происходит и почему это непросто
Процесс Python получает KeyboardInterrupt, но запущенный .bat может продолжать работу. Если завершить только хэндл, возвращённый Popen, можно не зацепить реальный интерпретатор команд, который выполняет батник. Именно поэтому решения, нацеленные на один PID или имя исполняемого файла, срабатывают лишь часть времени. Например, taskkill по имени или PID нередко оставляет .bat активным в заметной доле запусков:
subprocess.call(["taskkill", "/F", "/IM", 'test.bat'])
subprocess.check_call(['taskkill', '/F', '/PID', str(worker.pid)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
Прямые сигналы вроде os.kill(pid, signal.SIGTERM) тоже не помогают. Общая закономерность здесь в том, что нужно останавливать всё дерево процессов, а не только исходный хэндл.
Решение: завершать дерево процессов
Надёжный путь — перечислить дочерние процессы запущенного процесса и сначала завершить их, а затем убить родителя. С psutil это делается кратко и явно: получаем процесс по PID, рекурсивно убиваем всех потомков и в конце — корневой.
import os
import time
import subprocess
import psutil
with open('test.bat', 'w', encoding='utf-8') as outb:
outb.write('@echo off\nTIMEOUT /t 20')
with open('test.txt', 'w', encoding='utf-8') as outf:
outf.write('test file')
child = None
WIN_HIDE = 1
si = subprocess.STARTUPINFO()
si.dwFlags = subprocess.STARTF_USESHOWWINDOW
si.wShowWindow = WIN_HIDE
child = subprocess.Popen('test.bat', startupinfo=si, creationflags=CREATE_NEW_CONSOLE)
print(f"Subprocess started with PID: {child.pid}")
try:
while child.poll() is None:
time.sleep(0.5)
except KeyboardInterrupt as err:
print('aborting')
root = psutil.Process(child.pid)
for sub in root.children(recursive=True):
sub.kill()
root.kill()
os.remove('test.txt')
quit()
print('continue code')
os.remove('test.txt')
Такой подход нацеливается на всё, что было порождено батником, поэтому он предсказуемее, чем завершение по одному PID или имени образа. Он показывает работоспособность на практике, хотя для крайних случаев может понадобиться дополнительное тестирование.
Почему это важно
Долгие скрипты, CI-задания, тестовые стенды и конвейеры автоматизации зависят от корректного завершения. Частичное «убийство» может оставить батник тихо работающим в фоне, тратя время и держа ресурсы в блокировке — это трудно отлавливаемый источник флаков. Завершая целое дерево процессов, вы избегаете таких «хвостов» и сохраняете предсказуемый контроль потока при прерываниях.
Выводы
Когда процесс Python запускает .bat в Windows, остановка только PID, возвращённого Popen, не всегда прекращает реальную работу. Завершение дерева процессов — сперва потомков, затем родителя — решает случаи, когда taskkill по имени или PID срабатывает лишь частично. Если нужно предсказуемо сворачиваться при KeyboardInterrupt, явно убивайте всех потомков, потом родителя и выходите чисто.