diff --git a/pygdbmi/IoManager.py b/pygdbmi/IoManager.py index dbd7276..1555492 100644 --- a/pygdbmi/IoManager.py +++ b/pygdbmi/IoManager.py @@ -3,27 +3,13 @@ or pty. """ import logging -import os +import queue import select -import time -from pprint import pformat -from typing import IO, Any, Dict, List, Optional, Tuple, Union +import threading +from typing import IO, Dict, List, Optional, Union -from pygdbmi import gdbmiparser -from pygdbmi.constants import ( - DEFAULT_GDB_TIMEOUT_SEC, - DEFAULT_TIME_TO_CHECK_FOR_ADDITIONAL_OUTPUT_SEC, - USING_WINDOWS, - GdbTimeoutError, -) - - -if USING_WINDOWS: - import msvcrt - from ctypes import POINTER, WinError, byref, windll, wintypes # type: ignore - from ctypes.wintypes import BOOL, DWORD, HANDLE -else: - import fcntl +from . import gdbmiparser +from .constants import USING_WINDOWS __all__ = ["IoManager"] @@ -32,13 +18,56 @@ logger = logging.getLogger(__name__) +def write_thread(stdin, in_queue): + while True: + command = in_queue.get() + # if we put a None in the command queue the thread needs to stop + if command is None: + break + + if USING_WINDOWS: + # select not implemented in windows for pipes + # assume it's always ready + outputready = [stdin.fileno()] + else: + # The timeout for the select is hardcoded to 1s because it is not a public interface. + _, outputready, _ = select.select([], [stdin.fileno()], [], 1) + for fileno in outputready: + if fileno == stdin.fileno(): + # ready to write + + if not command.endswith("\n"): + command += "\n" + stdin.write(command) # type: ignore + # must flush, otherwise gdb won't realize there is data + # to evaluate, and we won't get a response + stdin.flush() # type: ignore + else: + logger.error("got unexpected fileno %d" % fileno) + + +def read_thread(stream, stream_name, out_queue): + while True: + try: + stream.flush() + line = stream.readline() + except ValueError: + break + + if USING_WINDOWS: + line = line.replace(b"\r", b"\n") + parsed_response = gdbmiparser.parse_response(line) + parsed_response["stream"] = stream_name + + out_queue.put(parsed_response) + + class IoManager: def __init__( self, - stdin: IO[bytes], - stdout: IO[bytes], - stderr: Optional[IO[bytes]], - time_to_check_for_additional_output_sec: float = DEFAULT_TIME_TO_CHECK_FOR_ADDITIONAL_OUTPUT_SEC, + stdin: IO[str], + stdout: IO[str], + stderr: Optional[IO[str]], ) -> None: """ Manage I/O for file objects created before calling this class @@ -46,6 +75,9 @@ def __init__( pty is used. """ + self.in_queue = queue.Queue() + self.out_queue = queue.Queue() + self.stdin = stdin self.stdout = stdout self.stderr = stderr @@ -59,192 +91,57 @@ def __init__( self.read_list.append(self.stdout_fileno) self.write_list = [self.stdin_fileno] - self._incomplete_output: Dict[str, Any] = {"stdout": None, "stderr": None} - self.time_to_check_for_additional_output_sec = ( - time_to_check_for_additional_output_sec + self.write_tread = threading.Thread( + target=write_thread, args=(self.stdin, self.in_queue), daemon=True + ) + + self.read_stdout_thread = threading.Thread( + target=read_thread, args=(self.stdout, "stdout", self.out_queue) ) - self._allow_overwrite_timeout_times = ( - self.time_to_check_for_additional_output_sec > 0 + self.read_stderr_thread = threading.Thread( + target=read_thread, args=(self.stderr, "stderr", self.out_queue) ) - _make_non_blocking(self.stdout) - if self.stderr: - _make_non_blocking(self.stderr) + + self.write_tread.start() + self.read_stderr_thread.start() + self.read_stdout_thread.start() def get_gdb_response( self, - timeout_sec: float = DEFAULT_GDB_TIMEOUT_SEC, - raise_error_on_timeout: bool = True, ) -> List[Dict]: - """Get response from GDB, and block while doing so. If GDB does not have any response ready to be read - by timeout_sec, an exception is raised. - - Args: - timeout_sec: Maximum time to wait for reponse. Must be >= 0. Will return after - raise_error_on_timeout: Whether an exception should be raised if no response was found after timeout_sec + """Get response from GDB without blocking. If GDB does not have any response ready to be read + it returns an empty list Returns: List of parsed GDB responses, returned from gdbmiparser.parse_response, with the additional key 'stream' which is either 'stdout' or 'stderr' - - Raises: - GdbTimeoutError: if response is not received within timeout_sec - ValueError: if select returned unexpected file number """ - if timeout_sec < 0: - logger.warning("timeout_sec was negative, replacing with 0") - timeout_sec = 0 - - if USING_WINDOWS: - retval = self._get_responses_windows(timeout_sec) - else: - retval = self._get_responses_unix(timeout_sec) - - if not retval and raise_error_on_timeout: - raise GdbTimeoutError( - "Did not get response from gdb after %s seconds" % timeout_sec - ) - - else: - return retval - - def _get_responses_windows(self, timeout_sec: float) -> List[Dict]: - """Get responses on windows. Assume no support for select and use a while loop.""" - timeout_time_sec = time.time() + timeout_sec responses = [] + # read all the elements of the queue while True: - responses_list = [] try: - self.stdout.flush() - raw_output = self.stdout.readline().replace(b"\r", b"\n") - responses_list = self._get_responses_list(raw_output, "stdout") - except OSError: - pass - - if self.stderr is not None: - try: - self.stderr.flush() - raw_output = self.stderr.readline().replace(b"\r", b"\n") - responses_list += self._get_responses_list(raw_output, "stderr") - except OSError: - pass - - responses += responses_list - if timeout_sec == 0: - break - elif responses_list and self._allow_overwrite_timeout_times: - timeout_time_sec = min( - time.time() + self.time_to_check_for_additional_output_sec, - timeout_time_sec, - ) - elif time.time() > timeout_time_sec: - break - - return responses - - def _get_responses_unix(self, timeout_sec: float) -> List[Dict]: - """Get responses on unix-like system. Use select to wait for output.""" - timeout_time_sec = time.time() + timeout_sec - responses = [] - while True: - select_timeout = timeout_time_sec - time.time() - if select_timeout <= 0: - select_timeout = 0 - events, _, _ = select.select(self.read_list, [], [], select_timeout) - responses_list = None # to avoid infinite loop if using Python 2 - for fileno in events: - # new data is ready to read - if fileno == self.stdout_fileno: - self.stdout.flush() - raw_output = self.stdout.read() - stream = "stdout" - - elif fileno == self.stderr_fileno: - assert self.stderr is not None - self.stderr.flush() - raw_output = self.stderr.read() - stream = "stderr" - - else: - raise ValueError( - "Developer error. Got unexpected file number %d" % fileno - ) - responses_list = self._get_responses_list(raw_output, stream) - responses += responses_list - - if timeout_sec == 0: # just exit immediately - break - - elif responses_list and self._allow_overwrite_timeout_times: - # update timeout time to potentially be closer to now to avoid lengthy wait times when nothing is being output by gdb - timeout_time_sec = min( - time.time() + self.time_to_check_for_additional_output_sec, - timeout_time_sec, - ) - - elif time.time() > timeout_time_sec: + res = self.out_queue.get_nowait() + responses.append(res) + except queue.Empty: break return responses - def _get_responses_list( - self, raw_output: bytes, stream: str - ) -> List[Dict[Any, Any]]: - """Get parsed response list from string output - Args: - raw_output (unicode): gdb output to parse - stream (str): either stdout or stderr - """ - responses: List[Dict[Any, Any]] = [] - - (_new_output, self._incomplete_output[stream],) = _buffer_incomplete_responses( - raw_output, self._incomplete_output.get(stream) - ) - - if not _new_output: - return responses - - response_list = list( - filter(lambda x: x, _new_output.decode(errors="replace").split("\n")) - ) # remove blank lines - - # parse each response from gdb into a dict, and store in a list - for response in response_list: - if gdbmiparser.response_is_finished(response): - pass - else: - parsed_response = gdbmiparser.parse_response(response) - parsed_response["stream"] = stream - - logger.debug("%s", pformat(parsed_response)) - - responses.append(parsed_response) - - return responses - def write( self, mi_cmd_to_write: Union[str, List[str]], - timeout_sec: float = DEFAULT_GDB_TIMEOUT_SEC, - raise_error_on_timeout: bool = True, - read_response: bool = True, - ) -> List[Dict]: - """Write to gdb process. Block while parsing responses from gdb for a maximum of timeout_sec. + ): + """Write to gdb process. Args: mi_cmd_to_write: String to write to gdb. If list, it is joined by newlines. - timeout_sec: Maximum number of seconds to wait for response before exiting. Must be >= 0. - raise_error_on_timeout: If read_response is True, raise error if no response is received - read_response: Block and read response. If there is a separate thread running, this can be false, and the reading thread read the output. Returns: - List of parsed gdb responses if read_response is True, otherwise [] + None Raises: TypeError: if mi_cmd_to_write is not valid """ # self.verify_valid_gdb_subprocess() - if timeout_sec < 0: - logger.warning("timeout_sec was negative, replacing with 0") - timeout_sec = 0 # Ensure proper type of the mi command if isinstance(mi_cmd_to_write, str): @@ -264,91 +161,15 @@ def write( else: mi_cmd_to_write_nl = mi_cmd_to_write_str - if USING_WINDOWS: - # select not implemented in windows for pipes - # assume it's always ready - outputready = [self.stdin_fileno] - else: - _, outputready, _ = select.select([], self.write_list, [], timeout_sec) - for fileno in outputready: - if fileno == self.stdin_fileno: - # ready to write - self.stdin.write(mi_cmd_to_write_nl.encode()) # type: ignore - # must flush, otherwise gdb won't realize there is data - # to evaluate, and we won't get a response - self.stdin.flush() # type: ignore - else: - logger.error("got unexpected fileno %d" % fileno) + self.in_queue.put_nowait(mi_cmd_to_write_nl) - if read_response is True: - return self.get_gdb_response( - timeout_sec=timeout_sec, raise_error_on_timeout=raise_error_on_timeout - ) + def terminate(self): + # ends the read/write threads when the iomanager is destroyed + self.in_queue.put(None) - else: - return [] - - -def _buffer_incomplete_responses( - raw_output: Optional[bytes], buf: Optional[bytes] -) -> Tuple[Optional[bytes], Optional[bytes]]: - """It is possible for some of gdb's output to be read before it completely finished its response. - In that case, a partial mi response was read, which cannot be parsed into structured data. - We want to ALWAYS parse complete mi records. To do this, we store a buffer of gdb's - output if the output did not end in a newline. - - Args: - raw_output: Contents of the gdb mi output - buf (str): Buffered gdb response from the past. This is incomplete and needs to be prepended to - gdb's next output. - - Returns: - (raw_output, buf) - """ - - if raw_output: - if buf: - # concatenate buffer and new output - raw_output = b"".join([buf, raw_output]) - buf = None - - if b"\n" not in raw_output: - # newline was not found, so assume output is incomplete and store in buffer - buf = raw_output - raw_output = None - - elif not raw_output.endswith(b"\n"): - # raw output doesn't end in a newline, so store everything after the last newline (if anything) - # in the buffer, and parse everything before it - remainder_offset = raw_output.rindex(b"\n") + 1 - buf = raw_output[remainder_offset:] - raw_output = raw_output[:remainder_offset] - - return (raw_output, buf) - - -def _make_non_blocking(file_obj: IO) -> None: - """make file object non-blocking - Windows doesn't have the fcntl module, but someone on - stack overflow supplied this code as an answer, and it works - http://stackoverflow.com/a/34504971/2893090""" - - if USING_WINDOWS: - LPDWORD = POINTER(DWORD) - PIPE_NOWAIT = wintypes.DWORD(0x00000001) - - SetNamedPipeHandleState = windll.kernel32.SetNamedPipeHandleState - SetNamedPipeHandleState.argtypes = [HANDLE, LPDWORD, LPDWORD, LPDWORD] - SetNamedPipeHandleState.restype = BOOL - - h = msvcrt.get_osfhandle(file_obj.fileno()) # type: ignore - - res = windll.kernel32.SetNamedPipeHandleState(h, byref(PIPE_NOWAIT), None, None) - if res == 0: - raise ValueError(WinError()) - - else: - # Set the file status flag (F_SETFL) on the pipes to be non-blocking - # so we can attempt to read from a pipe with no new data without locking - # the program up - fcntl.fcntl(file_obj, fcntl.F_SETFL, os.O_NONBLOCK) + self.write_tread.join() + self.read_stdout_thread.join() + self.read_stderr_thread.join() + + def __del__(self): + self.terminate() diff --git a/pygdbmi/StringStream.py b/pygdbmi/StringStream.py index f130150..97e898b 100644 --- a/pygdbmi/StringStream.py +++ b/pygdbmi/StringStream.py @@ -1,6 +1,6 @@ from typing import List -from pygdbmi.gdbescapes import advance_past_string_with_gdb_escapes +from .gdbescapes import advance_past_string_with_gdb_escapes __all__ = ["StringStream"] diff --git a/pygdbmi/constants.py b/pygdbmi/constants.py index 2b43b17..e18d6a7 100644 --- a/pygdbmi/constants.py +++ b/pygdbmi/constants.py @@ -2,19 +2,7 @@ __all__ = [ - "DEFAULT_GDB_TIMEOUT_SEC", - "DEFAULT_TIME_TO_CHECK_FOR_ADDITIONAL_OUTPUT_SEC", - "GdbTimeoutError", "USING_WINDOWS", ] - -DEFAULT_GDB_TIMEOUT_SEC = 1 -DEFAULT_TIME_TO_CHECK_FOR_ADDITIONAL_OUTPUT_SEC = 0.2 USING_WINDOWS = os.name == "nt" - - -class GdbTimeoutError(ValueError): - """Raised when no response is recieved from gdb after the timeout has been triggered""" - - pass diff --git a/pygdbmi/gdbcontroller.py b/pygdbmi/gdbcontroller.py index d4fa59a..35a293d 100644 --- a/pygdbmi/gdbcontroller.py +++ b/pygdbmi/gdbcontroller.py @@ -4,15 +4,13 @@ """ import logging +import os import shutil +import signal import subprocess from typing import Dict, List, Optional, Union -from pygdbmi.constants import ( - DEFAULT_GDB_TIMEOUT_SEC, - DEFAULT_TIME_TO_CHECK_FOR_ADDITIONAL_OUTPUT_SEC, -) -from pygdbmi.IoManager import IoManager +from .IoManager import IoManager __all__ = ["GdbController"] @@ -22,11 +20,16 @@ logger = logging.getLogger(__name__) +SIGNAL_NAME_TO_NUM = {} +for n in dir(signal): + if n.startswith("SIG") and "_" not in n: + SIGNAL_NAME_TO_NUM[n.upper()] = getattr(signal, n) + + class GdbController: def __init__( self, command: Optional[List[str]] = None, - time_to_check_for_additional_output_sec: float = DEFAULT_TIME_TO_CHECK_FOR_ADDITIONAL_OUTPUT_SEC, ) -> None: """ Run gdb as a subprocess. Send commands and receive structured output. @@ -49,13 +52,8 @@ def __init__( ) self.abs_gdb_path = None # abs path to gdb executable self.command: List[str] = command - self.time_to_check_for_additional_output_sec = ( - time_to_check_for_additional_output_sec - ) + self.gdb_process: Optional[subprocess.Popen] = None - self._allow_overwrite_timeout_times = ( - self.time_to_check_for_additional_output_sec > 0 - ) gdb_path = command[0] if not gdb_path: raise ValueError("a valid path to gdb must be specified") @@ -93,7 +91,8 @@ def spawn_new_gdb_subprocess(self) -> int: stdout=subprocess.PIPE, stdin=subprocess.PIPE, stderr=subprocess.PIPE, - bufsize=0, + bufsize=1, + universal_newlines=True, ) assert self.gdb_process.stdin is not None @@ -102,35 +101,57 @@ def spawn_new_gdb_subprocess(self) -> int: self.gdb_process.stdin, self.gdb_process.stdout, self.gdb_process.stderr, - self.time_to_check_for_additional_output_sec, ) return self.gdb_process.pid def get_gdb_response( self, - timeout_sec: float = DEFAULT_GDB_TIMEOUT_SEC, - raise_error_on_timeout: bool = True, ) -> List[Dict]: """Get gdb response. See IoManager.get_gdb_response() for details""" - return self.io_manager.get_gdb_response(timeout_sec, raise_error_on_timeout) + return self.io_manager.get_gdb_response() + + def wait_gdb_response(self) -> Dict: + """Block until a GDB output is ready""" + return self.io_manager.out_queue.get() def write( self, mi_cmd_to_write: Union[str, List[str]], - timeout_sec: float = DEFAULT_GDB_TIMEOUT_SEC, - raise_error_on_timeout: bool = True, - read_response: bool = True, - ) -> List[Dict]: + ): """Write command to gdb. See IoManager.write() for details""" - return self.io_manager.write( - mi_cmd_to_write, timeout_sec, raise_error_on_timeout, read_response - ) + self.io_manager.write(mi_cmd_to_write) + + def send_signal_to_gdb(self, signal_input): + """Send signal name (case insensitive) or number to gdb subprocess + gdbmi.send_signal_to_gdb(2) # valid + gdbmi.send_signal_to_gdb('sigint') # also valid + gdbmi.send_signal_to_gdb('SIGINT') # also valid + raises ValueError if signal_input is invalie + raises NoGdbProcessError if there is no gdb process to send a signal to + """ + try: + signal = int(signal_input) + except Exception: + signal = SIGNAL_NAME_TO_NUM.get(signal_input.upper()) + + if not signal: + raise ValueError( + 'Could not find signal corresponding to "%s"' % str(signal) + ) + + if self.gdb_process: + os.kill(self.gdb_process.pid, signal) + else: + logger.error("Cannot send signal to gdb process because no process exists.") def exit(self) -> None: """Terminate gdb process""" if self.gdb_process: - self.gdb_process.terminate() - self.gdb_process.wait() - self.gdb_process.communicate() - self.gdb_process = None - return None + gdb_process = self.gdb_process + self.gdb_process = None + + gdb_process.terminate() + gdb_process.wait() + gdb_process.communicate() + + self.io_manager.terminate() diff --git a/pygdbmi/gdbmiparser.py b/pygdbmi/gdbmiparser.py index 17a3e33..f601368 100755 --- a/pygdbmi/gdbmiparser.py +++ b/pygdbmi/gdbmiparser.py @@ -12,9 +12,9 @@ import re from typing import Any, Callable, Dict, List, Match, Optional, Pattern, Tuple, Union -from pygdbmi.gdbescapes import unescape -from pygdbmi.printcolor import fmt_green -from pygdbmi.StringStream import StringStream +from .gdbescapes import unescape +from .printcolor import fmt_green +from .StringStream import StringStream __all__ = [ @@ -162,6 +162,7 @@ def _extract_payload(match: Match, stream: StringStream) -> Optional[Dict]: # A regular expression matching a response finished record. _GDB_MI_RESPONSE_FINISHED_RE = re.compile(r"^\(gdb\)\s*$") +# _GDB_MI_RESPONSE_FINISHED_RE = re.compile(r"^\^done*$") # Regular expression identifying a token in a MI record. _GDB_MI_COMPONENT_TOKEN = r"(?P\d+)?"