blob: 6190838a603ff26ea80d8c2e30250c26dc3fce86 [file] [edit]
# 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
from collections.abc import Awaitable
import os
import random
import signal
import string
import subprocess
import sys
import tempfile
import typing
from daemon_manager import DaemonManager
from portpicker import portpicker
from event import EventRecorder
import test_list_file
async def _start_zxdb_daemon(
recorder: EventRecorder | None, port: int
) -> DaemonManager:
try:
manager = DaemonManager(port=port, connect_to_existing=True)
proc = await manager.start()
if proc is None:
raise RuntimeError(
"Daemon process was not started (already running or failed)"
)
except Exception as e:
raise RuntimeError(f"Failed to start zxdb-daemon: {e}") from e
# Print a hint when using DAP mode for how to connect with zxdb-cli.
if recorder is not None:
recorder.emit_info_message(
"A new fx debug cli session has been started automatically, "
"get started with `fx debug cli wait-for-event`."
)
return manager
def spawn(
tests: list[test_list_file.Test],
on_debugger_ready: typing.Callable[[], Awaitable[typing.Any]],
recorder: EventRecorder | None = None,
break_on_failure: bool = False,
enable_debug_adapter: bool = False,
debug_adapter_port: int | None = None,
breakpoints: list[str] | None = None,
) -> 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.
recorder (event.EventRecorder | None): Recorder for events. Defaults to None.
break_on_failure (bool): Whether or not we are in break-on-failure mode.
enable_debug_adapter (bool): Enables zxdb's DebugAdapter server.
debug_adapter_port (int | None): Sets the port for the DebugAdapter server. If None, a
random one is assigned.
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.
"""
breakpoints = breakpoints or []
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"]
port: int | None = None
debug_adapter_args = []
# |enable_debug_adapter| means we're always going to spawn a zxdb-daemon process now.
# zxdb-daemon will connect to the zxdb that we create below.
if enable_debug_adapter:
port = (
debug_adapter_port
if debug_adapter_port is not None
else portpicker.pick_unused_port()
)
debug_adapter_args = ["--enable-debug-adapter"]
debug_adapter_args.append("--debug-adapter-port")
debug_adapter_args.append(f"{port}")
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()),
*debug_adapter_args,
]
# Add the requested breakpoints.
for bp in breakpoints:
zxdb_args += ["--execute", f"break {bp}"]
# Use os.setpgrp instead of start_new_session=True, because we're going to explicitly handoff
# control of the foreground terminal to zxdb (and take it back during _cleanup below). Using
# start_new_session=True would create the process in a separate session, which will raise EPERM
# if we try to give it our TTY.
debugger_process = subprocess.Popen(
args=zxdb_args, preexec_fn=os.setpgrp, stderr=subprocess.STDOUT
)
# Give control of the tty to zxdb. This lets zxdb know that it has control of the TTY that we
# started with.
#
# Note that we do NOT need to set this back to our pgrp since our output will be routed through
# zxdb until the very end. If we try to call tcsetpgrp again during shutdown, we'll race with
# the shell and could leave a job suspended while waiting on an unnecessary SIGTTOU. This has no
# effect when running in debug_adapter mode since zxdb will not make use of terminal features in
# this mode.
os.tcsetpgrp(sys.stdin.fileno(), debugger_process.pid)
daemon_manager: DaemonManager | None = None
async def init_debugger() -> None:
nonlocal daemon_manager
try:
if enable_debug_adapter and port is not None:
daemon_manager = await _start_zxdb_daemon(recorder, port)
except Exception as e:
if recorder is not None:
recorder.emit_warning_message(
f"Failed to start zxdb-daemon: {e}"
)
await on_debugger_ready()
def on_sigusr1() -> None:
# Remove the signal handler immediately so it doesn't fire again.
loop.remove_signal_handler(signal.SIGUSR1)
asyncio.create_task(init_debugger())
# 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)
# 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
# Clean up daemon process if it was running
if daemon_manager is not None:
try:
daemon_manager.stop_sync(timeout=5.0)
except BaseException:
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