Jak ubić zespawnowany proces odczytując jego wyjścia standardowe?

0

Weźmy na tapetę taki program:

print("Started")
while True:
    pass

Jeśli go uruchomię, i podczas jego działania spróbuję go ubić, np przez Ctrl+C, to zobaczę takie coś:

PS C:\Users\Riddle\PycharmProjects\x> python .\filex.py
Started
Traceback (most recent call last):
  File "C:\Users\Riddle\PycharmProjects\x\filex.py", line 1, in <module>
    while True:                                                            
KeyboardInterrupt                        

Z czego "Started" leci na standard output, a stacktrace leci na standard output.

Chciałbym teraz zrobić to samo programistycznie, czyli:

  1. Zespawnować proces
  2. Wczytać string "Started"
  3. Wysłać sygnał interrupt (dowolną metodą, to może być signal, jakiś znak na standard input, ubicie procesu, dowolnie)
  4. Odczytać stacktrace.

Na razie zakodziłem coś takiego:

import signal
import threading
from subprocess import PIPE, Popen

def run_process():
    print("Starting process...")
    source_code = """
print("Started")
while True:
    pass
"""
    proc = Popen(["python", "-c", source_code], stdout=PIPE, stdin=PIPE)
    print("Process started")
    return proc


def send_interrupt(process: Popen) -> None:
    print("Sending interrupt...")
    process.send_signal(signal.CTRL_BREAK_EVENT)
    process.send_signal(signal.CTRL_C_EVENT)
    print("Interrupt sent")


def read_output(process: Popen) -> bytes:
    print("Reading output...")
    output = b""
    while True:
        line = process.stdout.readline()
        if not line:
            break
        output += line
        print(line.decode(), end="")
    print("Output read")
    return output


process = run_process()
threading.Timer(2.0, send_interrupt, args=[process]).start()
output = read_output(process)
print("Process finished with output:")
print(output.decode())

ale wysłanie signal.CTRL_BREAK_EVENT oraz signal.CTRL_C_EVENT nie ubija programu. Wysłanie b"\x03" (czyli ASCII EXT) na standard input też nie obija programu (to chyba działa tylko z shella).

import subprocess
from subprocess import PIPE

command = ["python", "-c", """
x = input();
print(x);
print("Finished");
"""]

process = subprocess.Popen(command, stdout=PIPE, stdin=PIPE, stderr=PIPE)

communicate = process.communicate(b"\x03")

print(communicate)
print("Process finished with exit code:", process.returncode)

Ten kod po prostu "łyka" ten \x03, nie można tego użyć żeby ubić proces.

(b'\x03\r\nFinished\r\n', b'')
Process finished with exit code: 0

Jeśli po prostu rzucę KeyboardInterrupt(), to udaje mi się go złapać:

import subprocess
from subprocess import PIPE

command = ["python", "-c", """
raise KeyboardInterrupt()
"""]

process = subprocess.Popen(command, stdout=PIPE, stdin=PIPE, stderr=PIPE)
print(process.communicate())
print("Process finished with exit code:", process.returncode)
Process finished with exit code: 3221225786
(b'', b'Traceback (most recent call last):\r\n  File "<string>", line 2, in <module>\r\nKeyboardInterrupt\r\n')

Dostaję stacktrace w wyniku, czyli dokładnie to o co mi chodzi - to jest spodziewany efekt, żeby wywołać KeyboardInterrupt i wczytać stacktrace, tak jak w tym przykładzie. Ale chciałbym wywołać go nie przez wpisanie raise KeyboardInterrupt(), tylko poprzez wysłanie sygnału, np tak:

import signal
import subprocess
from subprocess import PIPE

command = ["python", "-c", """
print("Started")
while True:
    pass
print("Finished")
"""]

process = subprocess.Popen(command, stdout=PIPE, stdin=PIPE, stderr=PIPE)

process.send_signal(signal.CTRL_BREAK_EVENT)
process.send_signal(signal.CTRL_C_EVENT)

print(process.communicate())
print("Process finished with exit code:", process.returncode)
(b'', b'')
Process finished with exit code: 3221225794

Tylko wtedy już nie udaje mi się dostać stacktrace'a. Nie jestem do końca pewien dlaczego, wydaje mi się że może być tak że sygnały trafią za szybko do procesu, i ubijają go zanim w ogóle wstanie.

Więc pomyślałem że zrobię coś takiego: spawn proces, wczytaj jego stdout, niech on teraz blokuje czekając na stdin, wyślij coś na stdin i od razu wyślij sygnał interrupta:

import signal
import subprocess
from subprocess import PIPE

command = ["python", "-c", """
print("Started")
value = input()
print("received: " + value)
while True:
    pass
"""]

process = subprocess.Popen(command, stdout=PIPE, stdin=PIPE, stderr=PIPE)

print("read line: " + process.stdout.readline().decode())

process.stdin.write(b"input\n")
process.stdin.flush()

process.send_signal(signal.CTRL_BREAK_EVENT)
process.send_signal(signal.CTRL_C_EVENT)

print(process.communicate())
print("Process finished with exit code:", process.returncode)

Niestety jak tak zrobię to zespawnowany program nigdy się nie ubija, po prostu wisi.

Co ciekaw jak zakomentuję wczytanie stdout procesu:

import signal
import subprocess
from subprocess import PIPE

command = ["python", "-c", """
print("Started")
value = input()
print("received: " + value)
while True:
    pass
"""]

process = subprocess.Popen(command, stdout=PIPE, stdin=PIPE, stderr=PIPE)

#print("read line: " + process.stdout.readline().decode())

process.stdin.write(b"input\n")
process.stdin.flush()

process.send_signal(signal.CTRL_BREAK_EVENT)
process.send_signal(signal.CTRL_C_EVENT)

print(process.communicate())
print("Process finished with exit code:", process.returncode)

To wtedy proces od razu się ubija i nie udaje mi się wczytać outputu:

(b'', b'')
Process finished with exit code: 3221225794

Myślałem też, że możeby nie odpalać tego programu w osobnym procesie, tylko w osobnym wątku, dodać lock, i zwolnić go w odpowiednim momencie żeby odczytać stacktrace.

Co do rozwiązania, chciałbym uniknąć sleep() - z prostego powodu, takiemu sleep() trzeba nadać jakąś liczbę sekund do czekania, która może być odpowiednia na różnych maszynach, a to znaczy że są potencjalne raceconditiony - chyba że się je jakoś ogarnie, np sleep() w while'u, wtedy okej.

Pytanko mam więc takie: macie jakiś pomysł jak zespawnować proces, wysłać do niego interrupta i odczytać stacktrace?

Wiem że jest to możliwe, bo raz jak się bawiłem w debugerze to dostałem stacktrace spowodowany przez os.kill(), ale nie udało mi się tego powtórzyć poza debuggerem.

Wołam @Wibowit , @LukeJL i @Patryk27.

0

Dziwna ta praktyka odczytu standardowego outputu. Trochę mi tu pachnie problemem XY. Masz kontrolę nad spawnowanymi procesami, tzn. Ty je tworzysz? Jeśli tak, to może połącz je kolejką i przechwytuj z nich dane, a gdy będziesz chciał ubić proces, to dodaj do niego metodę, która zakończy nasłuchującą metodę.

3

wydaje mi się że może być tak że sygnały trafią za szybko do procesu, i ubijają go zanim w ogóle wstanie.

Wydaje się, że to dobry trop. Niech zatem ten proces potomny, gdy już mozolnie uruchomi interpreter Pythona, wyśle na samym początku sygnał do rodzica (pid otrzymany z os.getppid()), że jest już gotowy i można go ubić, by otrzymać stacktrace. U mnie na Linuksie:

import signal
import subprocess
import os
from subprocess import PIPE

command = ["python", "-c", """
import os
import signal

print("Started")
os.kill(os.getppid(), signal.SIGUSR1)

while True:
    pass
print("Finished")
"""]

def handler(signum, frame):
    os.kill(process.pid, signal.SIGINT)    

signal.signal(signal.SIGUSR1, handler)

process = subprocess.Popen(command, stdout=PIPE, stdin=PIPE, stderr=PIPE)

print(process.communicate(b'..'))
print("Process finished with exit code:", process.returncode)

Co daje:

$ python test.py 
(b'Started\n', b'Traceback (most recent call last):\n  File "<string>", line 8, in <module>\nKeyboardInterrupt\n')
Process finished with exit code: 1
0
Spearhead napisał(a):

wydaje mi się że może być tak że sygnały trafią za szybko do procesu, i ubijają go zanim w ogóle wstanie.

Wydaje się, że to dobry trop. Niech zatem ten proces potomny, gdy już mozolnie uruchomi interpreter Pythona, wyśle na samym początku sygnał do rodzica (pid otrzymany z os.getppid()), że jest już gotowy i można go ubić, by otrzymać stacktrace. U mnie na Linuksie:

Okej, krok w dobrą stronę, podoba mi się.

Teraz pytanie czy da się z tego coś zrobić żeby ten sygnał był z zewnątrz.

0
Riddle napisał(a):

Teraz pytanie czy da się z tego coś zrobić żeby ten sygnał był z zewnątrz.

Da się, tylko wciąż nieco problematyczne jest przejmowanie z stdout z procesu potomnego. Sprawdź sobie taki kod:

#!/usr/bin/env python3

from multiprocessing import Process, Manager
from time import sleep


class Forrest(Process):

	def __init__(self, n, lst):
		self._counter = n
		self._result = lst
		super().__init__(target=self.run, args=(self._result,))

	def run(self):
		while self._counter > 0:
			self._result.append(self._counter)
			self._counter -= 1
			sleep(1)


if __name__ == '__main__':
	man = Manager()
	lst = man.list([])
	f = Forrest(5, lst)
	f.start()
	print(f"is process alive: {f.is_alive()}")
	#~ sleep(2)
	#~ f.terminate()
	f.join()
	print(f"is process alive: {f.is_alive()}")
	print(lst)

Na wyjściu dostaniesz:

is process alive: True
is process alive: False
[5, 4, 3, 2, 1]

Natomiast jak usuniesz komentarze z sekcji main to otrzymasz:

is process alive: True
is process alive: False
[5, 4]

Masz więc kontrolę nad ubijaniem procesu z poziomu rodzica. Z mojego doświadczenia to subprocess wybrałbym do spawnowania programów zewnętrznych, nie zaś kodu Pythona.

0

Teraz pytanie czy da się z tego coś zrobić żeby ten sygnał był z zewnątrz.

No raczej, w czym problem...? W momencie gdy potomek zgłosi wyżej, że jest gotowy i zaczyna właściwe działanie można mu ten sygnał wysłać z dowolnego miejsca, nie ma znaczenia czy wystrzelę go prosto z rodzica czy z sąsiedniego terminalu.

Można by na przykład wysyłać sygnał nie do samego potomka, ale do rodzica, w którym zaimplementować handler, który będzie się blokował do czasu aż potomek nie da znać, że już można go zabić ustawiając jakąś flagę, w rodzaju:

import signal
import subprocess
import os
from subprocess import PIPE

command = ["python", "-c", """
import os
import signal

print("Started")
os.kill(os.getppid(), signal.SIGUSR1)

while True:
    pass
print("Finished")
"""]

class Process:
    def __init__(self, command):
        self.command = command
        self.ready_to_kill = False
        signal.signal(signal.SIGUSR1, self._mark_as_ready_to_kill)
        signal.signal(signal.SIGINT, self._interrupt)

    def run(self):
        self.process = subprocess.Popen(self.command, stdout=PIPE, stdin=PIPE, stderr=PIPE)
        self.process.wait()
        print(self.process.communicate())
        print("Process finished with exit code:", self.process.returncode)

    def _mark_as_ready_to_kill(self, signum, frame):
        print("ready to kill")
        self.ready_to_kill = True

    def _interrupt(self, signum, frame):
        print("interrupt")
        while not self.ready_to_kill:
            pass
        os.kill(self.process.pid, signal.SIGINT)


Process(command).run()
0
Spearhead napisał(a):

Teraz pytanie czy da się z tego coś zrobić żeby ten sygnał był z zewnątrz.

No raczej, w czym problem...? W momencie gdy potomek zgłosi wyżej, że jest gotowy i zaczyna właściwe działanie można mu ten sygnał wysłać z dowolnego miejsca, nie ma znaczenia czy wystrzelę go prosto z rodzica czy z sąsiedniego terminalu.

Mówiąc "da się z tego coś zrobić żeby ten sygnał był z zewnątrz", miałem na myśli to, żeby nie było tego os.kill() w kodzie uruchamianego programu.

0
Pyxis napisał(a):
Riddle napisał(a):

Teraz pytanie czy da się z tego coś zrobić żeby ten sygnał był z zewnątrz.

Da się, tylko wciąż nieco problematyczne jest przejmowanie z stdout z procesu potomnego. Sprawdź sobie taki kod:

#!/usr/bin/env python3

from multiprocessing import Process, Manager
from time import sleep


class Forrest(Process):

	def __init__(self, n, lst):
		self._counter = n
		self._result = lst
		super().__init__(target=self.run, args=(self._result,))

	def run(self):
		while self._counter > 0:
			self._result.append(self._counter)
			self._counter -= 1
			sleep(1)


if __name__ == '__main__':
	man = Manager()
	lst = man.list([])
	f = Forrest(5, lst)
	f.start()
	print(f"is process alive: {f.is_alive()}")
	#~ sleep(2)
	#~ f.terminate()
	f.join()
	print(f"is process alive: {f.is_alive()}")
	print(lst)

Takie ubicie procesu nie rzuca KeyboardInterrupt() w dziecku, a miało rzucać.

1

Mówiąc "da się z tego coś zrobić żeby ten sygnał był z zewnątrz", miałem na myśli to, żeby nie było tego os.kill() w kodzie uruchamianego programu.

No ale musisz jakoś stwierdzić, kiedy chcesz ubić swój proces. Kod powyżej jawnie daje znać, że już można. Kiedy można uznać proces bez tego os.kill za gotowy do potencjalnego ubicia? W momencie jak interpreter wstanie? W momencie jak kod wejdzie w __main__...? W momencie jak kod będzie działać przez x milisekund...?

Jeżeliby uznać start inrepretera Pythona za moment początkowy, to można by to wykryć z użyciem pliku sitecustomize.py, którego zawartość byłaby uruchamiana przy każdym starcie Pythona zanim przejdziesz do swojego kodu. Jeżeli mam sobie katalog /tmp/Test, w którym mam plik __init__.py i sitecustomize.py z zawartością:

print("Started Python interpreter...")

To kod:

import signal
import subprocess
import os
from subprocess import PIPE

command = ["python", "-c", """
print("Started")
print("Finished")
"""]

my_env = os.environ.copy()
my_env["PYTHONPATH"] = "/tmp/Test"

process = subprocess.Popen(command, stdout=PIPE, stdin=PIPE, stderr=PIPE, env=my_env)
print(process.communicate())
print("Process finished with exit code:", process.returncode)

da mi:

$ python test.py 
(b'Started Python interpreter...\nStarted\nFinished\n', b'')
Process finished with exit code: 0

Wystarczy zatem zamienić zawartość sitecustomize.py na

import os
import signal

print("Started Python interpreter")
os.kill(os.getppid(), signal.SIGUSR1)

I kod:

import signal
import subprocess
import os
from subprocess import PIPE

command = ["python", "-c", """
print("Started")
while True:
    pass
print("Finished")
"""]

class Process:
    def __init__(self, command):
        self.command = command
        self.ready_to_kill = False
        signal.signal(signal.SIGUSR1, self._mark_as_ready_to_kill)
        signal.signal(signal.SIGINT, self._interrupt)

    def run(self):
        my_env = os.environ.copy()
        my_env["PYTHONPATH"] = "/tmp/Test"
        self.process = subprocess.Popen(self.command, stdout=PIPE, stdin=PIPE, stderr=PIPE, env=my_env)
        self.process.wait()
        print(self.process.communicate())
        print("Process finished with exit code:", self.process.returncode)

    def _mark_as_ready_to_kill(self, signum, frame):
        print("ready to kill")
        self.ready_to_kill = True

    def _interrupt(self, signum, frame):
        print("interrupt")
        while not self.ready_to_kill:
            pass
        os.kill(self.process.pid, signal.SIGINT)


Process(command).run()

Da mi:

$ python test.py 
ready to kill
^Cinterrupt
(b'Started Python interpreter\nStarted\n', b'Traceback (most recent call last):\n  File "<string>", line 3, in <module>\n\n')
Process finished with exit code: 1
0
Spearhead napisał(a):

Mówiąc "da się z tego coś zrobić żeby ten sygnał był z zewnątrz", miałem na myśli to, żeby nie było tego os.kill() w kodzie uruchamianego programu.

No ale musisz jakoś stwierdzić, kiedy chcesz ubić swój proces. Kod powyżej jawnie daje znać, że już można. Kiedy można uznać proces bez tego os.kill za gotowy do potencjalnego ubicia? W momencie jak interpreter wstanie? W momencie jak kod wejdzie w __main__...? W momencie jak kod będzie działać przez x milisekund...?

Jeżeliby uznać start inrepretera Pythona za moment początkowy, to można by to wykryć z użyciem pliku sitecustomize.py, którego zawartość byłaby uruchamiana przy każdym starcie Pythona zanim przejdziesz do swojego kodu. Jeżeli mam sobie katalog /tmp/Test, w którym mam plik __init__.py i sitecustomize.py z zawartością:

Okej, super, dokładnie czegoś takiego szukałem.

Teraz pytanko - jak zrobić to samo na Windowsie? Bo tam nie można wysłać .SIGUSR1.

0

Nie programuję pod Windowsem więc nie wiem jak tam działa komunikacja międzyprocesowa, ale pewnie coś w API mają. W najbardziej prymitywny sposób można by po prostu tworzyć lub edytować jakiś plik albo ustawiać klucz w rejestrze, cokolwiek co drugi proces może obserwować i reagować.

1 użytkowników online, w tym zalogowanych: 0, gości: 1