Files

191 lines
6.1 KiB
Python
Raw Permalink Normal View History

from __future__ import annotations
import atexit
import inspect
import os
import re
import traceback
from expect import EXPECT_DIED, EXPECT_TIMEOUT, ExpectProcess
from quotemeta import convert_expression
from utils import indent
class GDBSession:
"""
Handle for a GDB session, to run GDB commands and inspect their output.
"""
PROMPT_RE = r"\(gdb\) "
TIMEOUT = 30 # In seconds
def __init__(
self, program: str | None = None, log_file: str | None = None
):
# Make sure that the GDB subprogram is terminated with its logs written
# somewhere before the end of the script.
atexit.register(self.stop)
self.log_file = log_file or "gdb.log"
# Disable the load of .gdbinit to avoid user configuration
# interference.
argv = ["gdb", "--nh"]
os.environ["TERM"] = "dumb"
self.proc = ExpectProcess(argv, save_input=True, save_output=True)
self.alive = True
_ = self._read_to_next_prompt()
# Enable Python backtraces to ease investigation
self.execute("set python print-stack full")
# Disable interactive mode, which is bound to create trouble in a
# testsuite.
self.execute("set interactive-mode off")
# Make the output deterministic, independent of the actual terminal
# size.
self.execute("set height 0")
self.execute("set width 80")
if program:
# Only then, load the inferior. Loading gnatdbg before checks that
# importing it does not rely on the presence of debug information.
self.test(
"file {}".format(program),
r"Reading symbols from {}...@...@/done|/".format(program),
)
def _read_to_next_prompt(self) -> str:
"""
Read GDB's output until we reach the next prompt.
Return the output in between. Raise a RuntimeError if GDB dies or if
timeout is reached.
"""
assert self.alive
status = self.proc.expect([self.PROMPT_RE], self.TIMEOUT)
if status is EXPECT_DIED:
raise RuntimeError("GDB died")
elif status is EXPECT_TIMEOUT:
raise RuntimeError("Timeout reached while waiting for GDB")
assert status == 0
out, prompt = self.proc.out()
return out
def execute(self, command: str) -> None:
"""
Shortcut for `test` without an expected output.
"""
return self.test(command, None)
def test(
self, command: str, expected_output: str | None, quotemeta: bool = True
) -> None:
"""
Send the given command to GDB and check its output.
:param command: GDB command to send.
:param expected_output: If None, don't check the command output.
Otherwise, it must be a quotemeta expression that must match the
output.
:param quotemeta: Whether to interpret ``expected_output`` as a
quotemeta expression. If False, expect exactly the given output.
"""
assert self.alive
assert self.proc.send(command)
output = self._read_to_next_prompt().strip().replace("\r", "")
if expected_output is None:
matcher = ""
elif quotemeta:
matcher = convert_expression(expected_output)
else:
matcher = re.escape(expected_output)
if expected_output is not None and not re.match(matcher, output):
print("")
print("FAIL: {}".format(command))
print("From:")
# Print the current call stack but omit the current frame
print(
"".join(
traceback.format_stack(inspect.currentframe().f_back)
).rstrip()
)
print("Output:")
print(indent(output))
print("Does not match the expected:")
print(indent(expected_output))
def print_expr(self, expr: str, expected_output: str) -> None:
"""
Execute the "print" GDB command and check its output.
:param expr: Expression to print.
:param expected_output: Regular expression that the output must
match. Note that it must not include the '$NUMBER = ' prefix.
"""
self.test("print {}".format(expr), "$@NUMBER = " + expected_output)
@staticmethod
def find_loc(filename: str, slug: str) -> str:
"""
Look for a source file location.
If the location is found, return a string location suitable for GDB's
break command. Raise a RuntimeError otherwise.
:param filename: Target source file name.
:param slug: Source file excerpt for the location. For instance, a
specific string that is in a comment.
:return: String location suitable for GDB's break command.
"""
with open(filename, "r") as f:
for i, line in enumerate(f, 1):
if slug in line:
return "{}:{}".format(filename, i)
raise RuntimeError(
"Could not find location in {} for {}".format(filename, slug)
)
def run_to(self, location: str) -> None:
"""
Start inferior execution until it reaches the given location.
:param location: String location suitable for GDB's break command.
"""
self.execute("tbreak {}".format(location))
self.execute("run")
def kill(self) -> None:
"""
Kill the inferior process currently running.
"""
self.test(
"kill",
"Kill the program being debugged? (y or n)"
" [answered Y; input not from terminal]\n"
"[Inferior 1 (process @/\\d+/) killed]",
)
def stop(self) -> None:
"""
Stop GDB.
This writes session logs to make post-mortem debugging.
"""
if not self.alive:
return
self.alive = False
# No matter what, write the session logs to make post-mortem debugging
# possible.
with open(self.log_file, "w") as f:
f.write(self.proc.get_session_logs())