blob: dc87042ffba1258d03dd0dbca75692f1ee372019 [file] [log] [blame]
# Copyright 2024 The Fuchsia Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
import asyncio
import atexit
import os
import random
import signal
import string
import subprocess
import sys
import tempfile
import typing
import test_list_file
def spawn(
tests: list[test_list_file.Test],
on_debugger_ready: typing.Callable[[], typing.Any],
break_on_failure: bool = False,
breakpoints: list[str] = [],
) -> subprocess.Popen[bytes]:
"""Spawn zxdb in a subprocess.
Spawn zxdb and attach to |tests|, while waiting for a test failure reported by either
exception or software breakpoint. Standard output for this program is redirected to a fifo,
which zxdb will stream to the console. The debugger is spawned in a synchronous process since
zxdb will be handling all of the stdio streams itself and will take control of forwarding IO
from python back to the console. The caller has no responsibility to deal with any input or
output from the spawned process.
Args:
tests (list[test_list_file.Test]): List of tests selected to be executed.
on_debugger_ready (typing.Callable): An async closure to be issued when the debugger is
ready for processes to be spawned.
break_on_failure (bool): Whether or not we are in break-on-failure mode.
breakpoints (list[str]): List of breakpoint locations to install. Note: this may slow down
test execution significantly.
Returns:
subprocess.Popen: process handle for the zxdb process group.
Note: The caller is responsible for killing the process group associated with the returned
process.
"""
fifo = os.path.join(
tempfile.gettempdir()
+ "/zxdbpipe-"
+ "".join(
random.choice(string.ascii_letters + string.digits)
for _ in range(6)
)
)
os.mkfifo(fifo)
attach_args = []
for test in tests:
# If there are no explicit breakpoints, then we can weakly attach to all tests. Explicit
# breakpoints require us to load symbols proactively.
if not breakpoints:
attach_args.extend(
["--execute", f"attach --weak --recursive {test.name()}"]
)
else:
attach_args.extend(
["--execute", f"attach --recursive {test.name()}"]
)
# If only --breakpoint was specified on the command line (we won't get here if neither
# debug option was specified), we want to output a more general message than "test
# failure". Zxdb will default to filling in the type of exception in this slot if it's
# unspecified, so we don't need to specify any additional text. If both options are
# specified, it is impossible to know which one will happen first, so use the more specific
# text.
embedded_mode_context_args = []
if break_on_failure:
embedded_mode_context_args = ["--embedded-mode-context", "test failure"]
zxdb_args = [
"fx",
"ffx",
"debug",
"connect",
"--new-agent",
"--",
*attach_args,
"--console-mode",
"embedded",
*embedded_mode_context_args,
"--stream-file",
fifo,
"--signal-when-ready",
str(os.getpid()),
]
# Add the requested breakpoints.
for bp in breakpoints:
zxdb_args += ["--execute", f"break {bp}"]
# Use start_new_session, rather than just using os.setpgrp as a preexec function. This enables
# the subprocess to also control the tty, which zxdb requires, as well as terminating the entire
# process group when all the tests have finished.
debugger_process = subprocess.Popen(
args=zxdb_args, start_new_session=True, stderr=subprocess.STDOUT
)
def on_sigusr1() -> None:
# Replace stdout with the named pipe we created and enable line buffering.
# Note: 1 == line buffered. See https://docs.python.org/3/library/functions.html#open.
sys.stdout = open(fifo, "w", buffering=1)
asyncio.create_task(on_debugger_ready())
# zxdb will send us a SIGUSR1 when it has successfully connected to DebugAgent and is ready to
# stream output.
loop = asyncio.get_event_loop()
loop.add_signal_handler(signal.SIGUSR1, on_sigusr1)
def _cleanup() -> None:
# Close stdout. This may have already been done at the end of all the tests in main.py, but
# we do it again here to catch the ctrl+c case and still try to cleanly restore the terminal
# and clean up the socket to DebugAgent.
sys.stdout.close()
sys.stdout = open(os.devnull, "w")
try:
os.remove(fifo)
except FileNotFoundError:
# The tests for this don't actually create a file for the fifo, and we don't want to
# throw an exception, since we were trying to remove the file anyway.
pass
try:
# Give zxdb a chance to gracefully shutdown, in the normal case this should return
# immediately, but when handling ctrl+c in embedded mode we wait some time to run
# cleanup routines.
debugger_process.wait(5)
except subprocess.TimeoutExpired as e:
sys.stderr.write(f"{e}\n")
sys.stderr.flush()
finally:
# zxdb should have gracefully exited by now, if it hasn't forcefully terminate and
# inform the user that they may need to reset their terminal.
if debugger_process.poll() is None:
sys.stderr.write(
"⚠️ Warning: zxdb did not exit normally. `reset` will fix your terminal ⚠️\n"
)
sys.stderr.flush()
debugger_process.terminate()
atexit.register(_cleanup)
return debugger_process