test(desktop): замеры холодного старта и памяти обёртки
Требование спецификации §1/§7 (idle RAM ≤ 350 МБ, холодный старт ≤ 2 с) до сих пор не было измерено. `scripts/desktop-perf.py` запускает собранный `.app`, меряет время от `open` до строки «загружена страница» в логе обёртки и память после того, как клиент успокоился. `ps` и `top` в песочнице агента недоступны, поэтому на macOS память и список процессов читаются через `libproc` (`proc_pid_rusage`, `proc_listpids`), а для Linux есть ветка на `/proc`. Помощники WebKit (`WebContent`, `GPU`, `Networking`) запускаются через launchd, их родитель — не приложение, поэтому «свои» определяются сравнением списка процессов до и после запуска: иначе RSS главного процесса занижал бы потребление втрое. Замеры (macOS arm64, Mac на M-серии, инстанс через внешний прокси): холодный старт 708–740 мс, RSS открытого клиента 252 МБ (обёртка 117 + WebKit 135), в трее 251 МБ, физический след 103 МБ. Результаты — в `docs/PERF.md` и `desktop/README.md`. В Makefile добавлена цель `desktop-build-app` (`tauri build --bundles app`): артефакты обновления собираются без `.dmg`, который в песочнице и headless- окружении падает на `bundle_dmg.sh` (монтирование образа + AppleScript).
This commit is contained in:
@@ -191,6 +191,9 @@ web-build: ## Сборка web-клиента в web/dist
|
|||||||
DESKTOP_DIR := $(ROOT)/desktop
|
DESKTOP_DIR := $(ROOT)/desktop
|
||||||
DESKTOP_TAURI_DIR := $(DESKTOP_DIR)/src-tauri
|
DESKTOP_TAURI_DIR := $(DESKTOP_DIR)/src-tauri
|
||||||
DESKTOP_KEY ?= $(BUILD_DIR)/desktop-keys/glchat-updater.key
|
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
|
TAURI_CLI ?= npx --yes @tauri-apps/cli@^2
|
||||||
export CARGO_HOME ?= $(CACHE_DIR)/cargo
|
export CARGO_HOME ?= $(CACHE_DIR)/cargo
|
||||||
export NPM_CONFIG_CACHE ?= $(CACHE_DIR)/npm
|
export NPM_CONFIG_CACHE ?= $(CACHE_DIR)/npm
|
||||||
@@ -208,17 +211,22 @@ desktop-build: ## Собрать desktop-приложение (бандлы по
|
|||||||
cd $(DESKTOP_TAURI_DIR) && \
|
cd $(DESKTOP_TAURI_DIR) && \
|
||||||
TAURI_SIGNING_PRIVATE_KEY="$$(cat $(DESKTOP_KEY))" \
|
TAURI_SIGNING_PRIVATE_KEY="$$(cat $(DESKTOP_KEY))" \
|
||||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD="$${TAURI_SIGNING_PRIVATE_KEY_PASSWORD:-glchat-local-dev}" \
|
TAURI_SIGNING_PRIVATE_KEY_PASSWORD="$${TAURI_SIGNING_PRIVATE_KEY_PASSWORD:-glchat-local-dev}" \
|
||||||
$(TAURI_CLI) build; \
|
$(TAURI_CLI) build --bundles $(DESKTOP_BUNDLES); \
|
||||||
else \
|
else \
|
||||||
echo " ключа $(DESKTOP_KEY) нет: сборка без артефактов автообновления"; \
|
echo " ключа $(DESKTOP_KEY) нет: сборка без артефактов автообновления"; \
|
||||||
cd $(DESKTOP_TAURI_DIR) && \
|
cd $(DESKTOP_TAURI_DIR) && \
|
||||||
$(TAURI_CLI) build --config '{"bundle":{"createUpdaterArtifacts":false}}'; \
|
$(TAURI_CLI) build --bundles $(DESKTOP_BUNDLES) \
|
||||||
|
--config '{"bundle":{"createUpdaterArtifacts":false}}'; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
.PHONY: desktop-run
|
.PHONY: desktop-run
|
||||||
desktop-run: ## Запустить собранное приложение (macOS); доп. аргументы — ARGS=…
|
desktop-run: ## Запустить собранное приложение (macOS); доп. аргументы — ARGS=…
|
||||||
open -a "$(DESKTOP_TAURI_DIR)/target/release/bundle/macos/glchat.app" $(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).
|
# Каталог обновлений инстанса и его публичный адрес (docs/client-tauri.md §4).
|
||||||
# Раскладку делает scripts/desktop-release.sh: манифест версии + latest.json,
|
# Раскладку делает scripts/desktop-release.sh: манифест версии + latest.json,
|
||||||
# артефакт и подпись; приложение отдаёт их по /updates/{target}/{arch}/{version}.
|
# артефакт и подпись; приложение отдаёт их по /updates/{target}/{arch}/{version}.
|
||||||
|
|||||||
@@ -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())
|
||||||
Reference in New Issue
Block a user