| # 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 signal | |
| 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_dir = tempfile.mkdtemp(prefix="zxdb-") | |
| fifo = os.path.join(fifo_dir, "zxdbpipe") | |
| 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. | |
| try: | |
| if hasattr(sys.stdin, "fileno") and os.isatty(sys.stdin.fileno()): | |
| os.tcsetpgrp(sys.stdin.fileno(), debugger_process.pid) | |
| except (OSError, Exception): | |
| pass | |
| 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_running_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. | |
| try: | |
| sys.stdout.close() | |
| except Exception: | |
| pass | |
| sys.stdout = open(os.devnull, "w") | |
| try: | |
| os.remove(fifo) | |
| except (FileNotFoundError, OSError): | |
| # 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: | |
| os.rmdir(fifo_dir) | |
| except (FileNotFoundError, OSError): | |
| 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 |