blob: cd44f04dafb2efb6f33e0bac0570e731f9691722 [file]
# Copyright 2026 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.
from __future__ import annotations
import re
from typing import TYPE_CHECKING, Final
from shared.protocol.base import Response
from shared.protocol.modules import (
COMMAND_NAME,
ModuleEntry,
ModulesRequest,
ModulesResponse,
)
from zxdb_dap import ZxdbConsoleCommandArguments
__all__ = [
"COMMAND_NAME",
"handle",
"parse_libs_output",
"parse_sym_stat_output",
]
if TYPE_CHECKING:
from daemon.daemon import Daemon
# In `sym-stat`, module names under `Process <N> symbol status` are indented by
# 2 spaces, while per-module key-value attributes are indented by 4 spaces.
_SYM_MODULE_INDENT: Final[str] = " "
_SYM_ATTR_INDENT: Final[str] = " "
_LIBS_ROW_RE = re.compile(
r"""
^\s*
(?P<base>0x[0-9a-fA-F]+) # Module load address in hex
\s+
(?P<name>\S.*?) # Module or shared library name
\s*$
""",
re.VERBOSE,
)
_PROCESS_SYM_HEADER_RE = re.compile(r"^\s*Process\s+\d+\s+symbol status\s*$")
_SYM_BASE_RE = re.compile(r"^\s*Base:\s*(?P<base>0x[0-9a-fA-F]+)\s*$")
_SYM_BUILD_ID_RE = re.compile(
r"""
^\s*Build[ ]ID:\s*
(?P<build_id>\S+?) # ELF GNU build-id hex string
(?:\s+\(Downloading\.\.\.\))? # Optional symbol server download indicator
\s*$
""",
re.VERBOSE,
)
_SYM_LOADED_RE = re.compile(
r"^\s*Symbols loaded:\s*(?P<loaded>Yes|No)\s*$", re.IGNORECASE
)
_SYM_FILE_RE = re.compile(r"^\s*Symbol file:\s*(?P<symbol_file>.+?)\s*$")
_SYM_FILES_INDEXED_RE = re.compile(
r"^\s*Source files indexed:\s*(?P<count>\d+)\s*$"
)
_SYM_FUNCS_INDEXED_RE = re.compile(r"^\s*Symbols indexed:\s*(?P<count>\d+)\s*$")
def parse_libs_output(text: str) -> list[ModuleEntry]:
"""Parses `zxdb` `libs` table output into `ModuleEntry` items."""
entries: list[ModuleEntry] = []
for line in text.splitlines():
m = _LIBS_ROW_RE.match(line)
if m:
entries.append(
ModuleEntry(
name=m.group("name").strip(),
base=m.group("base").lower(),
)
)
return entries
def _build_module_entry(
name: str, fields: dict[str, str | int | bool]
) -> ModuleEntry:
"""Constructs a `ModuleEntry` from parsed `sym-stat` key-value fields."""
build_id = fields.get("build_id")
symbols_loaded = fields.get("symbols_loaded")
symbol_file = fields.get("symbol_file")
files_indexed = fields.get("files_indexed")
functions_indexed = fields.get("functions_indexed")
return ModuleEntry(
name=name,
base=str(fields["base"]),
build_id=str(build_id) if build_id is not None else None,
symbols_loaded=(
bool(symbols_loaded) if symbols_loaded is not None else None
),
symbol_file=str(symbol_file) if symbol_file is not None else None,
files_indexed=(
int(files_indexed) if isinstance(files_indexed, int) else None
),
functions_indexed=(
int(functions_indexed)
if isinstance(functions_indexed, int)
else None
),
)
def parse_sym_stat_output(text: str) -> list[ModuleEntry]:
"""Parses the `Process <N> symbol status` section of `sym-stat` output."""
entries: list[ModuleEntry] = []
in_process_section = False
current_name: str | None = None
current_fields: dict[str, str | int | bool] = {}
def flush_current() -> None:
nonlocal current_name, current_fields
if current_name is not None and "base" in current_fields:
entries.append(_build_module_entry(current_name, current_fields))
current_name = None
current_fields = {}
for line in text.splitlines():
if _PROCESS_SYM_HEADER_RE.match(line):
in_process_section = True
continue
if not in_process_section:
continue
if 'Use "libs" to refresh' in line or "No known modules." in line:
flush_current()
break
if m_base := _SYM_BASE_RE.match(line):
current_fields["base"] = m_base.group("base").lower()
continue
if m_bid := _SYM_BUILD_ID_RE.match(line):
current_fields["build_id"] = m_bid.group("build_id")
continue
if m_loaded := _SYM_LOADED_RE.match(line):
current_fields["symbols_loaded"] = (
m_loaded.group("loaded").lower() == "yes"
)
continue
if m_file := _SYM_FILE_RE.match(line):
current_fields["symbol_file"] = m_file.group("symbol_file")
continue
if m_files := _SYM_FILES_INDEXED_RE.match(line):
current_fields["files_indexed"] = int(m_files.group("count"))
continue
if m_funcs := _SYM_FUNCS_INDEXED_RE.match(line):
current_fields["functions_indexed"] = int(m_funcs.group("count"))
continue
stripped = line.strip()
if (
stripped
and line.startswith(_SYM_MODULE_INDENT)
and not line.startswith(_SYM_ATTR_INDENT)
):
flush_current()
current_name = stripped
flush_current()
return entries
def _merge_module_entries(
libs_entries: list[ModuleEntry], sym_entries: list[ModuleEntry]
) -> list[ModuleEntry]:
"""Merges `libs` and `sym-stat` module entries by base address."""
by_base: dict[str, ModuleEntry] = {e.base.lower(): e for e in libs_entries}
for sym in sym_entries:
key = sym.base.lower()
if key in by_base:
existing = by_base[key]
existing.build_id = sym.build_id
existing.symbols_loaded = sym.symbols_loaded
existing.symbol_file = sym.symbol_file
existing.files_indexed = sym.files_indexed
existing.functions_indexed = sym.functions_indexed
else:
by_base[key] = sym
return sorted(
by_base.values(),
key=lambda m: int(m.base, 16) if m.base.startswith("0x") else 0,
)
async def handle(
daemon: Daemon, req: ModulesRequest
) -> Response[ModulesResponse]:
"""Handles a `modules` request by combining `libs` and `sym-stat` output."""
if not daemon.zxdb_writer:
return Response(
success=False, message="Not connected to zxdb DAP server"
)
pid, err = daemon.resolve_process_id(req.pid)
if err is not None:
return Response(success=False, message=err)
libs_cmd = daemon.scope_console_command(
"libs --reload" if req.force_reload else "libs", pid
)
try:
libs_resp = await daemon.dap_client.zxdb_console_command(
ZxdbConsoleCommandArguments(
command=libs_cmd,
)
)
if not libs_resp.success or libs_resp.body is None:
return Response(
success=False,
message=libs_resp.message or "Failed to query modules via libs",
)
libs_text = libs_resp.body.output or ""
libs_entries = parse_libs_output(libs_text)
if (
not libs_entries
and "Load address" not in libs_text
and libs_text.strip()
):
return Response(success=False, message=libs_text.strip())
sym_resp = await daemon.dap_client.zxdb_console_command(
ZxdbConsoleCommandArguments(
command=daemon.scope_console_command("sym-stat", pid),
)
)
sym_entries = (
parse_sym_stat_output(sym_resp.body.output or "")
if sym_resp.success and sym_resp.body is not None
else []
)
modules = _merge_module_entries(libs_entries, sym_entries)
if req.filter:
needle = req.filter.lower()
modules = [
m
for m in modules
if needle in m.name.lower()
or (m.build_id is not None and needle in m.build_id.lower())
or (
m.symbol_file is not None
and needle in m.symbol_file.lower()
)
]
return Response(
success=True,
body=ModulesResponse(pid=pid, modules=modules),
)
except Exception as e:
return Response(success=False, message=f"Failed to query modules: {e}")