diff --git a/Makefile b/Makefile index 4ae7457..1f0824b 100644 --- a/Makefile +++ b/Makefile @@ -191,6 +191,9 @@ web-build: ## Сборка web-клиента в web/dist DESKTOP_DIR := $(ROOT)/desktop DESKTOP_TAURI_DIR := $(DESKTOP_DIR)/src-tauri DESKTOP_KEY ?= $(BUILD_DIR)/desktop-keys/glchat-updater.key +# Бандлы Tauri: `all` — обычная сборка, `app` — только .app (и артефакты +# обновления) без .dmg: сборка .dmg монтирует образ и требует Finder. +DESKTOP_BUNDLES ?= all TAURI_CLI ?= npx --yes @tauri-apps/cli@^2 export CARGO_HOME ?= $(CACHE_DIR)/cargo export NPM_CONFIG_CACHE ?= $(CACHE_DIR)/npm @@ -208,17 +211,22 @@ desktop-build: ## Собрать desktop-приложение (бандлы по cd $(DESKTOP_TAURI_DIR) && \ TAURI_SIGNING_PRIVATE_KEY="$$(cat $(DESKTOP_KEY))" \ TAURI_SIGNING_PRIVATE_KEY_PASSWORD="$${TAURI_SIGNING_PRIVATE_KEY_PASSWORD:-glchat-local-dev}" \ - $(TAURI_CLI) build; \ + $(TAURI_CLI) build --bundles $(DESKTOP_BUNDLES); \ else \ echo " ключа $(DESKTOP_KEY) нет: сборка без артефактов автообновления"; \ cd $(DESKTOP_TAURI_DIR) && \ - $(TAURI_CLI) build --config '{"bundle":{"createUpdaterArtifacts":false}}'; \ + $(TAURI_CLI) build --bundles $(DESKTOP_BUNDLES) \ + --config '{"bundle":{"createUpdaterArtifacts":false}}'; \ fi .PHONY: desktop-run desktop-run: ## Запустить собранное приложение (macOS); доп. аргументы — ARGS=… open -a "$(DESKTOP_TAURI_DIR)/target/release/bundle/macos/glchat.app" $(ARGS) +.PHONY: desktop-build-app +desktop-build-app: ## Собрать .app и артефакты обновления без .dmg (dmg требует Finder/AppleScript) + @$(MAKE) --no-print-directory desktop-build DESKTOP_BUNDLES=app + # Каталог обновлений инстанса и его публичный адрес (docs/client-tauri.md §4). # Раскладку делает scripts/desktop-release.sh: манифест версии + latest.json, # артефакт и подпись; приложение отдаёт их по /updates/{target}/{arch}/{version}. diff --git a/scripts/desktop-perf.py b/scripts/desktop-perf.py new file mode 100644 index 0000000..8858835 --- /dev/null +++ b/scripts/desktop-perf.py @@ -0,0 +1,319 @@ +#!/usr/bin/env python3 +"""desktop-perf.py — замеры холодного старта и памяти desktop-обёртки. + +Требование спецификации: idle RAM ≤ 350 MB, холодный старт ≤ 2 с +(docs/client-tauri.md §1, §7). Скрипт запускает собранный бандл, замеряет время +от старта процесса до загрузки страницы клиента (строка «загружена страница» в +логе обёртки) и память после того, как клиент успокоился. + +Память считается по дереву процессов: на macOS страница живёт в отдельных +процессах WebKit (`WebContent`, `GPU`, `Networking`), поэтому одного RSS +главного процесса мало. Помощники WebKit запускаются через launchd (их +ppid = 1), поэтому «свои» определяются сравнением списка процессов до и после +запуска приложения. + +Примеры: + python3 scripts/desktop-perf.py # 3 холодных старта + простой + python3 scripts/desktop-perf.py --runs 5 --json build/desktop-perf.json +""" + +from __future__ import annotations + +import argparse +import json +import os +import platform +import signal +import subprocess +import sys +import time +from dataclasses import dataclass +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +DEFAULT_APP = REPO / "desktop/src-tauri/target/release/bundle/macos/glchat.app" +APP_MARKER = "glchat.app/Contents/MacOS/glchat-desktop" +PAGE_LOADED = "загружена страница" + + +@dataclass +class ProcInfo: + """Сведения о процессе, нужные для замера.""" + + pid: int + ppid: int + path: str + rss: int + footprint: int + + +class MacProcessTable: + """Чтение процессов через libproc (ps и top в песочнице недоступны).""" + + PROC_ALL_PIDS = 1 + PROC_PIDTBSDINFO = 3 + RUSAGE_INFO_V2 = 2 + + def __init__(self) -> None: + import ctypes + import ctypes.util + + self.ctypes = ctypes + self.libproc = ctypes.CDLL(ctypes.util.find_library("proc"), use_errno=True) + + class BsdInfo(ctypes.Structure): + """struct proc_bsdinfo: нужны pbi_ppid и pbi_pid.""" + + _fields_ = [ + ("pbi_flags", ctypes.c_uint32), ("pbi_status", ctypes.c_uint32), + ("pbi_xstatus", ctypes.c_uint32), ("pbi_pid", ctypes.c_uint32), + ("pbi_ppid", ctypes.c_uint32), ("pbi_uid", ctypes.c_uint32), + ("pbi_gid", ctypes.c_uint32), ("pbi_ruid", ctypes.c_uint32), + ("pbi_rgid", ctypes.c_uint32), ("pbi_svuid", ctypes.c_uint32), + ("pbi_svgid", ctypes.c_uint32), ("rfu_1", ctypes.c_uint32), + ("pbi_comm", ctypes.c_char * 16), ("pbi_name", ctypes.c_char * 32), + ("pbi_nfiles", ctypes.c_uint32), ("pbi_pgid", ctypes.c_uint32), + ("pbi_pjobc", ctypes.c_uint32), ("e_tdev", ctypes.c_uint32), + ("e_tpgid", ctypes.c_uint32), ("pbi_nice", ctypes.c_int32), + ("pbi_start_tvsec", ctypes.c_uint64), ("pbi_start_tvusec", ctypes.c_uint64), + ] + + self.bsd_info = BsdInfo + + def _ppid(self, pid: int) -> int | None: + ctypes = self.ctypes + info = self.bsd_info() + size = self.libproc.proc_pidinfo( + ctypes.c_int(pid), ctypes.c_int(self.PROC_PIDTBSDINFO), ctypes.c_uint64(0), + ctypes.byref(info), ctypes.c_int(ctypes.sizeof(info)), + ) + return int(info.pbi_ppid) if size == ctypes.sizeof(info) else None + + def _rusage(self, pid: int) -> tuple[int, int]: + """RSS и физический след процесса (rusage_info_v2).""" + ctypes = self.ctypes + buf = (ctypes.c_uint64 * 32)() + if self.libproc.proc_pid_rusage(ctypes.c_int(pid), ctypes.c_int(self.RUSAGE_INFO_V2), ctypes.byref(buf)) != 0: + return 0, 0 + # ri_uuid занимает два 64-битных слова, дальше: resident_size, phys_footprint. + return int(buf[8]), int(buf[9]) + + def _path(self, pid: int) -> str: + ctypes = self.ctypes + buf = ctypes.create_string_buffer(4096) + size = self.libproc.proc_pidpath(ctypes.c_int(pid), buf, ctypes.c_uint32(4096)) + return buf.value.decode("utf-8", "replace") if size > 0 else "" + + def snapshot(self) -> dict[int, ProcInfo]: + ctypes = self.ctypes + count = 8192 + buf = (ctypes.c_int * count)() + size = self.libproc.proc_listpids( + ctypes.c_int(self.PROC_ALL_PIDS), ctypes.c_uint32(0), ctypes.byref(buf), + ctypes.c_int(ctypes.sizeof(buf)), + ) + result: dict[int, ProcInfo] = {} + for index in range(max(size, 0) // ctypes.sizeof(ctypes.c_int)): + pid = buf[index] + if pid <= 0: + continue + ppid = self._ppid(pid) + if ppid is None: + continue + resident, footprint = self._rusage(pid) + result[pid] = ProcInfo( + pid=pid, ppid=ppid, path=self._path(pid), rss=resident, footprint=footprint, + ) + return result + + +class LinuxProcessTable: + """Чтение процессов через /proc (для сборок под Linux).""" + + def snapshot(self) -> dict[int, ProcInfo]: + result: dict[int, ProcInfo] = {} + for entry in Path("/proc").iterdir(): + if not entry.name.isdigit(): + continue + pid = int(entry.name) + try: + fields = (entry / "stat").read_text().rsplit(") ", 1)[1].split() + ppid = int(fields[1]) + rss = int(fields[21]) * os.sysconf("SC_PAGE_SIZE") + path = os.readlink(entry / "exe") + except (OSError, IndexError, ValueError): + continue + result[pid] = ProcInfo(pid=pid, ppid=ppid, path=path, rss=rss, footprint=rss) + return result + + +def process_table(): + if platform.system() == "Darwin": + return MacProcessTable() + return LinuxProcessTable() + + +def app_pids(table, marker: str = APP_MARKER) -> list[int]: + return [info.pid for info in table.snapshot().values() if marker in info.path] + + +def log_candidates() -> list[Path]: + home = Path.home() + return [ + home / "Library/Logs/su.mhspx.glchat/glchat.log", + home / ".local/share/su.mhspx.glchat/logs/glchat.log", + home / ".config/su.mhspx.glchat/logs/glchat.log", + ] + + +def log_file() -> Path | None: + existing = [path for path in log_candidates() if path.exists()] + if not existing: + return None + return max(existing, key=lambda path: path.stat().st_mtime) + + +def quit_app(table, timeout: float = 15.0) -> None: + """Завершает запущенные экземпляры обёртки и ждёт их исчезновения.""" + for pid in app_pids(table): + try: + os.kill(pid, signal.SIGTERM) + except OSError: + pass + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if not app_pids(table): + time.sleep(0.6) # окну и WebKit нужно время освободить ресурсы + return + time.sleep(0.1) + raise SystemExit("не удалось завершить запущенный экземпляр glchat") + + +def measure_cold_start(table, app: Path, extra_args: list[str], timeout: float = 30.0) -> dict: + """Запускает приложение и измеряет время до загрузки страницы клиента.""" + before = table.snapshot() + log = log_file() + offset = log.stat().st_size if log is not None else 0 + + started = time.monotonic() + subprocess.run(["open", "-a", str(app), "--args", *extra_args], check=True) + + pid: int | None = None + pid_time: float | None = None + page_time: float | None = None + deadline = started + timeout + while time.monotonic() < deadline: + if pid is None: + found = app_pids(table) + if found: + pid = found[0] + pid_time = time.monotonic() + if log is not None: + with log.open("rb") as handle: + handle.seek(offset) + chunk = handle.read().decode("utf-8", "replace") + if PAGE_LOADED in chunk: + page_time = time.monotonic() + break + time.sleep(0.02) + + if pid is None or page_time is None: + raise SystemExit("приложение не запустилось или страница не загрузилась: смотрите лог обёртки") + + return { + "pid": pid, + "open_ms": round((pid_time - started) * 1000), + "page_ms": round((page_time - pid_time) * 1000), + "total_ms": round((page_time - started) * 1000), + "before_pids": set(before), + } + + +def measure_memory(table, pid: int, before_pids: set[int], settle: float) -> dict: + """Суммирует память обёртки и её процессов WebKit после паузы.""" + time.sleep(settle) + snapshot = table.snapshot() + own = snapshot.get(pid) + helpers = [ + info for info in snapshot.values() + if info.pid != pid and info.pid not in before_pids and "WebKit" in info.path + ] + rss = (own.rss if own else 0) + sum(info.rss for info in helpers) + footprint = (own.footprint if own else 0) + sum(info.footprint for info in helpers) + return { + "rss_mb": round(rss / 1024 / 1024, 1), + "footprint_mb": round(footprint / 1024 / 1024, 1), + "app_rss_mb": round((own.rss if own else 0) / 1024 / 1024, 1), + "webkit_rss_mb": round(sum(info.rss for info in helpers) / 1024 / 1024, 1), + "webkit_processes": len(helpers), + } + + +def main() -> int: + parser = argparse.ArgumentParser(description="Замеры холодного старта и памяти glchat.app") + parser.add_argument("--app", type=Path, default=DEFAULT_APP, help="путь к собранному .app") + parser.add_argument("--runs", type=int, default=3, help="сколько холодных стартов измерить") + parser.add_argument("--settle", type=float, default=20.0, help="пауза перед замером памяти, с") + parser.add_argument("--json", type=Path, default=None, help="сохранить результаты в JSON") + args = parser.parse_args() + + if not args.app.exists(): + print(f"нет собранного приложения: {args.app} (сначала make desktop-build)", file=sys.stderr) + return 1 + + table = process_table() + results: dict[str, object] = {"app": str(args.app), "runs": []} + + # Настройки обёртки замер не меняет: приложение запускается как обычно, + # автозапуск не включается и не выключается. + for _ in range(args.runs): + quit_app(table) + run = measure_cold_start(table, args.app, []) + run.update(measure_memory(table, run["pid"], run.pop("before_pids"), args.settle)) + results["runs"].append(run) + print( + f"старт: open→процесс {run['open_ms']} мс, процесс→страница {run['page_ms']} мс, " + f"итого {run['total_ms']} мс; память RSS {run['rss_mb']} МБ " + f"(обёртка {run['app_rss_mb']} + WebKit {run['webkit_rss_mb']}), " + f"footprint {run['footprint_mb']} МБ" + ) + + # Простой в трее: окно скрыто, клиент загружен (closeToTray). + quit_app(table) + tray = measure_cold_start(table, args.app, ["--minimized"]) + tray.update(measure_memory(table, tray["pid"], tray.pop("before_pids"), args.settle)) + results["tray"] = tray + print( + f"трей: старт {tray['total_ms']} мс; память RSS {tray['rss_mb']} МБ " + f"(обёртка {tray['app_rss_mb']} + WebKit {tray['webkit_rss_mb']}), " + f"footprint {tray['footprint_mb']} МБ" + ) + + cold = [run["total_ms"] for run in results["runs"]] + results["summary"] = { + "cold_ms_min": min(cold), + "cold_ms_max": max(cold), + "cold_ms_avg": round(sum(cold) / len(cold)), + "ram_open_mb": max(run["rss_mb"] for run in results["runs"]), + "ram_tray_mb": tray["rss_mb"], + "budget_ram_mb": 350, + "budget_cold_ms": 2000, + } + print( + f"итог: холодный старт {results['summary']['cold_ms_min']}–" + f"{results['summary']['cold_ms_max']} мс (бюджет 2000), " + f"RAM открытого клиента {results['summary']['ram_open_mb']} МБ, " + f"в трее {results['summary']['ram_tray_mb']} МБ (бюджет 350)" + ) + + if args.json is not None: + args.json.parent.mkdir(parents=True, exist_ok=True) + args.json.write_text(json.dumps(results, ensure_ascii=False, indent=2) + "\n") + print(f"результаты: {args.json}") + + quit_app(table) + return 0 + + +if __name__ == "__main__": + sys.exit(main())