| 1 | #!/usr/bin/env bash |
| 2 | # Measure one desktop shell build: launch with a disposable data home, wait for |
| 3 | # the Go lifecycle diagnostics to reach "healthy", then sample the full process |
| 4 | # tree (shell, renderer/GPU helpers and the Go service) at fixed offsets. |
| 5 | # Output is one JSON document; compare runs of this script only, never against |
| 6 | # a differently sampled figure. The retired Wails shell's baseline runs live in |
| 7 | # docs/desktop-migration/baseline/. |
| 8 | # |
| 9 | # Usage: scripts/desktop-shell-metrics.sh <executable> <label> <out.json> [idle_seconds] |
| 10 | # executable: the packaged app's main executable |
| 11 | # label: free text recorded in the output (e.g. electron-darwin-arm64) |
| 12 | set -euo pipefail |
| 13 | |
| 14 | exe="${1:?usage: desktop-shell-metrics.sh <executable> <label> <out.json> [idle_seconds]}" |
| 15 | label="${2:?label}" |
| 16 | out="${3:?out.json}" |
| 17 | idle="${4:-30}" |
| 18 | |
| 19 | home="$(mktemp -d "${TMPDIR:-/tmp}/reasonix-shell-metrics.XXXXXX")" |
| 20 | cleanup() { |
| 21 | if [ -n "${pid:-}" ] && kill -0 "$pid" 2>/dev/null; then |
| 22 | kill -TERM "$pid" 2>/dev/null || true |
| 23 | for _ in $(seq 1 50); do |
| 24 | kill -0 "$pid" 2>/dev/null || break |
| 25 | sleep 0.1 |
| 26 | done |
| 27 | kill -KILL "$pid" 2>/dev/null || true |
| 28 | fi |
| 29 | rm -rf "$home" |
| 30 | } |
| 31 | trap cleanup EXIT |
| 32 | |
| 33 | now_ms() { python3 -c 'import time; print(int(time.time()*1000))'; } |
| 34 | |
| 35 | # Process-tree RSS in KiB: the launched pid, its descendants, and helper |
| 36 | # processes that started after launch and belong to the same shell family |
| 37 | # (WebKit XPC helpers on macOS re-parent to launchd; Electron helpers do not). |
| 38 | tree_rss() { |
| 39 | python3 - "$pid" "$launch_epoch" <<'PY' |
| 40 | import subprocess, sys, time |
| 41 | root = int(sys.argv[1]); launched = float(sys.argv[2]) |
| 42 | rows = subprocess.run(["ps", "-axo", "pid=,ppid=,rss=,lstart=,comm="], capture_output=True, text=True).stdout.splitlines() |
| 43 | procs = {} |
| 44 | for row in rows: |
| 45 | parts = row.split(None, 3) |
| 46 | if len(parts) < 4: |
| 47 | continue |
| 48 | pid, ppid, rss = int(parts[0]), int(parts[1]), int(parts[2]) |
| 49 | rest = parts[3] |
| 50 | # lstart is 5 whitespace-separated fields; the remainder is comm. |
| 51 | fields = rest.split(None, 5) |
| 52 | if len(fields) < 6: |
| 53 | continue |
| 54 | started = " ".join(fields[:5]); comm = fields[5] |
| 55 | try: |
| 56 | epoch = time.mktime(time.strptime(started, "%a %b %d %H:%M:%S %Y")) |
| 57 | except ValueError: |
| 58 | epoch = 0 |
| 59 | procs[pid] = (ppid, rss, epoch, comm) |
| 60 | family = set() |
| 61 | def descend(p): |
| 62 | for q, (pp, _, _, _) in procs.items(): |
| 63 | if pp == p and q not in family: |
| 64 | family.add(q); descend(q) |
| 65 | if root in procs: |
| 66 | family.add(root); descend(root) |
| 67 | helpers = ("WebKit", "Reasonix Helper", "reasonix-desktop", "Electron Helper", "reasonix", "chrome_crashpad") |
| 68 | for p, (pp, rss, epoch, comm) in procs.items(): |
| 69 | if p in family: |
| 70 | continue |
| 71 | if epoch >= launched - 1 and any(h in comm for h in helpers): |
| 72 | family.add(p) |
| 73 | total = sum(procs[p][1] for p in family) |
| 74 | print(total, len(family), ";".join(sorted({procs[p][3].rsplit('/',1)[-1] for p in family}))) |
| 75 | PY |
| 76 | } |
| 77 | |
| 78 | lifecycle_phase() { |
| 79 | python3 - "$home" "$pid" <<'PY' |
| 80 | import glob, json, os, sys |
| 81 | home, pid = sys.argv[1], sys.argv[2] |
| 82 | # The Go process writes the file under its own pid; a fresh home has one. |
| 83 | paths = glob.glob(os.path.join(home, "**", "diagnostics", "lifecycle", "*.json"), recursive=True) |
| 84 | for path in sorted(paths, key=os.path.getmtime, reverse=True): |
| 85 | try: |
| 86 | with open(path) as fh: |
| 87 | state = json.load(fh) |
| 88 | print(state.get("phase", ""), state.get("startedAt", ""), state.get("updatedAt", "")) |
| 89 | break |
| 90 | except (OSError, ValueError): |
| 91 | pass |
| 92 | PY |
| 93 | } |
| 94 | |
| 95 | export REASONIX_HOME="$home" REASONIX_STATE_HOME="$home" REASONIX_CACHE_HOME="$home/cache" REASONIX_DEV=1 |
| 96 | launch_epoch="$(python3 -c 'import time; print(time.time())')" |
| 97 | t0="$(now_ms)" |
| 98 | "$exe" >"$home/stdout.log" 2>"$home/stderr.log" & |
| 99 | pid=$! |
| 100 | |
| 101 | phase=""; healthy_ms=""; ready_ms="" |
| 102 | for _ in $(seq 1 600); do |
| 103 | sleep 0.1 |
| 104 | kill -0 "$pid" 2>/dev/null || { echo "shell exited before becoming healthy" >&2; cat "$home/stderr.log" >&2; exit 1; } |
| 105 | read -r phase _ _ < <(lifecycle_phase) || true |
| 106 | case "$phase" in |
| 107 | ready) [ -n "$ready_ms" ] || ready_ms=$(( $(now_ms) - t0 )) ;; |
| 108 | healthy) [ -n "$ready_ms" ] || ready_ms=$(( $(now_ms) - t0 )); healthy_ms=$(( $(now_ms) - t0 )); break ;; |
| 109 | esac |
| 110 | done |
| 111 | [ -n "$healthy_ms" ] || { echo "shell never reported healthy (last phase: '$phase')" >&2; exit 1; } |
| 112 | |
| 113 | samples="[" |
| 114 | for offset in 2 10 "$idle"; do |
| 115 | sleep "$offset" |
| 116 | read -r rss_kib count names < <(tree_rss) |
| 117 | samples+="{\"afterHealthySeconds\":$offset,\"rssKiB\":$rss_kib,\"processCount\":$count,\"processes\":\"$names\"}," |
| 118 | done |
| 119 | samples="${samples%,}]" |
| 120 | |
| 121 | kill -TERM "$pid" |
| 122 | exit_t0="$(now_ms)" |
| 123 | for _ in $(seq 1 100); do |
| 124 | kill -0 "$pid" 2>/dev/null || break |
| 125 | sleep 0.1 |
| 126 | done |
| 127 | exit_ms=$(( $(now_ms) - exit_t0 )) |
| 128 | kill -0 "$pid" 2>/dev/null && exit_clean=false || exit_clean=true |
| 129 | |
| 130 | python3 - "$out" "$label" "$exe" "$ready_ms" "$healthy_ms" "$samples" "$exit_ms" "$exit_clean" <<'PY' |
| 131 | import json, platform, sys, datetime |
| 132 | out, label, exe, ready, healthy, samples, exit_ms, exit_clean = sys.argv[1:] |
| 133 | doc = { |
| 134 | "label": label, "executable": exe, |
| 135 | "measuredAt": datetime.datetime.now(datetime.timezone.utc).isoformat(), |
| 136 | "machine": {"platform": platform.platform(), "machine": platform.machine()}, |
| 137 | "startup": {"readyMs": int(ready), "healthyMs": int(healthy)}, |
| 138 | "samples": json.loads(samples), |
| 139 | "exit": {"terminatedWithinMs": int(exit_ms), "clean": exit_clean == "true"}, |
| 140 | } |
| 141 | with open(out, "w") as fh: |
| 142 | json.dump(doc, fh, indent=2); fh.write("\n") |
| 143 | print(json.dumps(doc, indent=2)) |
| 144 | PY |
| 145 |