diff --git a/bec_widgets/widgets/editors/console/console.py b/bec_widgets/widgets/editors/console/console.py index 6af4a8f0..b83bc444 100644 --- a/bec_widgets/widgets/editors/console/console.py +++ b/bec_widgets/widgets/editors/console/console.py @@ -11,7 +11,9 @@ import html import os import pty import re +import signal import sys +import time import pyte from pygments.token import Token @@ -353,6 +355,12 @@ class BECConsole(QtWidgets.QWidget): prompt_pattern = "".join(regex_parts) self.term._prompt_re = re.compile(prompt_pattern + r"\s*$") + def terminate(self, timeout=10): + self.term.stop(timeout=timeout) + + def send_ctrl_c(self, timeout=None): + self.term.send_ctrl_c(timeout) + cols = pyqtProperty(int, get_cols, set_cols) rows = pyqtProperty(int, get_rows, set_rows) bgcolor = pyqtProperty(QColor, get_bgcolor, set_bgcolor) @@ -372,6 +380,8 @@ class _TerminalWidget(QtWidgets.QPlainTextEdit): self._prompt_re = None # last prompt self._prompt_str = None + # process pid + self.pid = None # file descriptor to communicate with the subprocess self.fd = None self.backend = None @@ -468,7 +478,7 @@ class _TerminalWidget(QtWidgets.QPlainTextEdit): self.update_term_size() # Start the Bash process - self.fd = self.fork_shell() + self.pid, self.fd = self.fork_shell() if self.fd: # Create the ``Backend`` object @@ -484,6 +494,62 @@ class _TerminalWidget(QtWidgets.QPlainTextEdit): self.appendHtml(f"

{repr(self._cmd)} - Process exited.

") self.setReadOnly(True) + def send_ctrl_c(self, wait_prompt=True, timeout=None): + """Send CTRL-C to the process + + If wait_prompt=True (default), wait for a new prompt after CTRL-C + If no prompt is displayed after 'timeout' seconds, TimeoutError is raised + """ + os.kill(self.pid, signal.SIGINT) + if wait_prompt: + timeout_error = False + if timeout: + + def set_timeout_error(): + nonlocal timeout_error + timeout_error = True + + timeout_timer = QTimer() + timeout_timer.singleShot(timeout * 1000, set_timeout_error) + while self._prompt_str is None: + QApplication.instance().process_events() + if timeout_error: + raise TimeoutError( + f"CTRL-C: could not get back to prompt after {timeout} seconds." + ) + + def _is_running(self): + if os.waitpid(self.pid, os.WNOHANG) == (0, 0): + return True + return False + + def stop(self, kill=True, timeout=None): + """Stop the running process + + SIGTERM is the default signal for terminating processes. + + If kill=True (default), SIGKILL will be sent if the process does not exit after timeout + """ + # try to exit gracefully + os.kill(self.pid, signal.SIGTERM) + + # wait until process is truly dead + t0 = time.perf_counter() + while self._is_running(): + time.sleep(1) + if timeout is not None and time.perf_counter() - t0 > timeout: + # still alive after 'timeout' seconds + if kill: + # send SIGKILL and make a last check in loop + os.kill(self.pid, signal.SIGKILL) + kill = False + else: + # still running after timeout... + raise TimeoutError( + f"Could not terminate process with pid: {self.pid} within timeout" + ) + self.process_exited() + def data_ready(self, screen): """Handle new screen: redraw, set scroll bar max and slider, move cursor to its position @@ -762,7 +828,7 @@ class _TerminalWidget(QtWidgets.QPlainTextEdit): # We are in the parent process. # Set file control fcntl.fcntl(fd, fcntl.F_SETFL, os.O_NONBLOCK) - return fd + return pid, fd if __name__ == "__main__":