Files
m2dev-server/deploy/systemd/bin/metin-core-backtrace.in
2026-04-14 17:05:11 +02:00

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())