forked from metin-server/m2dev-server
223 lines
6.6 KiB
Python
223 lines
6.6 KiB
Python
#!/usr/bin/env python3
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
RUNTIME_ROOT = Path("{{RUNTIME_ROOT}}")
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(description="Generate a backtrace for a Metin runtime core file")
|
|
parser.add_argument("--core", help="Core file path. Defaults to the newest core under the runtime tree.")
|
|
parser.add_argument("--exe", help="Executable path override. If omitted, infer it from the core path.")
|
|
return parser.parse_args()
|
|
|
|
|
|
def ensure_root() -> None:
|
|
if os.geteuid() != 0:
|
|
raise SystemExit("Run as root.")
|
|
|
|
|
|
def run(command: list[str], check: bool = False) -> subprocess.CompletedProcess[str]:
|
|
return subprocess.run(command, check=check, capture_output=True, text=True)
|
|
|
|
|
|
def iter_core_files() -> list[Path]:
|
|
return sorted(
|
|
(path for path in RUNTIME_ROOT.glob("channels/**/core*") if path.is_file()),
|
|
key=lambda path: path.stat().st_mtime,
|
|
reverse=True,
|
|
)
|
|
|
|
|
|
def resolve_core_path(core_arg: str | None) -> Path:
|
|
if core_arg:
|
|
candidate = Path(core_arg)
|
|
if not candidate.is_absolute():
|
|
runtime_relative = RUNTIME_ROOT / core_arg
|
|
if runtime_relative.exists():
|
|
candidate = runtime_relative
|
|
candidate = candidate.resolve()
|
|
if not candidate.is_file():
|
|
raise SystemExit(f"Core file not found: {candidate}")
|
|
return candidate
|
|
|
|
cores = iter_core_files()
|
|
if not cores:
|
|
raise SystemExit(f"No core files found under {RUNTIME_ROOT}")
|
|
return cores[0]
|
|
|
|
|
|
def infer_execfn_from_file_output(core_path: Path) -> Path | None:
|
|
completed = run(["file", str(core_path)])
|
|
if completed.returncode != 0:
|
|
return None
|
|
|
|
match = re.search(r"execfn: '([^']+)'", completed.stdout)
|
|
if not match:
|
|
return None
|
|
|
|
candidate = Path(match.group(1))
|
|
if candidate.is_file():
|
|
return candidate
|
|
return None
|
|
|
|
|
|
def infer_executable(core_path: Path, exe_arg: str | None) -> Path:
|
|
if exe_arg:
|
|
exe_path = Path(exe_arg).resolve()
|
|
if not exe_path.is_file():
|
|
raise SystemExit(f"Executable not found: {exe_path}")
|
|
return exe_path
|
|
|
|
execfn_candidate = infer_execfn_from_file_output(core_path)
|
|
|
|
parent_name = core_path.parent.name
|
|
grandparent_name = core_path.parent.parent.name if core_path.parent.parent else ""
|
|
|
|
candidates: list[Path] = []
|
|
if execfn_candidate:
|
|
candidates.append(execfn_candidate)
|
|
|
|
if parent_name == "db":
|
|
candidates.append(core_path.parent / "db")
|
|
elif parent_name == "auth":
|
|
candidates.append(core_path.parent / "game_auth")
|
|
elif parent_name.startswith("core") and grandparent_name.startswith("channel"):
|
|
candidates.append(core_path.parent / f"{grandparent_name}_{parent_name}")
|
|
|
|
for candidate in candidates:
|
|
if candidate.is_file():
|
|
return candidate.resolve()
|
|
|
|
raise SystemExit(f"Could not infer executable for core file: {core_path}")
|
|
|
|
|
|
def preferred_debugger() -> str | None:
|
|
for tool in ("gdb", "lldb"):
|
|
if shutil.which(tool):
|
|
return tool
|
|
return None
|
|
|
|
|
|
def format_section(title: str, body: str) -> str:
|
|
return f"== {title} ==\n{body.rstrip()}\n"
|
|
|
|
|
|
def render_file_info(path: Path) -> str:
|
|
completed = run(["file", str(path)])
|
|
body = completed.stdout or completed.stderr or "<no output>"
|
|
return format_section(f"file {path}", body)
|
|
|
|
|
|
def render_executable_freshness(core_path: Path, exe_path: Path) -> str:
|
|
core_stat = core_path.stat()
|
|
exe_stat = exe_path.stat()
|
|
core_mtime = datetime.fromtimestamp(core_stat.st_mtime, tz=timezone.utc).isoformat()
|
|
exe_mtime = datetime.fromtimestamp(exe_stat.st_mtime, tz=timezone.utc).isoformat()
|
|
|
|
lines = [
|
|
f"core_mtime: {core_mtime}",
|
|
f"exe_mtime: {exe_mtime}",
|
|
]
|
|
|
|
if exe_stat.st_mtime > core_stat.st_mtime + 1:
|
|
lines.append(
|
|
"warning: executable is newer than the core file; symbols may not match. "
|
|
"Prefer an executable snapshot from an incident bundle or pass --exe explicitly."
|
|
)
|
|
else:
|
|
lines.append("status: executable is not newer than the core file")
|
|
|
|
return format_section("core/executable freshness", "\n".join(lines))
|
|
|
|
|
|
def render_readelf_notes(core_path: Path) -> str:
|
|
if not shutil.which("readelf"):
|
|
return ""
|
|
completed = run(["readelf", "-n", str(core_path)])
|
|
body = completed.stdout or completed.stderr or "<no output>"
|
|
return format_section(f"readelf -n {core_path}", body)
|
|
|
|
|
|
def render_debugger_backtrace(debugger: str, exe_path: Path, core_path: Path) -> str:
|
|
if debugger == "gdb":
|
|
command = [
|
|
"gdb",
|
|
"-batch",
|
|
"-ex",
|
|
"set pagination off",
|
|
"-ex",
|
|
"thread apply all bt full",
|
|
str(exe_path),
|
|
str(core_path),
|
|
]
|
|
elif debugger == "lldb":
|
|
command = [
|
|
"lldb",
|
|
"--batch",
|
|
"-o",
|
|
"thread backtrace all",
|
|
"-c",
|
|
str(core_path),
|
|
str(exe_path),
|
|
]
|
|
else:
|
|
raise SystemExit(f"Unsupported debugger: {debugger}")
|
|
|
|
completed = run(command)
|
|
output = completed.stdout or completed.stderr or "<no output>"
|
|
return format_section("backtrace", f"$ {' '.join(command)}\n\n{output}")
|
|
|
|
|
|
def main() -> int:
|
|
args = parse_args()
|
|
ensure_root()
|
|
|
|
core_path = resolve_core_path(args.core)
|
|
exe_path = infer_executable(core_path, args.exe)
|
|
debugger = preferred_debugger()
|
|
|
|
sections = [
|
|
format_section(
|
|
"summary",
|
|
"\n".join(
|
|
[
|
|
f"core: {core_path}",
|
|
f"executable: {exe_path}",
|
|
f"debugger: {debugger or '<none>'}",
|
|
]
|
|
),
|
|
),
|
|
render_file_info(core_path),
|
|
render_file_info(exe_path),
|
|
render_executable_freshness(core_path, exe_path),
|
|
]
|
|
|
|
readelf_section = render_readelf_notes(core_path)
|
|
if readelf_section:
|
|
sections.append(readelf_section)
|
|
|
|
if debugger:
|
|
sections.append(render_debugger_backtrace(debugger, exe_path, core_path))
|
|
else:
|
|
sections.append(
|
|
format_section(
|
|
"backtrace",
|
|
"No supported debugger found. Install gdb or lldb on the host to generate a stack trace.",
|
|
)
|
|
)
|
|
|
|
print("\n".join(section.rstrip() for section in sections if section).rstrip())
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|